diff --git a/Makefile b/Makefile index 3d0300f99d..a9dbf7c09a 100644 --- a/Makefile +++ b/Makefile @@ -368,6 +368,12 @@ build-byo-a2a: buildx-create $(DOCKER_BUILDER) $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) --build-arg BUILD_PACKAGE=core/test/byoa2a/main.go -t $(DOCKER_REGISTRY)/$(DOCKER_REPO)/byo-a2a:$(VERSION) -f go/Dockerfile ./go $(DOCKER_PUSH) $(DOCKER_REGISTRY)/$(DOCKER_REPO)/byo-a2a:$(VERSION) +.PHONY: build-workflow-agent +build-workflow-agent: ## Build and push the generic BYO image for declarative workflow agents +build-workflow-agent: buildx-create + $(DOCKER_BUILDER) $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) --build-arg BUILD_PACKAGE=./adk/examples/declarative -t $(DOCKER_REGISTRY)/$(DOCKER_REPO)/workflow-agent:$(VERSION) -f go/Dockerfile ./go + $(DOCKER_PUSH) $(DOCKER_REGISTRY)/$(DOCKER_REPO)/workflow-agent:$(VERSION) + .PHONY: build-claude-harness build-claude-harness: ## Build and push the native Claude Harness image build-claude-harness: buildx-create diff --git a/docs/architecture/README.md b/docs/architecture/README.md index f4f95bce8d..747b074d0b 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -113,6 +113,8 @@ cleanup. Session lifecycle workers independently pause/suspend idle Actors. - [MCP](mcp.md) - [A2A agent tools](a2a-subagents.md) - [Human in the loop](human-in-the-loop.md) +- [Declarative workflow agents](workflow-agents.md) +- [Workflow design patterns](workflow-patterns.md) - [Token usage](usage-extension.md) - [Prompt resolution](prompt-templates.md) - [Telemetry](telemetry.md) diff --git a/docs/architecture/a2a-subagents.md b/docs/architecture/a2a-subagents.md index bad2403b5e..cd31b43b3f 100644 --- a/docs/architecture/a2a-subagents.md +++ b/docs/architecture/a2a-subagents.md @@ -33,6 +33,54 @@ tools: Tree resolution detects missing references and cycles before compilation. +### Session isolation + +By default a subagent shares the parent's Session: the parent transfers control +and the subagent answers in the same conversation history. `isolation: Isolated` +on the binding changes the call model. The parent calls the subagent as a tool and +receives its answer, and every call runs in a new conversation seeded with a copy +of the parent's state: + +```yaml +tools: + - subAgent: + name: reviewer + description: Review proposed changes before applying them. + isolation: Isolated + templateRef: + name: review-context +``` + +| | `Shared` (default) | `Isolated` | +| --- | --- | --- | +| Call model | The parent transfers the conversation | The parent calls a tool and gets a result | +| History the subagent sees | The whole conversation | Only the request it was given | +| Parallel calls | Share one conversation | One conversation each | +| State | Shared | A copy; writes are not copied back | +| Human input and approvals | Reach the person through the parent | Not possible | + +Use `Isolated` to fan out to the same subagent in parallel, to keep a long parent +conversation out of the subagent's context, or to keep the subagent from seeing +what the parent was told. The copy of the parent's state is what the subagent +starts from, so do not keep values there that it should not see. + +The controller rejects an `Isolated` binding that cannot work, with +`Compatible=False`: + +- Only the kagent and BYO Harnesses support it. Codex and Claude do not. +- The subagent's MCP bindings cannot set `requireApproval`, and it is not offered + `ask_user`: its conversation ends with the tool call, so a request for a person + would never be answered. +- It cannot be combined with STS token exchange. The exchanged token is cached by + session ID and an isolated subagent runs in a new session, so its MCP requests + would carry the caller's own credential instead. Plain token propagation works, + because the credential travels with the request. + +A subagent that is not isolated keeps its previous behavior and its revisions do +not change. The same distinction exists for declarative workflows as +`sub_agents` and `agent_tools`; see +[Declarative workflow agents](workflow-agents.md#session-isolation). + ## Dedicated agent tools Dedicated bindings would select an independently configured Agent with its own diff --git a/docs/architecture/configuration-and-compilation.md b/docs/architecture/configuration-and-compilation.md index 8f7ff9eec5..73f7f9396f 100644 --- a/docs/architecture/configuration-and-compilation.md +++ b/docs/architecture/configuration-and-compilation.md @@ -10,7 +10,10 @@ on each Agent that selects it. `AgentTemplate` describes what the agent does. It contains model configuration, description and prompt, MCP tool bindings, skills, plugins, and Shared -subagent bindings (`tools[].subAgent`). Model configuration may be omitted for BYO images; +subagent bindings (`tools[].subAgent`). A BYO root template may instead set +`workflowFrom` to a ConfigMap-held declarative workflow document that the +controller validates and delivers (see [Declarative workflow agents](workflow-agents.md#control-plane)). +Model configuration may be omitted for BYO images; Agent compilation rejects managed harness combinations without one. `Agent` pairs one template and one Harness. The required `template` field selects @@ -22,6 +25,8 @@ complete values, not overrides. References, including those inside inline specs, resolve in the Agent's namespace. The reusable resources have no binding to each other. Child templates are selected with `tools[].subAgent.templateRef` and compile under the parent Agent's Harness. Each subagent requires `templateRef` and shares the parent's runtime and Harness. +It shares the parent's Session unless its binding sets `isolation: Isolated`; see +[Session isolation](a2a-subagents.md#session-isolation). Dedicated `agentRef` bindings are deferred and are not part of the served API. The resource boundary does not grant authorization by itself. Kubernetes RBAC diff --git a/docs/architecture/workflow-agents.md b/docs/architecture/workflow-agents.md new file mode 100644 index 0000000000..c2481ce48a --- /dev/null +++ b/docs/architecture/workflow-agents.md @@ -0,0 +1,361 @@ +# Declarative Workflow Agents + +The Go ADK builds agents from an `adk.declarative/v0.2` `AgentSystem` document +with `go/adk/pkg/declarative`. The package compiles a document into native ADK +2.x agents; it adds no execution engine. Routing, fan-out and join, retries, +timeouts, pause and resume, telemetry, and A2A streaming are the behavior of the +ADK `workflow` package and the ADK agent runtime. + +This is a library for Go ADK programs, such as a BYO Harness. It is not a session +or task model: A2A still owns interaction state, and a paused workflow resumes +through the same A2A task as any other ADK agent. A BYO image can embed its +document, or receive one through `AgentTemplate.spec.workflowFrom`; see +[Control plane](#control-plane). + +## Document model + +A document lists agents; the first is the root. Every agent has an +`agent_class`: + +| Class | ADK type | Required | +| ----------------- | ------------------------------ | ------------------------ | +| `Workflow` | `workflowagent` graph | `nodes`; `max_concurrency` caps it | +| `LlmAgent` | `llmagent` | `model`, `instruction` | +| `SequentialAgent` | `workflowagents/sequentialagent` | `sub_agents` | +| `ParallelAgent` | `workflowagents/parallelagent` | `sub_agents` | +| `LoopAgent` | `workflowagents/loopagent` | `sub_agents`; `max_iterations` bounds it | + +`sub_agents` names other agents of the document. ADK agent names are unique +within an agent tree, so an agent has at most one parent and `sub_agents` never +cycle. An agent may additionally be the target of any number of `agent` nodes +in different graphs; it is built once and shared. Agents that the root does not +reach are still built and are addressable through `System.Agent` and +`System.Loader`. + +Fields that a class does not accept are rejected rather than ignored. + +### LLM options + +`LlmAgent` agents and inline `llm` nodes share `model`, `mode`, `instruction`, +`global_instruction`, `tools`, `output_key`, `output_schema` (a JSON Schema, +converted with the same code as structured output) and `include_contents` +(`default` or `none`). `LlmAgent` additionally takes +`disallow_transfer_to_parent` and `disallow_transfer_to_peers`. + +### Node types + +| Type | Required | Behavior | +| ----------------- | -------------------------- | --------------------------------------------------------------- | +| `function` | `function` | Runs a registered `Func`; may select routes | +| `join` | | Fan-in barrier; emits a map keyed by predecessor | +| `tool` | `tool` | Runs a registered tool with the predecessor output as arguments | +| `agent` | `agent` | Runs an agent of the document | +| `llm` | `model`, `instruction` | Inline single-turn agent named after the node id | +| `workflow` | `nodes`, `edges` | Nested graph with its own `START`, nested at most 8 deep | +| `parallel_worker` | one of function/tool/agent | Runs the target once per item of a list input | +| `dynamic` | `function` | Registered `DynamicFunc` that runs children under `max_iters`, with `isolation` | +| `hitl` | `message` | Pauses for a human reply and forwards the reply | +| `cel` | one of output/route/routes/state_delta | Evaluates CEL expressions with `vars`; may select routes and write state | + +`function`, `tool`, `agent`, `llm`, `parallel_worker`, `dynamic` and `cel` nodes accept +`timeout` and `retry`, which map to `workflow.NodeConfig`. Unset retry fields +take the ADK defaults. `retry.when` is a CEL predicate over `error` +(`message`, `timeout`, `canceled`) that decides whether a failure is retried, +for example `error.timeout || error.message.contains("transient")`; it maps to +`RetryConfig.ShouldRetry`, which has no context, so state and session are not +visible there. An evaluation error means no retry. + +A `dynamic` node decides at run time which children to run. `children` lists +nodes of the same graph that only that node schedules: they carry no edges, and +the function runs one with `DynamicEnv.RunChild(id, input)`. Every child run, +and every `DynamicEnv.Run`, spends one `max_iters` iteration and fails with +`ErrIterationBudget` when none is left. + +Edges connect node ids. `START` is the entry source; `entry` is shorthand for a +`START` edge. An edge may carry a `route`, matched against the routes a +`function` or `cel` node returns; `DEFAULT` is the edge taken when no route matched. +There is no `END`: as in ADK, whose only sentinel is `START`, a node without +outgoing edges ends its branch, so a separate finish node is needed only where +the terminal rules below require one. +Unknown fields anywhere in the document are decode errors. + +### CEL + +A `cel` node evaluates expressions instead of calling Go. `output` replaces +the node output, which otherwise passes the input through; `route` returns a +route name or a list of names; `state_delta` writes each key's value to session +state. All of them see the state as it was before the node ran. + +`vars` names intermediate values, like the `variables` of a Kubernetes +ValidatingAdmissionPolicy. Each `{name, expr}` is evaluated in order and read +as `vars.` by later vars and by the other fields. `routes` is the +rule-table alternative to `route`: an ordered list of `{route, when}`; the +first rule whose `when` is true selects its route, an omitted `when` (last rule +only) always matches, and when nothing matches the `DEFAULT` edge is taken. +`route` and `routes` are mutually exclusive. Validation rejects a `vars.` +reference to an undeclared var or, inside `vars`, to a later one. + +```yaml +- id: classify + type: cel + vars: + - name: text + expr: input.lowerAscii() + routes: + - route: BUG + when: vars.text.matches(r'\b(crash|error|bug)\b') + - route: BILLING + when: vars.text.matches(r'\b(invoice|refund)\b') +``` + +Expressions see `input`, `vars` and the ADK context: `state`, `session` (`id`, +`app_name`, `user_id`), `user` (`id`, `content`, the text that started the +invocation) and `invocation` (`id`, `branch`, `agent_name`). Besides the CEL +standard library, including RE2 `matches`, the environment enables the cel-go +extensions for optional types, `cel.bind`, strings (`trim`, `lowerAscii`, +`split`, `format`, ...), base64 encoders, lists, `math`, sets and regex +(`regex.extract`, `regex.extractAll`, `regex.replace`). An empty optional +result is null. Expressions compile at validation and run under a cost limit +of 1,000,000. + +The helper functions follow the names and semantics of the +[agentgateway CEL functions](https://agentgateway.dev/docs/standalone/latest/reference/cel/variables/), +so one expression reads the same in kagent and agentgateway: + +| Helper | Purpose | +| --- | --- | +| `json(text)` | Parse a JSON string. | +| `toJson(value)` | Encode a value as a JSON string. | +| `x.with(name, body)` | Bind `x` to `name` inside `body`; same as `cel.bind(name, x, body)`. | +| `default(a.b[k], fallback)` | The field or index value, or `fallback` when a key on the path is missing. | + +Callback rules and `retry.when` use the same environment with the variables of +their scope, described below. + +### Prompt sources + +`instruction_from` and `global_instruction_from` replace `instruction` and +`global_instruction` with a `TextSource`: exactly one of `config_map` or +`secret` (`{name, key}` in the document's namespace) or `env`. The controller +inlines ConfigMap text before delivery, records the ConfigMap in the revision +provenance, and retries while it is missing. It rejects `secret` and `env` +sources as `UnsupportedConfiguration`: kagent never delivers Secret values to a +runtime (credentials are injected at the egress gateway), so prompt text the +runtime must read belongs in a ConfigMap. A document built directly with the Go +library resolves only `env`; `declarative.ResolvePrompts` lets an embedding +program resolve the other kinds. + +### Remote MCP servers + +`mcp_servers` declares streamable HTTP MCP servers: `name`, exactly one of +`url` or `url_from` (`{env: VAR}`), optional `headers` (`value` or +`value_from: {env: VAR}`), and an optional `tools` allowlist. Both sourced +fields read from the BYO runtime process environment. A server is used two ways: + +- a `tool` node with `server:` calls one of its tools as a function node; the + predecessor output is the arguments and the node output is the tool result, + `{"output": ...}` for both structured and text results; +- an `LlmAgent` or `llm` node lists the server name in `tools`, giving the + model the server's allowlisted tools. + +Connecting creates the ADK `mcptoolset` without contacting the server: tools +are listed when first needed, so an unreachable server fails a run, not the +build, and a node retries per its `retry` policy. `System.Close` closes every +MCP session. `Registry.SetMCPConnector` replaces the default connector, for +example to add per-request credentials. + +### Callbacks + +`callbacks` on an `LlmAgent` or `llm` node maps to the ADK before/after model +and tool callbacks: `before_model`, `after_model`, `before_tool`, `after_tool`. +Each is an ordered list of rules. A rule applies when its CEL `when` is true +(empty always applies) and either decides declaratively with `action: accept` +(run the call unchanged and stop evaluating) or `action: reject` with a +`message`, or names a registered `Hook` with `function`. A rejected model call +is answered with the message; a rejected tool call returns `{"error": message}` +to the model. Both carry `kagent_hook` metadata so clients can tell them from +real output. Rules see the context variables plus `request` (before_model: +`model`, `text`, `contents`, `system_instruction`, `tools`), `response` +(after_model: `text`, `partial`, `turn_complete`, `error`, `finish_reason`, +`function_calls`) or `tool` (`name`, `args`, and after the call `result` and +`error`). For example, a `before_tool` rule with +`when: tool.name == 'lookup_customer' && tool.args.customer_id != state.customer_id` +and `action: reject` keeps a model from reading another customer's record. + +### Harness tools + +`Registry.RegisterHarnessTools(HarnessConfig{Workspace, DisableShell})` +registers the local tool library: `read_file`, `write_file`, `list_files` and +`bash`, the same implementations the kagent runtime uses. Relative paths +resolve against the workspace; isolation comes from the sandbox the runtime +runs in. They are opt-in, because a document's image decides what it exposes, +and can be `tool` nodes (CEL builds their arguments) or LLM tools. + +### Typed contracts + +A `function`, `dynamic`, `agent` or `llm` node can carry an `input_schema`, and a +`function` or `dynamic` node an `output_schema` (an `llm` node's `output_schema` +is the model's reply schema). On a `join`, `input_schema` applies to each +predecessor's output separately. The engine validates what a node returns and +what the next node receives, and compares the two schemas on every edge when it +builds the graph, so a producer that promises a string cannot feed a consumer +that requires an integer. Validation parses and resolves each schema, so a +malformed one fails at reconcile time. + +### Output visibility + +A2A clients display event message text only; successor nodes read the structured +`Event.Output`. Function nodes are plain ADK function nodes: they emit data, so +intermediate steps stay out of the chat, exactly as with a native +`workflow.NewFunctionNode`. For the terminal nodes of a graph, `function`, +`dynamic` and `hitl` nodes also render their output as message text: strings as +they are, structured values as JSON. A terminal `tool`, `join` or +`parallel_worker` node would end the run with output no client displays, so +validation rejects it: end such a branch with a `function` node. A nested +`workflow` node is checked through its own terminals. + +### Session isolation + +How much of the session a concurrent branch or a subagent sees is a choice with +four levels: shared, branch, scope, and a separate session. Static fan-out, +`parallel_worker` items and `ParallelAgent` sub-agents are always isolated by +branch. Two fields control the rest: + +- `isolation` on a `dynamic` node sets how its children run: `shared` (the + default, the whole conversation), `branch` (siblings do not see each other) + or `scope` (a child sees only its own events). +- `agent_tools` on an `LlmAgent` calls other agents as tools. Each call runs in a + new session seeded with a copy of the caller's state, so the agent never sees + the caller's history, parallel calls cannot interfere, and its state writes + are not copied back. An agent is either a `sub_agents` entry (shared session, + transfer) or an `agent_tools` entry, never both. This is the declarative form + of `isolation: Isolated` on an AgentTemplate subagent binding, which the + controller delivers to the kagent and BYO runtimes + ([Session isolation](a2a-subagents.md#session-isolation)). + +Branches isolate history, not state. Validation rejects two agents that run +concurrently and write the same `output_key`: `ParallelAgent` sub-agents, +unconditional fan-out successors, and a `parallel_worker` agent with an +`output_key`. Cycles through `sub_agents`, `agent_tools` and `agent` nodes are +rejected too, since each would build forever. + +[Workflow design patterns](workflow-patterns.md#session-isolation) has the full +matrix, and runnable patterns that print what each agent saw. The behavior is +pinned by `isolation_test.go`, which measures it rather than assuming it. + +## Boundaries + +| Concern | Owner | +| -------------------------------------------------------- | ------------------------------- | +| Document shape, per-class and per-type field rules, name references between agents and nodes, output visibility | `Validate` (pure, no registry) | +| Names that resolve to Go values (functions, tools, models) | `Registry.Validate` | +| Reachability, unconditional cycles, fan-in, task mode | ADK workflow engine, at `Build` | +| Execution, routing, retries, pause and resume | ADK workflow engine | + +Validation reports every issue with a field path such as +`agents[0].nodes[2].function`, including unregistered names. `Registry.Parse` +and `Build` run it; a model resolver can only fail at `Build`, because only it +knows whether a name resolves. Errors from the ADK engine are wrapped with the +agent and node they belong to and are not `ValidationError`s. + +A document carries no code. `Registry` attaches behavior by name: +`RegisterFunction`, `RegisterDynamic`, `RegisterTool`, `RegisterToolset`, +`RegisterModel`, and `SetModelResolver` for model names that are not registered +by value (for example to reuse the runtime's provider factories). The offline +`stub` model and the ADK memory tools are preregistered. Tools and toolsets are +registered under their own `Name()`. Toolsets, such as MCP, can be listed in an +LLM `tools` list but cannot back a `tool` node. + +The registry is populated before `Build` and read-only afterwards. Documents are +authored by the agent's owner and are trusted like code that selects registered +behavior; validation bounds nesting depth but not document size. + +## Extending + +`Registry.RegisterNodeType` adds a node type: a `NodeRule` listing the +`NodeSpec` fields it requires and accepts, and a `NodeBuilder` returning any +`workflow.Node`. Documents using custom types are parsed with `Registry.Parse` +so validation applies the same rules. Built-in types cannot be replaced. +Agent-level customization that ADK exposes as Go callbacks stays in Go: build +the document, then wrap or compose the resulting agents. + +## Constraints inherited from the ADK engine + +- An unset `mode` is `single_turn` for an agent placed as a graph node and a + declared `chat` mode is honored there, keeping history. `task` agents cannot be + graph nodes; `Build` surfaces the engine's rejection. Inline `llm` nodes are + always `single_turn` and validation rejects other modes. +- Mutually exclusive routes must not fan in to a non-`join` node; give each + route its own terminal. A `join` suits fan-out that always runs every branch. +- An `agent` node is named after its agent inside the engine, so two nodes of + one graph cannot wrap the same agent and an agent name cannot equal a node id. +- Fan-out from edges starts every successor at once unless the Workflow agent + sets `max_concurrency`. The cap is graph-wide: at most that many nodes of the + graph run at once and further ready nodes wait. A nested `workflow` node and + the items of a `parallel_worker` run inside their parent node and are not + counted separately; `parallel_worker.max_concurrency` bounds the items of one + worker. `workflowagent.Config` does not accept engine options, so a capped + Workflow is built with `workflow.WithMaxConcurrency` behind a small agent that + runs or resumes the graph; its telemetry span is `invoke_agent`, not + `invoke_workflow`. +- A `parallel_worker` target runs without routes; a `Func` result's routes are + ignored there. +- A `hitl` node re-runs on resume. Its interrupt id combines the node id and the + invocation id, so it is stable within a run and unique across runs. + +## Control plane + +`AgentTemplate.spec.workflowFrom` references a document, as YAML or JSON, in a +same-namespace ConfigMap key, like `systemPromptFrom` and `outputSchemaFrom`. +The controller: + +1. reads the key and rejects a document larger than 64 KiB; +2. decodes it strictly and runs `declarative.Validate`, the registry-free + structural checks above, so a typo fails at reconcile time with its field + path instead of crash-looping the runtime; +3. canonicalizes it to JSON, records its SHA-256, and delivers both in the BYO + runtime's `config.json` as `agent.workflow`; +4. records the ConfigMap in the revision provenance, so editing the document + prepares a new revision. + +Failures that are the document's fault report `Compatible=False` with reason +`UnsupportedConfiguration` and the validation message. A missing ConfigMap or key +is an ordinary reference failure and is retried. + +What the controller cannot check is whether the image has the functions, tools +and models the document names. Those live in the image, so the runtime checks +them at startup with `Registry.ParseConfig`, which also verifies the delivered +digest. A document therefore pairs with the image that registers its names; roll +them out together. + +Constraints, enforced at compile time: + +- Only a BYO Harness runs a workflow. The managed kagent runtime has no + function registry, so it rejects the config as well as the controller; + Codex and Claude cannot run ADK graphs. +- Only the root template may set `workflowFrom`. A shared subagent template that + sets it is rejected rather than ignored. +- `workflowFrom` cannot be combined with `systemPrompt` or `systemPromptFrom`; + the CRD enforces this with CEL, along with non-empty `name` and `key`. + +The CRD does not model the document. CRD schemas cannot be recursive, so nested +`workflow` nodes cannot be typed, and a second copy of the rules in CEL would +drift from the Go validation. + +## Examples and tests + +`go/adk/examples/declarative` is a generic BYO runtime for workflow agents +(`make build-workflow-agent`). It carries no workflow and no workflow-specific +code: it serves the document that `workflowFrom` delivers, resolves every model +name in it to the AgentTemplate's ModelConfig, and offers the harness tools. +Like the managed runtime, it initializes telemetry from the delivered settings +and serves through the kagent executor, which emits each node's reply as its +own artifact. The artifact's `kagent.dev/a2a/author` metadata names the node, so +the chat shows the writer, each critic, and the loop as separate senders. +`go/adk/examples/workflow` deploys it with the writer-critics and +llm-router documents, and the runtime's tests run both offline. +[Workflow design patterns](workflow-patterns.md) shows router, fan-out and fan-in, +map-reduce, loops, human approval, and more, each as Go next to YAML, with the +graphs the engine rejects. `go/adk/pkg/declarative/testdata` holds two documents the package tests run end +to end: `showcase.yaml` exercises every node type, route form and LLM option, and +`composites.yaml` the sequential, parallel and loop agents. diff --git a/docs/architecture/workflow-patterns.md b/docs/architecture/workflow-patterns.md new file mode 100644 index 0000000000..c15fddbf61 --- /dev/null +++ b/docs/architecture/workflow-patterns.md @@ -0,0 +1,1779 @@ +# Workflow Design Patterns + +Each pattern below is written twice: with the Go ADK API (`google.golang.org/adk/v2/workflow`, described in the [ADK graph documentation](https://adk.dev/graphs/)) and as a declarative `adk.declarative/v0.2` document (see [Declarative workflow agents](workflow-agents.md)). The code is not illustrative: it lives in [`go/adk/examples/workflowpatterns`](../../go/adk/examples/workflowpatterns), the tests run both versions on the same inputs and require the same events, and a test fails when this page is out of date. + +Run any pattern, in either form or both, from `go/`: + +```sh +go run ./adk/examples/workflowpatterns -list +go run ./adk/examples/workflowpatterns -pattern router -input "bug: crash" +go run ./adk/examples/workflowpatterns -pattern human_approval -input "ship it" -reply yes +go run ./adk/examples/workflowpatterns -pattern fan_in -input Go -mode yaml +go test ./adk/examples/workflowpatterns +``` + +The models are the offline stub, so nothing needs credentials. + +## Building blocks + +Both columns use the same behavior, so only the wiring differs. The Go column wraps a plain function with `step` or `router`; the YAML column names the same function, which the registry exposes through `textFunc` and `routeFunc`. The behaviors themselves (`clean`, `upper`, `classify`, ...) are one-liners in `helpers.go`. + +```go +// step wraps a text function as a graph node. +func step(name string, f func(string) string) workflow.Node { + return workflow.NewFunctionNode(name, func(_ agent.Context, in string) (string, error) { + return f(in), nil + }, workflow.NodeConfig{}) +} +``` + +```go +// router wraps a routing decision as a node. It forwards its input unchanged +// and emits the routes that decide which outgoing edges run. +func router(name string, decide func(string) []string) workflow.Node { + return workflow.NewEmittingFunctionNode(name, func(ctx agent.Context, in string, emit func(*session.Event) error) (any, error) { + ev := session.NewEvent(ctx, ctx.InvocationID()) + ev.Routes = decide(in) + ev.Output = in + return nil, emit(ev) + }, workflow.NodeConfig{}) +} +``` + +```go +// textFunc registers a text function for function nodes. +func textFunc(f func(string) string) declarative.Func { + return func(_ agent.Context, in any) (declarative.Result, error) { + return declarative.Output(f(fmt.Sprint(in))), nil + } +} +``` + +```go +// routeFunc registers a routing decision: the input is forwarded unchanged +// and the chosen routes select the outgoing edges. +func routeFunc(f func(string) []string) declarative.Func { + return func(_ agent.Context, in any) (declarative.Result, error) { + return declarative.Route(in, f(fmt.Sprint(in))...), nil + } +} +``` + +## Patterns + +Every row is built twice and run by the tests; the Go and YAML versions must produce identical results. The last line of each Pattern cell is one verified run. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PatternGo (native ADK)YAML (declarative)
+ +**Sequence (pipeline)** + +Run steps one after another; each step's output is the next step's input. + +```text +START → clean → upper → exclaim +``` + +Run: `" hello "` → `out:HELLO!` + + + +```go +func sequenceGo() (agent.Agent, error) { + return workflowagent.New(workflowagent.Config{ + Name: "sequence", + Edges: workflow.Chain(workflow.Start, step("clean", clean), step("upper", upper), step("exclaim", exclaim)), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: sequence + agent_class: Workflow + nodes: + - { id: clean, type: function, function: clean } + - { id: upper, type: function, function: upper } + - { id: exclaim, type: function, function: exclaim } + edges: + - { from: START, to: clean } + - { from: clean, to: upper } + - { from: upper, to: exclaim } +``` + +
+ +**Router (content-based, with default)** + +A classifier picks exactly one branch by route; DEFAULT catches everything else. + +```text +START → classify ─BUG→ bug + ├BILLING→ billing + └DEFAULT→ fallback +``` + +Run: `"bug: crash"` → `out:bug handled: bug: crash` + + + +```go +func routerGo() (agent.Agent, error) { + classifier := router("classify", classify) + bug, billing, other := step("bug", bugHandler), step("billing", billingHandler), step("fallback", fallbackReply) + return workflowagent.New(workflowagent.Config{ + Name: "router", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, classifier), + []workflow.Edge{ + {From: classifier, To: bug, Route: workflow.StringRoute("BUG")}, + {From: classifier, To: billing, Route: workflow.StringRoute("BILLING")}, + {From: classifier, To: other, Route: workflow.Default}, + }, + ), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: router + agent_class: Workflow + nodes: + - { id: classify, type: function, function: classify } + - { id: bug, type: function, function: bug_handler } + - { id: billing, type: function, function: billing_handler } + - { id: fallback, type: function, function: fallback_reply } + edges: + - { from: START, to: classify } + - { from: classify, to: bug, route: BUG } + - { from: classify, to: billing, route: BILLING } + - { from: classify, to: fallback, route: DEFAULT } +``` + +
+ +**Conditional (if / else)** + +A boolean decision sends the message down one of two branches. Go uses BoolRoute; YAML uses the strings "true" and "false", which is what BoolRoute matches. + +```text +START → is_long ─true→ truncate + └false→ keep +``` + +Run: `"short"` → `out:short` + + + +```go +func conditionalGo() (agent.Agent, error) { + check := router("is_long", isLong) + short, keep := step("truncate", truncate), step("keep", clean) + return workflowagent.New(workflowagent.Config{ + Name: "conditional", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, check), + []workflow.Edge{ + {From: check, To: short, Route: workflow.BoolRoute(true)}, + {From: check, To: keep, Route: workflow.BoolRoute(false)}, + }, + ), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: conditional + agent_class: Workflow + nodes: + - { id: is_long, type: function, function: is_long } + - { id: truncate, type: function, function: truncate } + - { id: keep, type: function, function: clean } + edges: + - { from: START, to: is_long } + - { from: is_long, to: truncate, route: "true" } + - { from: is_long, to: keep, route: "false" } +``` + +
+ +**Fan-out and fan-in (scatter-gather)** + +Several unconditional edges leave one node and its branches run concurrently. A join node is the barrier that waits for all of them and hands their outputs, keyed by node, to one aggregator. max_concurrency caps how many nodes of the graph run at once. + +```text +START → shout ─┐ + ├→ whisper ─┼→ gather → combine + └→ mirror ─┘ +``` + +Run: `"Go"` → `out:mirror=oG|shout=GO|whisper=go` + + + +```go +func fanInGo() (agent.Agent, error) { + shout, whisper, mirror := step("shout", upper), step("whisper", lower), step("mirror", reverse) + gather := workflow.NewJoinNode("gather") + merge := workflow.NewFunctionNode("combine", func(_ agent.Context, in map[string]any) (string, error) { + return combine(in), nil + }, workflow.NodeConfig{}) + // workflowagent.Config takes no engine options, so cap the graph with + // workflow.New and run it from a plain agent. + wf, err := workflow.New("fan_in", workflow.NewEdgeBuilder(). + AddFanOut(workflow.Start, shout, whisper, mirror). + AddFanIn(gather, shout, whisper, mirror). + Add(gather, merge). + Build(), workflow.WithMaxConcurrency(2)) + if err != nil { + return nil, err + } + return agent.New(agent.Config{Name: "fan_in", Run: wf.Run}) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: fan_in + agent_class: Workflow + max_concurrency: 2 + nodes: + - { id: shout, type: function, function: upper } + - { id: whisper, type: function, function: lower } + - { id: mirror, type: function, function: reverse } + - { id: gather, type: join } + - { id: combine, type: function, function: combine } + edges: + - { from: START, to: shout } + - { from: START, to: whisper } + - { from: START, to: mirror } + - { from: shout, to: gather } + - { from: whisper, to: gather } + - { from: mirror, to: gather } + - { from: gather, to: combine } +``` + +
+ +**Map-reduce (dynamic fan-out)** + +A parallel worker runs one node per item of a list at run time, then a reducer combines the results. Fan-out width is data-dependent. + +```text +START → split → shout_all (×N items) → reduce +``` + +Run: `"a b c"` → `out:A B C` + + + +```go +func mapReduceGo() (agent.Agent, error) { + split := workflow.NewFunctionNode("split", func(_ agent.Context, in string) ([]any, error) { + return words(in), nil + }, workflow.NodeConfig{}) + shoutWord := workflow.NewFunctionNode("shout_word", func(_ agent.Context, in string) (string, error) { + return upper(in), nil + }, workflow.NodeConfig{}) + shoutAll, err := workflow.NewParallelWorker("shout_all", shoutWord, 2, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + reduce := workflow.NewFunctionNode("reduce", func(_ agent.Context, in []any) (string, error) { + return joinWords(in), nil + }, workflow.NodeConfig{}) + return workflowagent.New(workflowagent.Config{ + Name: "map_reduce", + Edges: workflow.Chain(workflow.Start, split, shoutAll, reduce), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: map_reduce + agent_class: Workflow + nodes: + - { id: split, type: function, function: split } + - { id: shout_all, type: parallel_worker, function: upper, max_concurrency: 2 } + - { id: reduce, type: function, function: join_words } + edges: + - { from: START, to: split } + - { from: split, to: shout_all } + - { from: shout_all, to: reduce } +``` + +
+ +**Loop until done (conditional cycle)** + +A routed edge points back to an earlier node. A cycle is allowed as long as it contains a conditional edge, so it can end. + +```text +START → grow → check ─again→ grow + └done→ finish +``` + +Run: `"ab"` → `out:final: ab!!!` + + + +```go +func retryLoopGo() (agent.Agent, error) { + grow, check, finish := step("grow", exclaim), router("check", longEnough), step("finish", finalReport) + return workflowagent.New(workflowagent.Config{ + Name: "retry_loop", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, grow, check), + []workflow.Edge{ + {From: check, To: grow, Route: workflow.StringRoute("again")}, + {From: check, To: finish, Route: workflow.StringRoute("done")}, + }, + ), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: retry_loop + agent_class: Workflow + nodes: + - { id: grow, type: function, function: exclaim } + - { id: check, type: function, function: long_enough } + - { id: finish, type: function, function: final_report } + edges: + - { from: START, to: grow } + - { from: grow, to: check } + - { from: check, to: grow, route: again } + - { from: check, to: finish, route: done } +``` + +
+ +**Human approval gate (pause / resume)** + +A hitl node pauses the run and asks a person; the reply resumes it and can drive a router. + +```text +START → draft → approve ⏸ → verdict ─APPROVED→ publish + └REJECTED→ reject +``` + +Run: `"ship it"`, reply `"yes"` → `out:published after reply: yes` + + + +```go +func humanApprovalGo() (agent.Agent, error) { + draft := step("draft", draftText) + rerun := true + approve := workflow.NewEmittingFunctionNode("approve", func(ctx agent.Context, _ any, emit func(*session.Event) error) (any, error) { + return workflow.ResumeOrRequestInput(ctx, emit, session.RequestInput{ + InterruptID: "approve-" + ctx.InvocationID(), + Message: "Publish this draft?", + }) + }, workflow.NodeConfig{RerunOnResume: &rerun}) + decide := router("verdict", verdict) + publish, reject := step("publish", publisher), step("reject", rejecter) + return workflowagent.New(workflowagent.Config{ + Name: "human_approval", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, draft, approve, decide), + []workflow.Edge{ + {From: decide, To: publish, Route: workflow.StringRoute("APPROVED")}, + {From: decide, To: reject, Route: workflow.StringRoute("REJECTED")}, + }, + ), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: human_approval + agent_class: Workflow + nodes: + - { id: draft, type: function, function: draft_text } + - { id: approve, type: hitl, message: "Publish this draft?" } + - { id: verdict, type: function, function: verdict } + - { id: publish, type: function, function: publisher } + - { id: reject, type: function, function: rejecter } + edges: + - { from: START, to: draft } + - { from: draft, to: approve } + - { from: approve, to: verdict } + - { from: verdict, to: publish, route: APPROVED } + - { from: verdict, to: reject, route: REJECTED } +``` + +
+ +**Nested workflow (sub-workflow as a node)** + +A whole graph with its own START is one node of an outer graph, so a sub-flow can be reused and reasoned about alone. + +```text +START → prep → subflow[ START → plan → execute ] → finish +``` + +Run: `" x "` → `out:exec(plan(x))!` + + + +```go +func nestedGo() (agent.Agent, error) { + inner, err := workflow.NewWorkflowNode("subflow", workflow.Chain(workflow.Start, step("plan", plan), step("execute", execute))) + if err != nil { + return nil, err + } + return workflowagent.New(workflowagent.Config{ + Name: "nested", + Edges: workflow.Chain(workflow.Start, step("prep", clean), inner, step("finish", exclaim)), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: nested + agent_class: Workflow + nodes: + - { id: prep, type: function, function: clean } + - id: subflow + type: workflow + nodes: + - { id: plan, type: function, function: plan } + - { id: execute, type: function, function: execute } + edges: + - { from: START, to: plan } + - { from: plan, to: execute } + - { id: finish, type: function, function: exclaim } + edges: + - { from: START, to: prep } + - { from: prep, to: subflow } + - { from: subflow, to: finish } +``` + +
+ +**Dynamic orchestrator (bounded child runs)** + +A dynamic node runs a child node as many times as its code decides, under a hard iteration budget. In YAML the budget is max_iters and the child is declared in children. + +```text +START → prep → refine (runs critic ≤ 3×) → finish +``` + +Run: `" x "` → `out:critic(critic(critic(x)))!` + + + +```go +func dynamicGo() (agent.Agent, error) { + review := step("critic", critic) + refine := workflow.NewDynamicNode("refine", func(ctx agent.Context, in any, _ func(*session.Event) error) (any, error) { + out := in + for range 3 { + next, err := workflow.RunNode[any](ctx, review, out) + if err != nil { + return nil, err + } + out = next + } + return out, nil + }, workflow.NodeConfig{}) + return workflowagent.New(workflowagent.Config{ + Name: "dynamic", + Edges: workflow.Chain(workflow.Start, step("prep", clean), refine, step("finish", exclaim)), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: dynamic + agent_class: Workflow + nodes: + - { id: prep, type: function, function: clean } + - { id: refine, type: dynamic, function: refine, max_iters: 3, children: [critic] } + - { id: critic, type: function, function: critic } + - { id: finish, type: function, function: exclaim } + edges: + - { from: START, to: prep } + - { from: prep, to: refine } + - { from: refine, to: finish } +``` + +
+ +**LLM stage inside a graph** + +A model-backed agent is one step of a deterministic graph. The offline stub model echoes its input, so the run needs no credentials. + +```text +START → prep → answer (LLM) → finish +``` + +Run: `"hi"` → `out:[stub] hi!` + + + +```go +func llmStageGo() (agent.Agent, error) { + answer, err := llmagent.New(llmagent.Config{ + Name: "answer", + Instruction: "Answer briefly.", + Model: declarative.NewStubModel("stub"), + Mode: llmagent.ModeSingleTurn, + }) + if err != nil { + return nil, err + } + answerNode, err := workflow.NewAgentNode(answer, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + return workflowagent.New(workflowagent.Config{ + Name: "llm_stage", + Edges: workflow.Chain(workflow.Start, step("prep", clean), answerNode, step("finish", exclaim)), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: llm_stage + agent_class: Workflow + nodes: + - { id: prep, type: function, function: clean } + - { id: answer, type: llm, model: stub, instruction: Answer briefly. } + - { id: finish, type: function, function: exclaim } + edges: + - { from: START, to: prep } + - { from: prep, to: answer } + - { from: answer, to: finish } +``` + +
+ +**Tool stage inside a graph** + +A tool runs as a graph node: the previous output is its arguments and its result feeds the next step. + +```text +START → args → lookup (tool) → describe +``` + +Run: `"c-7"` → `out:customer c-7 is on plan pro` + + + +```go +func toolStageGo() (agent.Agent, error) { + lookup, err := newLookupTool() + if err != nil { + return nil, err + } + lookupNode, err := workflow.NewNamedToolNode("lookup", lookup, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + args := workflow.NewFunctionNode("args", func(_ agent.Context, in string) (map[string]any, error) { + return customerArgs(in), nil + }, workflow.NodeConfig{}) + describe := workflow.NewFunctionNode("describe", func(_ agent.Context, in map[string]any) (string, error) { + return describeCustomer(in), nil + }, workflow.NodeConfig{}) + return workflowagent.New(workflowagent.Config{ + Name: "tool_stage", + Edges: workflow.Chain(workflow.Start, args, lookupNode, describe), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: tool_stage + agent_class: Workflow + nodes: + - { id: args, type: function, function: customer_args } + - { id: lookup, type: tool, tool: lookup_customer } + - { id: describe, type: function, function: describe_customer } + edges: + - { from: START, to: args } + - { from: args, to: lookup } + - { from: lookup, to: describe } +``` + +
+ +**CEL router with state (no Go code)** + +A cel node classifies with the CEL standard library and regular expressions, picks a route and writes extracted ids to state in the same event; later cel nodes format replies from input and state. The Go twin is the same graph with function nodes. + +```text +START → classify (CEL) ─BUG→ bug + ├BILLING→ billing (reads state.ids) + └DEFAULT→ other +``` + +Run: `"App crash on login"` → `out:bug: App crash on login` + + + +```go +func celRouterGo() (agent.Agent, error) { + classify := workflow.NewEmittingFunctionNode("classify", func(ctx agent.Context, in string, emit func(*session.Event) error) (any, error) { + ev := session.NewEvent(ctx, ctx.InvocationID()) + switch t := strings.ToLower(in); { + case bugWords.MatchString(t): + ev.Routes = []string{"BUG"} + case billingWords.MatchString(t): + ev.Routes = []string{"BILLING"} + default: + ev.Routes = []string{"OTHER"} + } + ev.Actions.StateDelta = map[string]any{"ids": customerIDs.FindAllString(in, -1)} + ev.Output = in + return nil, emit(ev) + }, workflow.NodeConfig{}) + bug := step("bug", func(s string) string { return "bug: " + strings.TrimSpace(s) }) + other := step("other", func(s string) string { return "other: " + strings.TrimSpace(s) }) + billing := workflow.NewFunctionNode("billing", func(ctx agent.Context, _ string) (string, error) { + ids, _ := ctx.State().Get("ids") + list, _ := ids.([]string) + if len(list) == 0 { + return "billing for unknown customer", nil + } + return "billing for " + strings.Join(list, ","), nil + }, workflow.NodeConfig{}) + return workflowagent.New(workflowagent.Config{ + Name: "cel_router", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, classify), + []workflow.Edge{ + {From: classify, To: bug, Route: workflow.StringRoute("BUG")}, + {From: classify, To: billing, Route: workflow.StringRoute("BILLING")}, + {From: classify, To: other, Route: workflow.Default}, + }, + ), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: cel_router + agent_class: Workflow + nodes: + # A CEL node with only route and state_delta forwards its input. + - id: classify + type: cel + # Ordered rules: the first match wins, no match takes DEFAULT. + vars: + - name: text + expr: input.lowerAscii() + routes: + - route: BUG + when: vars.text.matches(r'\b(crash|error|bug)\b') + - route: BILLING + when: vars.text.matches(r'\b(invoice|refund)\b') + state_delta: + ids: regex.extractAll(input, r'\bc-[0-9]+\b') + - id: bug + type: cel + output: "'bug: ' + input.trim()" + - id: billing + type: cel + output: | + 'billing for ' + (size(state.ids) > 0 ? state.ids.map(i, string(i)).join(',') : 'unknown customer') + - id: other + type: cel + output: "'other: ' + input.trim()" + edges: + - { from: START, to: classify } + - { from: classify, to: bug, route: BUG } + - { from: classify, to: billing, route: BILLING } + - { from: classify, to: other, route: DEFAULT } +``` + +
+ +**Remote MCP tool as a function node** + +A tool node with server: calls one tool of a remote MCP server declared in mcp_servers (URL from the environment); CEL builds its arguments and reads its structured result. The Go twin wraps an ADK mcptoolset in a function node. Both call the same streamable HTTP server. + +```text +START → args (CEL) → lookup (crm MCP) → describe (CEL) +``` + +Run: `"c-7"` → `out:customer c-7 is on plan enterprise` + + + +```go +func mcpToolGo() (agent.Agent, error) { + url, err := crmURL() + if err != nil { + return nil, err + } + toolset, err := mcptoolset.New(mcptoolset.Config{Transport: &mcpsdk.StreamableClientTransport{Endpoint: url}}) + if err != nil { + return nil, err + } + crm := tool.FilterToolset(toolset, tool.StringPredicate([]string{"lookup_customer"})) + args := workflow.NewFunctionNode("args", func(_ agent.Context, in string) (map[string]any, error) { + return map[string]any{"customer_id": strings.TrimSpace(in)}, nil + }, workflow.NodeConfig{}) + lookup := workflow.NewFunctionNode("lookup", func(ctx agent.Context, in map[string]any) (map[string]any, error) { + tools, err := crm.Tools(ctx) + if err != nil || len(tools) != 1 { + return nil, fmt.Errorf("lookup_customer is not available: %v", err) + } + runnable, ok := tools[0].(interface { + Run(agent.Context, any) (map[string]any, error) + }) + if !ok { + return nil, errors.New("lookup_customer cannot run outside a model turn") + } + return runnable.Run(ctx, in) + }, workflow.NodeConfig{}) + describe := workflow.NewFunctionNode("describe", func(_ agent.Context, in map[string]any) (string, error) { + c, _ := in["output"].(map[string]any) + return fmt.Sprintf("customer %v is on plan %v", c["id"], c["plan"]), nil + }, workflow.NodeConfig{}) + return workflowagent.New(workflowagent.Config{ + Name: "mcp_tool", + Edges: workflow.Chain(workflow.Start, args, lookup, describe), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +mcp_servers: + - name: crm + url_from: { env: PATTERNS_CRM_URL } + tools: [lookup_customer] +agents: + - name: mcp_tool + agent_class: Workflow + nodes: + - id: args + type: cel + output: '{"customer_id": input.trim()}' + # A remote MCP tool as a function node: the input is its arguments. + - id: lookup + type: tool + server: crm + tool: lookup_customer + - id: describe + type: cel + output: "'customer %s is on plan %s'.format([input.output.id, input.output.plan])" + edges: + - { from: START, to: args } + - { from: args, to: lookup } + - { from: lookup, to: describe } +``` + +
+ +**Typed contract between steps (JSON Schema)** + +A node can carry an input and an output JSON Schema. The engine validates what the node returns and what the next node receives, and compares the two schemas on every edge when it builds the graph. + +```text +START → parse (output: {name}) → greet (input: {name}) +``` + +Run: `"Ada"` → `out:hello Ada` + + + +```go +func typedContractGo() (agent.Agent, error) { + parse, err := workflow.NewFunctionNodeWithSchema( + "parse", func(_ agent.Context, in string) (map[string]any, error) { return makePerson(in), nil }, + nil, personSchema, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + greet, err := workflow.NewFunctionNodeWithSchema( + "greet", func(_ agent.Context, in map[string]any) (string, error) { return greetPerson(in), nil }, + personSchema, nil, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + return workflowagent.New(workflowagent.Config{ + Name: "typed_contract", + Edges: workflow.Chain(workflow.Start, parse, greet), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: typed_contract + agent_class: Workflow + nodes: + - id: parse + type: function + function: make_person + output_schema: { type: object, properties: { name: { type: string } }, required: [name] } + - id: greet + type: function + function: greet_person + input_schema: { type: object, properties: { name: { type: string } }, required: [name] } + edges: + - { from: START, to: parse } + - { from: parse, to: greet } +``` + +
+ +**Resilient step (retry and timeout)** + +A node carries a retry policy, a CEL retry predicate (retry.when) and a timeout. The step below fails twice, then succeeds on the third attempt. + +```text +START → unreliable (retry ×3, timeout 5s) +``` + +Run: `"go"` → `out:recovered: go` + + + +```go +func resilientGo() (agent.Agent, error) { + retry := workflow.DefaultRetryConfig() + retry.MaxAttempts = 3 + retry.InitialDelay, retry.MaxDelay = time.Millisecond, 2*time.Millisecond + retry.BackoffFactor, retry.Jitter = 1, 0 + retry.ShouldRetry = func(err error) bool { + return errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "transient") + } + flaky := newFlaky(2) + unreliable := workflow.NewFunctionNode("unreliable", func(_ agent.Context, in string) (string, error) { + return flaky(in) + }, workflow.NodeConfig{RetryConfig: retry, Timeout: 5 * time.Second}) + return workflowagent.New(workflowagent.Config{ + Name: "resilient", + Edges: workflow.Chain(workflow.Start, unreliable), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: resilient + agent_class: Workflow + nodes: + - id: unreliable + type: function + function: flaky + timeout: 5s + retry: + max_attempts: 3 + initial_delay: 1ms + max_delay: 2ms + backoff_factor: 1 + jitter: 0 + # Retry only transient failures and timeouts; other errors fail at once. + when: error.timeout || error.message.contains("transient") + edges: + - { from: START, to: unreliable } +``` + +
+ +**Fan-out of agents (isolated by default)** + +Concurrent branches get their own branch of the session: neither agent is shown the other's reply, so both answer saw=1 (just the user's message). Sibling isolation is automatic for static fan-out, parallel workers and ParallelAgent. + +```text +START → left ─┐ + └→ right ─┴→ gather → combine +``` + +Run: `"hi"` → `out:left=saw=1|right=saw=1` + + + +```go +func fanOutAgentsGo() (agent.Agent, error) { + left, err := chatAgent("left") + if err != nil { + return nil, err + } + right, err := chatAgent("right") + if err != nil { + return nil, err + } + leftNode, err := workflow.NewAgentNode(left, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + rightNode, err := workflow.NewAgentNode(right, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + gather := workflow.NewJoinNode("gather") + merge := workflow.NewFunctionNode("combine", func(_ agent.Context, in map[string]any) (string, error) { + return combine(in), nil + }, workflow.NodeConfig{}) + return workflowagent.New(workflowagent.Config{ + Name: "fan_out_agents", + Edges: workflow.NewEdgeBuilder(). + AddFanOut(workflow.Start, leftNode, rightNode). + AddFanIn(gather, leftNode, rightNode). + Add(gather, merge). + Build(), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: fan_out_agents + agent_class: Workflow + nodes: + - { id: ask_left, type: agent, agent: left } + - { id: ask_right, type: agent, agent: right } + - { id: gather, type: join } + - { id: combine, type: function, function: combine } + edges: + - { from: START, to: ask_left } + - { from: START, to: ask_right } + - { from: ask_left, to: gather } + - { from: ask_right, to: gather } + - { from: gather, to: combine } + - { name: left, agent_class: LlmAgent, model: history, mode: chat, instruction: Answer. } + - { name: right, agent_class: LlmAgent, model: history, mode: chat, instruction: Answer. } +``` + +
+ +**Dynamic children sharing the conversation** + +By default a dynamic node runs its children on its own branch, so the second child is shown what the first one said (saw=3 against saw=1). + +```text +START → review (runs first, then second, shared) +``` + +Run: `"hi"` → `out:first saw=1, second saw=3` + + + +```go +func sharedChildrenGo() (agent.Agent, error) { return reviewersGo("shared_children") } +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: shared_children + agent_class: Workflow + nodes: + - { id: review, type: dynamic, function: review_both, children: [ask_first, ask_second] } + - { id: ask_first, type: agent, agent: first } + - { id: ask_second, type: agent, agent: second } + edges: + - { from: START, to: review } + - { name: first, agent_class: LlmAgent, model: history, mode: chat, instruction: Answer. } + - { name: second, agent_class: LlmAgent, model: history, mode: chat, instruction: Answer. } +``` + +
+ +**Dynamic children isolated from each other** + +isolation: branch (WithUseSubBranch in Go) gives every child run its own branch, so the second child is back to saw=1 and no longer sees the first one's reply. Use isolation: scope (WithIsolationScopeFromNodePath) to hide ancestor events too. + +```text +START → review (runs first, then second, each on its own branch) +``` + +Run: `"hi"` → `out:first saw=1, second saw=1` + + + +```go +func isolatedChildrenGo() (agent.Agent, error) { + return reviewersGo("isolated_children", workflow.WithUseSubBranch()) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: isolated_children + agent_class: Workflow + nodes: + - { id: review, type: dynamic, function: review_both, children: [ask_first, ask_second], isolation: branch } + - { id: ask_first, type: agent, agent: first } + - { id: ask_second, type: agent, agent: second } + edges: + - { from: START, to: review } + - { name: first, agent_class: LlmAgent, model: history, mode: chat, instruction: Answer. } + - { name: second, agent_class: LlmAgent, model: history, mode: chat, instruction: Answer. } +``` + +
+ +**Agent as a tool (a session of its own)** + +agent_tools (agenttool in Go) runs the agent in a new session, seeded with a copy of the caller's state, so it sees only the request it was given (saw=1) and parallel calls cannot interfere. A sub_agent, by contrast, shares the caller's session. + +```text +caller ──call──▶ specialist (own session) +``` + +Run: `"question"` → `say:agent_as_tool:tool said: saw=1` + + + +```go +func agentAsToolGo() (agent.Agent, error) { + specialist, err := llmagent.New(llmagent.Config{ + Name: "specialist", Description: "Answers one question.", Instruction: "Answer.", Model: historyModel{}, + }) + if err != nil { + return nil, err + } + return llmagent.New(llmagent.Config{ + Name: "agent_as_tool", + Instruction: "Delegate to the specialist.", + Model: callerModel{tool: "specialist"}, + Tools: []tool.Tool{agenttool.New(specialist, nil)}, + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: agent_as_tool + agent_class: LlmAgent + model: caller + instruction: Delegate to the specialist. + agent_tools: [specialist] + - { name: specialist, agent_class: LlmAgent, model: history, description: Answers one question., instruction: Answer. } +``` + +
+ +**Sequential agents** + +The classic ADK SequentialAgent runs its sub-agents in order; each sees the conversation so far, including what earlier agents said. + +```text +draft → review +``` + +Run: `"topic"` → `say:review:[stub] For context:[draft] said: [stub] topic` + + + +```go +func sequentialAgentGo() (agent.Agent, error) { + draft, err := worker("draft", "Write a draft.") + if err != nil { + return nil, err + } + review, err := worker("review", "Review the draft.") + if err != nil { + return nil, err + } + return sequentialagent.New(sequentialagent.Config{AgentConfig: agent.Config{ + Name: "sequential_agent", SubAgents: []agent.Agent{draft, review}, + }}) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: sequential_agent + agent_class: SequentialAgent + sub_agents: [draft, review] + - { name: draft, agent_class: LlmAgent, model: stub, instruction: Write a draft. } + - { name: review, agent_class: LlmAgent, model: stub, instruction: Review the draft. } +``` + +
+ +**Parallel agents (ensemble)** + +The classic ADK ParallelAgent runs its sub-agents concurrently in isolated branches. + +```text +optimist ∥ pessimist +``` + +Run: `"topic"` → `say:pessimist:[stub] topic` + + + +```go +func parallelAgentGo() (agent.Agent, error) { + optimist, err := worker("optimist", "Argue for it.") + if err != nil { + return nil, err + } + pessimist, err := worker("pessimist", "Argue against it.") + if err != nil { + return nil, err + } + return parallelagent.New(parallelagent.Config{AgentConfig: agent.Config{ + Name: "parallel_agent", SubAgents: []agent.Agent{optimist, pessimist}, + }}) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: parallel_agent + agent_class: ParallelAgent + sub_agents: [optimist, pessimist] + - { name: optimist, agent_class: LlmAgent, model: stub, instruction: Argue for it. } + - { name: pessimist, agent_class: LlmAgent, model: stub, instruction: Argue against it. } +``` + +
+ +**Loop agent (bounded repetition)** + +The classic ADK LoopAgent repeats its sub-agents until max_iterations or until one escalates. + +```text +polish ×2 +``` + +Run: `"topic"` → `say:polish:[stub] topic` + + + +```go +func loopAgentGo() (agent.Agent, error) { + polish, err := worker("polish", "Improve the text.") + if err != nil { + return nil, err + } + return loopagent.New(loopagent.Config{ + AgentConfig: agent.Config{Name: "loop_agent", SubAgents: []agent.Agent{polish}}, + MaxIterations: 2, + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: loop_agent + agent_class: LoopAgent + max_iterations: 2 + sub_agents: [polish] + - { name: polish, agent_class: LlmAgent, model: stub, instruction: Improve the text. } +``` + +
+ +## Rejected graphs + +These look reasonable and fail. Each failure is asserted for both versions, so the YAML validation does not hide a graph the Go API accepts, or the reverse. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Anti-patternGo (native ADK)YAML (declarative)
+ +**Unconditional cycle** + +An edge without a route that points back never ends. Put a routed edge in the cycle, as in the loop pattern. + +Fails when built with: + +```text +…unconditional cycle… +``` + + + +```go +func unconditionalCycleGo() (agent.Agent, error) { + first, second := step("first", clean), step("second", upper) + return workflowagent.New(workflowagent.Config{ + Name: "unconditional_cycle", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, first, second), + []workflow.Edge{{From: second, To: first}}, + ), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: unconditional_cycle + agent_class: Workflow + nodes: + - { id: first, type: function, function: clean } + - { id: second, type: function, function: upper } + edges: + - { from: START, to: first } + - { from: first, to: second } + - { from: second, to: first } +``` + +
+ +**Mutually exclusive routes converging on one node** + +Only a join may receive several incoming edges. Give each route its own terminal, or end both with the same logic in separate nodes. + +Fails when built with: + +```text +…non-JoinNode fan-in… +``` + + + +```go +func convergingRoutesGo() (agent.Agent, error) { + classifier := router("classify", classify) + bug, billing, reply := step("bug", bugHandler), step("billing", billingHandler), step("reply", finalReport) + return workflowagent.New(workflowagent.Config{ + Name: "converging_routes", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, classifier), + []workflow.Edge{ + {From: classifier, To: bug, Route: workflow.StringRoute("BUG")}, + {From: classifier, To: billing, Route: workflow.StringRoute("BILLING")}, + {From: bug, To: reply}, + {From: billing, To: reply}, + }, + ), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: converging_routes + agent_class: Workflow + nodes: + - { id: classify, type: function, function: classify } + - { id: bug, type: function, function: bug_handler } + - { id: billing, type: function, function: billing_handler } + - { id: reply, type: function, function: final_report } + edges: + - { from: START, to: classify } + - { from: classify, to: bug, route: BUG } + - { from: classify, to: billing, route: BILLING } + - { from: bug, to: reply } + - { from: billing, to: reply } +``` + +
+ +**Fan-out without a gather** + +A workflow has one result. Two or more terminal nodes that produce output fail at run time; converge the branches with a join. + +Fails when run with: + +```text +…multiple terminal nodes produced output… +``` + + + +```go +func fanOutWithoutGatherGo() (agent.Agent, error) { + shout, whisper, mirror := step("shout", upper), step("whisper", lower), step("mirror", reverse) + return workflowagent.New(workflowagent.Config{ + Name: "fan_out_without_gather", + Edges: workflow.NewEdgeBuilder().AddFanOut(workflow.Start, shout, whisper, mirror).Build(), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: fan_out_without_gather + agent_class: Workflow + nodes: + - { id: shout, type: function, function: upper } + - { id: whisper, type: function, function: lower } + - { id: mirror, type: function, function: reverse } + edges: + - { from: START, to: shout } + - { from: START, to: whisper } + - { from: START, to: mirror } +``` + +
+ +**Multicast routes without a gather** + +A router that emits several routes starts several branches, which hits the same one-result rule. Only multicast to branches that finish without output, or end the branches in a join. + +Fails when run with: + +```text +…multiple terminal nodes produced output… +``` + + + +```go +func multicastWithoutGatherGo() (agent.Agent, error) { + pickAudience := router("audience", audience) + audit, notify := step("audit", auditor), step("notify", notifier) + return workflowagent.New(workflowagent.Config{ + Name: "multicast_without_gather", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, pickAudience), + []workflow.Edge{ + {From: pickAudience, To: audit, Route: workflow.StringRoute("audit")}, + {From: pickAudience, To: notify, Route: workflow.StringRoute("notify")}, + }, + ), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: multicast_without_gather + agent_class: Workflow + nodes: + - { id: audience, type: function, function: audience } + - { id: audit, type: function, function: auditor } + - { id: notify, type: function, function: notifier } + edges: + - { from: START, to: audience } + - { from: audience, to: audit, route: audit } + - { from: audience, to: notify, route: notify } +``` + +
+ +**Task-mode agent as a graph node** + +A task-mode agent is multi-turn and cannot be a static graph node. Use single_turn, or run it dynamically. + +Fails when built with: + +```text +…task… +``` + + + +```go +func taskModeNodeGo() (agent.Agent, error) { + task, err := llmagent.New(llmagent.Config{ + Name: "worker", Instruction: "Do the task.", Model: declarative.NewStubModel("stub"), Mode: llmagent.ModeTask, + }) + if err != nil { + return nil, err + } + node, err := workflow.NewAgentNode(task, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + return workflowagent.New(workflowagent.Config{ + Name: "task_mode_node", + Edges: workflow.Chain(workflow.Start, node), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: task_mode_node + agent_class: Workflow + nodes: + - { id: work, type: agent, agent: worker } + edges: + - { from: START, to: work } + - { name: worker, agent_class: LlmAgent, model: stub, mode: task, instruction: Do the task. } +``` + +
+ +**Schemas that disagree across an edge** + +A step that promises a string cannot feed a step that requires an integer. The engine compares the schemas when it builds the graph, so the mistake never reaches a run. + +Fails when built with: + +```text +…schema mismatch… +``` + + + +```go +func schemaMismatchGo() (agent.Agent, error) { + produce, err := workflow.NewFunctionNodeWithSchema( + "produce", func(_ agent.Context, in string) (string, error) { return in, nil }, + nil, &jsonschema.Schema{Type: "string"}, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + consume, err := workflow.NewFunctionNodeWithSchema( + "consume", func(_ agent.Context, in int) (string, error) { return fmt.Sprint(in), nil }, + &jsonschema.Schema{Type: "integer"}, nil, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + return workflowagent.New(workflowagent.Config{ + Name: "schema_mismatch", + Edges: workflow.Chain(workflow.Start, produce, consume), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: schema_mismatch + agent_class: Workflow + nodes: + - { id: produce, type: function, function: clean, output_schema: { type: string } } + - { id: consume, type: function, function: clean, input_schema: { type: integer } } + edges: + - { from: START, to: produce } + - { from: produce, to: consume } +``` + +
+ +**Ending a branch on a tool node** + +The ADK accepts it, but the tool's structured result is data that no A2A client displays. The declarative validation asks for a closing function node. + +Fails when built with: + +```text +…terminal tool node emits structured output… +``` + + + +_The ADK Go API accepts this graph; only the declarative validation refuses it._ + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: terminal_tool + agent_class: Workflow + nodes: + - { id: args, type: function, function: customer_args } + - { id: lookup, type: tool, tool: lookup_customer } + edges: + - { from: START, to: args } + - { from: args, to: lookup } +``` + +
+ +## Session isolation + +Concurrent branches and subagents differ in how much of the session they can see. There are four levels, and each boundary has a default and a control. The first two rows were measured against ADK v2.6.0 by `isolation_test.go` in `pkg/declarative`; a change upstream fails those tests. + +| Boundary | Sees the others' history by default | Control | +| --- | --- | --- | +| Static fan-out (several edges from one node) | no, own branch | none: always branch-isolated | +| `parallel_worker` items | no, own branch | none: always branch-isolated | +| `ParallelAgent` sub-agents | no, own branch | none: always branch-isolated | +| `SequentialAgent` / `LoopAgent` sub-agents | yes, by design | none that hides earlier agents; run them as `single_turn` agent nodes in a graph, or as `agent_tools` | +| Children of a `dynamic` node | yes | `isolation: shared | branch | scope` on the node | +| `sub_agents` of an `LlmAgent` (transfer) | yes, same session | none; call it as an `agent_tools` entry instead | +| `single_turn` agent node in a graph | no, sees only its predecessor's output | the mode is the control | +| `agent_tools` of an `LlmAgent` | no, a separate session | the agent is the control: use `agent_tools` instead of `sub_agents` | + +- **shared**: the child runs on its parent's branch and sees the whole conversation. +- **branch**: the child has its own branch. It sees its ancestors' history, not its siblings'. +- **scope**: the child also gets a scope unique to the run, so it sees only its own events. The user's message and the input you hand it still arrive; earlier agents' replies do not. +- **session** (`agent_tools`): the agent runs in a new session seeded with a copy of the caller's state. It never sees the caller's history, parallel calls get one session each, and its state writes are not copied back. + +The ADK graph pages do not describe branch or scope isolation, so this matrix is measured, not quoted. `include_contents: none` is not isolation. It keeps the current turn, and the replies of agents that ran earlier in that turn are part of it, so a sequential sub-agent still receives what the one before it said (and loses the user's message). + +Isolation covers history, not state. Branches share one session state, so two concurrent agents that write the same `output_key` race; validation rejects that for `ParallelAgent`, for unconditional fan-out successors, and for `parallel_worker` agents. Remember that a tool agent starts from a copy of the caller's state, so do not keep secrets there that the tool agent should not see. + +## Reading the table + +- **One result.** A workflow ends in exactly one terminal output. The ADK graph pages do not state this, but the engine enforces it: branches that run concurrently must converge in a `join`, or all but one must finish without output. +- **Joins need outputs.** A join releases only after all its predecessors finish, and a predecessor that finishes without an output leaves the join no value for that branch. The failure shows up downstream, away from the node that caused it, so give every branch an output and a retry policy where it can fail. +- **Cycles are not bounded.** A routed back-edge re-activates a node on each pass and nothing stops it, so the exit condition must eventually hold. When you need a hard bound, use a `dynamic` node (`max_iters`) or a `LoopAgent` (`max_iterations`). +- **Agent nodes.** The ADK documents graph agents as `single_turn` or `task`. The engine also accepts `chat` for a node fed directly by START and for a dynamic child, and rejects a static `task` node; a task agent is for dynamic dispatch. The isolation patterns use `chat` on purpose, because a `single_turn` node already sees only its input. +- **Data between steps.** Pass data through node outputs, not session state. State is for values that tools, callbacks and instruction templates read, and it should stay small. Give a step an `input_schema` or `output_schema` when you want the engine to enforce the contract. +- **Routes are strings.** `BoolRoute(true)` and `IntRoute(1)` match the strings `"true"` and `"1"`, which is how YAML writes them. `DEFAULT` is the ADK `Default` route. +- **Plain steps are silent.** A function node emits data for its successors. The terminal result is also rendered as message text, so A2A clients display the answer and not every intermediate step. +- **Names.** A sub-workflow node cannot share the root agent's name, and an agent node is named after its agent. +- **Where YAML stops.** YAML names behavior; it cannot contain it. Functions, tools, and models come from a `Registry`, and a custom node type is Go code registered once. diff --git a/go/adk/.gitignore b/go/adk/.gitignore index 201b228484..611b422b7c 100644 --- a/go/adk/.gitignore +++ b/go/adk/.gitignore @@ -1,2 +1,3 @@ *.crt *.dox +/declarative diff --git a/go/adk/examples/declarative/main.go b/go/adk/examples/declarative/main.go new file mode 100644 index 0000000000..2c3a8798a3 --- /dev/null +++ b/go/adk/examples/declarative/main.go @@ -0,0 +1,252 @@ +// Package main is a generic BYO runtime for declarative workflow agents. +// +// It serves whatever AgentSystem document the controller delivers from +// AgentTemplate.spec.workflowFrom in /config/config.json. The image carries no +// workflow and no workflow-specific Go code; the registry holds only what any +// document can name: +// +// - Models: every model name resolves to the AgentTemplate's ModelConfig, so +// a document runs against whichever model the operator configured. Without +// a ModelConfig the name is used as an OpenAI model. "stub" is always the +// offline test model. +// - Tools: the harness tools read_file, write_file, list_files and bash in +// the actor workspace. MCP toolsets are declared in the document itself. +// +// Telemetry and A2A output follow the managed kagent runtime: delivered +// exporter settings and OTEL_* variables configure tracing, every turn is +// flushed before its final event, and each workflow node's reply is its own +// artifact carrying the node as author. +// +// Build the image with `make build-workflow-agent` and run it from a BYO +// Harness; see go/adk/examples/workflow for complete manifests. +package main + +import ( + "cmp" + "context" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "time" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + adkagent "google.golang.org/adk/v2/agent" + adkmodel "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/runner" + adksession "google.golang.org/adk/v2/session" + + "github.com/kagent-dev/kagent/go/adk/pkg/a2a" + "github.com/kagent-dev/kagent/go/adk/pkg/agent" + "github.com/kagent-dev/kagent/go/adk/pkg/app" + "github.com/kagent-dev/kagent/go/adk/pkg/config" + "github.com/kagent-dev/kagent/go/adk/pkg/declarative" + "github.com/kagent-dev/kagent/go/adk/pkg/models" + "github.com/kagent-dev/kagent/go/adk/pkg/session" + "github.com/kagent-dev/kagent/go/adk/pkg/telemetry" + configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" + processconfig "github.com/kagent-dev/kagent/go/core/pkg/config" + "github.com/kagent-dev/kagent/go/pkg/logging" + "github.com/kagent-dev/kagent/go/pkg/tracing" +) + +const configDir = "/config" + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + if err := config.MaterializeFromBootstrap(context.Background(), configDir); err != nil { + return err + } + compiled, err := config.LoadAgentConfig(filepath.Join(configDir, "config.json")) + if err != nil { + return err + } + card, err := loadAgentCard(filepath.Join(configDir, "agent-card.json")) + if err != nil { + return err + } + runtime := config.ProcessConfig(compiled) + logger, err := logging.New(os.Stderr, cmp.Or(runtime.LogLevel, "info")) + if err != nil { + return err + } + slog.SetDefault(logger) + ctx := logging.IntoContext(context.Background(), logger) + + runtimeTelemetry := processconfig.RuntimeTelemetry(runtime.GetTelemetry()).WithEnvironment(os.Environ()) + runtimeTelemetry.Runtime = tracing.RuntimeADKGo + runtimeTelemetry.AgentName = runtime.Name + runtimeTelemetry.AgentNamespace = cmp.Or(runtime.Namespace, "default") + providers, err := telemetry.Init(ctx, runtimeTelemetry) + if err != nil { + logger.Error("failed to initialize telemetry", "error", err) + } + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := providers.Shutdown(shutdownCtx); err != nil { + logger.Error("failed to shut down telemetry", "error", err) + } + }() + + agentConfig := compiled.GetAgent() + reg, err := newRegistry(agentConfig.GetModel(), "") + if err != nil { + return fmt.Errorf("create registry: %w", err) + } + spec, err := loadDocument(reg, agentConfig.GetWorkflow()) + if err != nil { + return err + } + system, err := declarative.Build(ctx, spec, reg) + if err != nil { + return fmt.Errorf("build workflow: %w", err) + } + defer system.Close() + root := system.Root() + + sessions, err := session.NewService(agentConfig.GetSessionDbUrl()) + if err != nil { + return fmt.Errorf("open session store: %w", err) + } + if sessions == nil { + sessions = adksession.InMemoryService() + } + appName := appName(runtime.Namespace, runtime.Name, root.Name()) + if runtimeTelemetry.AgentName == "" { + runtimeTelemetry.AgentName = appName + } + executor, err := a2a.NewKAgentExecutor(a2a.KAgentExecutorConfig{ + RunnerConfig: runner.Config{AppName: appName, Agent: root, SessionService: sessions}, + SessionService: sessions, + Stream: agentConfig.GetStream(), + AppName: appName, + Logger: logger, + Flush: providers.ForceFlush, + AgentName: runtimeTelemetry.AgentName, + }) + if err != nil { + return fmt.Errorf("create A2A executor: %w", err) + } + + kagentApp, err := app.New(app.AppConfig{ + ControlPlaneURL: runtime.ControlPlaneUrl, + MaxContentLength: runtime.MaxContentLength, + AgentCard: agentCardFor(card, root), + Host: runtime.Host, + Port: fmt.Sprint(cmp.Or(runtime.GetPort(), 8082)), + AppName: appName, + ShutdownTimeout: 5 * time.Second, + Logger: logger, + Agent: root, + Telemetry: runtimeTelemetry, + Flush: providers.ForceFlush, + }, executor) + if err != nil { + return fmt.Errorf("create app: %w", err) + } + return kagentApp.Run() +} + +// newRegistry returns the generic library every document may use. model is the +// AgentTemplate's ModelConfig, nil when it has none; workspace is the directory +// of the harness tools, empty for the actor workspace. +func newRegistry(model *configv1.ADKModelConfig, workspace string) (*declarative.Registry, error) { + reg := declarative.NewRegistry() + reg.SetModelResolver(modelResolver(model, agent.CreateLLM)) + if err := reg.RegisterHarnessTools(declarative.HarnessConfig{Workspace: workspace}); err != nil { + return nil, err + } + return reg, nil +} + +// llmFactory creates the model of a ModelConfig. +type llmFactory func(context.Context, *configv1.ADKModelConfig) (adkmodel.LLM, error) + +// modelResolver maps every model name a document uses to the ModelConfig's +// model, created once and shared by all nodes. Without a ModelConfig the name +// is an OpenAI model. +func modelResolver(model *configv1.ADKModelConfig, create llmFactory) declarative.ModelResolver { + if model == nil { + return func(ctx context.Context, name string) (adkmodel.LLM, error) { + return models.NewOpenAIModel(ctx, &models.OpenAIConfig{Model: name}) + } + } + var ( + mu sync.Mutex + llm adkmodel.LLM + ) + return func(ctx context.Context, _ string) (adkmodel.LLM, error) { + mu.Lock() + defer mu.Unlock() + if llm != nil { + return llm, nil + } + created, err := create(ctx, model) + if err != nil { + return nil, fmt.Errorf("create model from ModelConfig: %w", err) + } + llm = created + return llm, nil + } +} + +// loadDocument parses the document the controller validated and delivered. +func loadDocument(reg *declarative.Registry, delivered *configv1.ADKWorkflowConfig) (*declarative.Spec, error) { + if delivered == nil { + return nil, errors.New("no workflow document delivered: set spec.workflowFrom on the AgentTemplate") + } + spec, err := reg.ParseConfig(delivered) + if err != nil { + return nil, fmt.Errorf("load workflow document: %w", err) + } + return spec, nil +} + +// loadAgentCard reads the delivered agent card; a missing file is not an error. +func loadAgentCard(path string) (*a2atype.AgentCard, error) { + card, err := config.LoadAgentCard(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return card, err +} + +// agentCardFor returns the delivered card, or one describing the workflow root. +// Task events always stream, whether or not model responses do. +func agentCardFor(delivered *a2atype.AgentCard, root adkagent.Agent) a2atype.AgentCard { + card := a2atype.AgentCard{ + Name: root.Name(), + Description: root.Description(), + Version: "1.0.0", + SupportedInterfaces: []*a2atype.AgentInterface{ + a2atype.NewAgentInterface("/", a2atype.TransportProtocolJSONRPC), + }, + DefaultInputModes: []string{"text/plain"}, + DefaultOutputModes: []string{"text/plain"}, + } + if delivered != nil { + card = *delivered + } + card.Capabilities.Streaming = true + return card +} + +// appName is the ADK application name of the agent, namespace__NS__name as in +// the managed runtime, or the workflow root's name outside a cluster. +func appName(namespace, name, rootName string) string { + if namespace == "" || name == "" { + return rootName + } + return strings.ReplaceAll(namespace, "-", "_") + "__NS__" + strings.ReplaceAll(name, "-", "_") +} diff --git a/go/adk/examples/declarative/main_test.go b/go/adk/examples/declarative/main_test.go new file mode 100644 index 0000000000..35029041c8 --- /dev/null +++ b/go/adk/examples/declarative/main_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "log/slog" + "os" + "path/filepath" + "testing" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/stretchr/testify/require" + adkmodel "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/runner" + adksession "google.golang.org/adk/v2/session" + + "github.com/kagent-dev/kagent/go/adk/pkg/a2a" + "github.com/kagent-dev/kagent/go/adk/pkg/declarative" + apia2a "github.com/kagent-dev/kagent/go/api/a2a" + configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" +) + +func delivered(document string) *configv1.ADKWorkflowConfig { + sum := sha256.Sum256([]byte(document)) + return &configv1.ADKWorkflowConfig{Document: []byte(document), Sha256: hex.EncodeToString(sum[:])} +} + +func TestLoadDocumentRequiresTheDeliveredWorkflow(t *testing.T) { + reg, err := newRegistry(nil, t.TempDir()) + require.NoError(t, err) + + _, err = loadDocument(reg, nil) + require.ErrorContains(t, err, "spec.workflowFrom") + + spec, err := loadDocument(reg, delivered(`{"apiVersion":"adk.declarative/v0.2","kind":"AgentSystem","agents":[{"name":"delivered","agent_class":"LlmAgent","model":"stub","instruction":"hi"}]}`)) + require.NoError(t, err) + require.Equal(t, "delivered", spec.Agents[0].Name) +} + +func TestModelResolverMapsEveryNameToTheModelConfig(t *testing.T) { + model := &configv1.ADKModelConfig{} + created := 0 + resolve := modelResolver(model, func(_ context.Context, got *configv1.ADKModelConfig) (adkmodel.LLM, error) { + require.Same(t, model, got) + created++ + return declarative.NewStubModel("configured"), nil + }) + + first, err := resolve(t.Context(), "reviewer") + require.NoError(t, err) + second, err := resolve(t.Context(), "gpt-4o") + require.NoError(t, err) + require.Equal(t, first, second) + require.Equal(t, 1, created) +} + +func TestModelResolverWithoutModelConfigUsesTheNameAsAnOpenAIModel(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "test-key") // creating the client needs a key; no request is made + llm, err := modelResolver(nil, nil)(t.Context(), "gpt-4o-mini") + require.NoError(t, err) + require.Equal(t, "gpt-4o-mini", llm.Name()) +} + +func TestAppName(t *testing.T) { + require.Equal(t, "kagent__NS__writer_critics", appName("kagent", "writer-critics", "root")) + require.Equal(t, "root", appName("", "writer-critics", "root")) +} + +// exampleDocuments are the workflows this runtime is deployed with. +func exampleDocuments(t *testing.T) []string { + t.Helper() + paths, err := filepath.Glob("../workflow/*/agent-system.yaml") + require.NoError(t, err) + require.NotEmpty(t, paths) + return paths +} + +// TestExampleWorkflowsRunAndNameEveryReply runs each example document offline +// through the executor the runtime serves with: every model name resolves to +// the template's model (here the stub), and every reply reaches the chat as its +// own artifact naming the node that wrote it. +func TestExampleWorkflowsRunAndNameEveryReply(t *testing.T) { + for _, path := range exampleDocuments(t) { + t.Run(filepath.Base(filepath.Dir(path)), func(t *testing.T) { + document, err := os.ReadFile(path) + require.NoError(t, err) + reg, err := newRegistry(&configv1.ADKModelConfig{}, t.TempDir()) + require.NoError(t, err) + reg.SetModelResolver(modelResolver(&configv1.ADKModelConfig{}, func(context.Context, *configv1.ADKModelConfig) (adkmodel.LLM, error) { + return declarative.NewStubModel("template-model"), nil + })) + spec, err := loadDocument(reg, delivered(string(document))) + require.NoError(t, err) + system, err := declarative.Build(t.Context(), spec, reg) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, system.Close()) }) + + root := system.Root() + sessions := adksession.InMemoryService() + executor, err := a2a.NewKAgentExecutor(a2a.KAgentExecutorConfig{ + RunnerConfig: runner.Config{AppName: "test", Agent: root, SessionService: sessions}, + SessionService: sessions, + AppName: "test", + Logger: slog.New(slog.DiscardHandler), + }) + require.NoError(t, err) + + reqCtx := &a2asrv.ExecutorContext{ + TaskID: "task-1", + ContextID: "context-1", + Message: a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("why is the sky blue")), + } + ids := map[a2atype.ArtifactID]bool{} + authors := map[string]bool{} + for event, err := range executor.Execute(t.Context(), reqCtx) { + require.NoError(t, err) + update, ok := event.(*a2atype.TaskArtifactUpdateEvent) + if !ok || update.Artifact == nil { + continue + } + author, _ := update.Artifact.Metadata[apia2a.AuthorMetadataKey].(string) + require.NotEmpty(t, author, "artifact %s has no author", update.Artifact.ID) + require.False(t, ids[update.Artifact.ID], "reply appended to artifact %s", update.Artifact.ID) + ids[update.Artifact.ID] = true + authors[author] = true + } + require.GreaterOrEqual(t, len(authors), 2, "authors: %v", authors) + }) + } +} diff --git a/go/adk/examples/workflow/coordinator-worker.yaml b/go/adk/examples/workflow/coordinator-worker.yaml new file mode 100644 index 0000000000..d856ff3d73 --- /dev/null +++ b/go/adk/examples/workflow/coordinator-worker.yaml @@ -0,0 +1,84 @@ +# Coordinator / Worker AgentTemplates, exported from the k3s cluster (namespace kagent). +# Traced to Langfuse; scored by the `delegation_quality` LLM-as-judge (see README.md). +apiVersion: api.kagent.dev/v1alpha3 +kind: AgentTemplate +metadata: + name: coordinator + namespace: kagent + labels: + app.kubernetes.io/part-of: coordinator-example + role: coordinator +spec: + description: Coordinator agent. Decomposes a request into independent units of work, delegates each to the Worker subagent, then merges the results into one answer. + modelConfig: + name: default-model-config + systemPrompt: | + You are the Coordinator. You never do the work yourself — you plan, delegate, + verify, and synthesize. + + Available tool: `worker`, a general-purpose subagent. Each call is fully + isolated: the worker sees only the text you send it, with no memory of this + conversation or of other worker calls. So every task you hand it must be + self-contained. + + Procedure: + 1. PLAN. Split the user's request into the smallest set of tasks that are + genuinely independent. Do not over-decompose: if the request is one task, + make one call. + 2. DELEGATE. For each task, call `worker` with: + - the goal, in one sentence; + - the exact inputs/facts it needs (it cannot look them up in this chat); + - the output format you want back. + Then fan out: issue the independent worker calls together rather than one + at a time. Never exceed 5 worker calls for a single request. + 3. VERIFY. Check each reply against what you asked for. If a reply is empty, + off-scope, or reports a tool error, retry it once with a sharper task, then + record the gap instead of guessing. + 4. SYNTHESIZE. Merge the results into a single coherent answer for the user. + Attribute claims to the worker task they came from, and state explicitly + anything that could not be completed. + + Rules: + - Never fabricate a worker result; only report what the worker actually returned. + - Keep your own turns short. Your value is decomposition and aggregation, not prose. + tools: + - subAgent: + description: General-purpose Worker subagent. Send it one self-contained task with all required inputs and the desired output format. Use it for any concrete research, lookup, calculation, or production step needed to satisfy the user's request. Returns a compact result, not a conversation. + isolation: Isolated + name: worker + templateRef: + name: worker +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: AgentTemplate +metadata: + name: worker + namespace: kagent + labels: + app.kubernetes.io/part-of: coordinator-example + role: worker +spec: + description: Generic worker subagent. Executes one well-scoped task handed to it by the Coordinator and returns a structured result. Has no knowledge of the other workers, and never talks to the user directly. + modelConfig: + name: default-model-config + systemPrompt: | + You are a Worker subagent. You are invoked by a Coordinator agent, never by + a human directly. + + Contract: + - You receive ONE self-contained task. Do not ask clarifying questions and do + not expand the scope; if the task is ambiguous, state the ambiguity and make + the most reasonable assumption explicitly. + - You have no context from the parent conversation. Everything you need must + be in the task text. + - Use your tools to actually do the work. Never invent data. If a tool call + fails, report the exact error. + - Return a compact result: a one-line outcome, the supporting facts, and any + caveats. No preamble, no restating the task. + + Keep responses short enough for the Coordinator to aggregate several of them. + tools: + - mcp: + server: + kind: RemoteMCPServer + name: kagent-tool-server diff --git a/go/adk/examples/workflow/llm-router/README.md b/go/adk/examples/workflow/llm-router/README.md new file mode 100644 index 0000000000..5a4722ff48 --- /dev/null +++ b/go/adk/examples/workflow/llm-router/README.md @@ -0,0 +1,40 @@ +# llm-router: a declarative workflow agent with Langfuse evals + +A kagent agent defined entirely in YAML (`adk.declarative/v0.2`), ported from +[adk-go `examples/workflow/routing/llm`](https://github.com/google/adk-go/tree/main/examples/workflow/routing/llm) +with a second LLM stage added on each branch. + +```mermaid +graph LR + S((START)) --> C[classify: llm] + C --> R{route: cel} + R -- QUESTION --> Q[answer_question: llm] + R -- EXCLAMATION --> E[react_exclamation: llm] + R -- DEFAULT --> T[comment_statement: llm] +``` + +- `classify` answers with one word. `route` turns it into a route; anything unexpected takes `DEFAULT`. +- Each branch ends in its own `llm` node. Branches are separate terminals because exclusive routes must not converge on a non-join node. +- No Go code. The harness runs the generic workflow-agent image (`make build-workflow-agent VERSION=dev`), which resolves every model name, here `reviewer`, to the AgentTemplate's ModelConfig. + +## Files + +| File | Purpose | +|---|---| +| `agent-system.yaml` | The workflow document | +| `manifests.yaml` | Harness, AgentTemplate (`workflowFrom`) and Agent | +| `deploy.sh` | `apply` / `delete` against the k3s cluster over ssh | +| `dataset.jsonl` | 10 labelled messages for an experiment | + +## Status + +- Runs offline through the workflow-agent runtime in `go test ./adk/examples/declarative/` (from `go/`). +- Not yet deployed or run end to end. See the open items in the lecture notes below. + +## Demo steps + +1. `./deploy.sh apply`: creates the ConfigMap, Harness, Template and Agent, and waits for `Ready`. +2. Send each line of `dataset.jsonl` to the `llm-router` agent. +3. In Langfuse, open the traces. The `classify` generation holds the label; the branch node holds the reply. +4. Create an LLM-as-judge evaluator on the `classify` observation, scoring the label against the dataset's `expected` value, and run it on the traces from step 2. +5. `./deploy.sh delete` to clean up. diff --git a/go/adk/examples/workflow/llm-router/agent-system.yaml b/go/adk/examples/workflow/llm-router/agent-system.yaml new file mode 100644 index 0000000000..c7eddc807a --- /dev/null +++ b/go/adk/examples/workflow/llm-router/agent-system.yaml @@ -0,0 +1,84 @@ +# LLM-driven routing, declared in YAML. +# +# Port of google/adk-go examples/workflow/routing/llm. The Go original needs a +# custom BaseNode to turn the classifier's reply into Event.Routes; here the +# `route` CEL node does that with no Go code. +# +# START -> classify (llm) -> route (cel) -+- QUESTION -> answer_question (llm) +# +- EXCLAMATION -> react_exclamation (llm) +# +- DEFAULT -> comment_statement (llm) +# +# 1. `classify` is the first model call. It answers with one word. +# 2. `route` normalises that word and picks the branch. An off-script reply +# matches no rule and takes DEFAULT, the same fallback the Go sample uses. +# 3. `route` forwards the user's original message, not the label, because a +# single_turn llm node sees only its input. +# 4. The branch's llm node is the second model call and writes the reply. The +# Go sample's handlers only echoed the text; here each branch has its own +# instruction. +apiVersion: adk.declarative/v0.2 +kind: AgentSystem + +agents: + - name: llm_router + agent_class: Workflow + description: Classifies a message as question, exclamation or statement and routes it to a handler + nodes: + - id: classify + type: llm + model: reviewer + instruction: | + Classify the user's message into one of three categories: + - "question": ends with '?' or asks for information + - "exclamation": expresses strong emotion, often ends with '!' + - "statement": a neutral declarative sentence + + Answer with EXACTLY one word, lowercase, no punctuation: + question, exclamation, or statement. + + - id: route + type: cel + vars: + - name: label + expr: input.trim().lowerAscii() + routes: + - route: QUESTION + when: vars.label.startsWith('question') + - route: EXCLAMATION + when: vars.label.startsWith('exclamation') + # A single_turn llm node sees only its input, so forward the user's + # message, not the label. The label is kept in state for inspection. + output: user.content + state_delta: + label: vars.label + + # Second LLM stage. One single_turn llm node per branch, each with its own + # instruction. Branches stay separate terminals: mutually exclusive routes + # must not converge on a non-join node. Each node sees the user's message. + - id: answer_question + type: llm + model: reviewer + instruction: | + The user asked a question. Answer it directly in at most three sentences. + If you do not know, say so instead of guessing. + + - id: react_exclamation + type: llm + model: reviewer + instruction: | + The user expressed strong emotion. Reply in one short sentence that + acknowledges the feeling. Do not ask questions. + + - id: comment_statement + type: llm + model: reviewer + instruction: | + The user made a neutral statement. Reply with one short sentence that + adds a relevant, accurate remark. Do not ask questions. + + edges: + - { from: START, to: classify } + - { from: classify, to: route } + - { from: route, to: answer_question, route: QUESTION } + - { from: route, to: react_exclamation, route: EXCLAMATION } + - { from: route, to: comment_statement, route: DEFAULT } diff --git a/go/adk/examples/workflow/llm-router/dataset.jsonl b/go/adk/examples/workflow/llm-router/dataset.jsonl new file mode 100644 index 0000000000..a87a04e94d --- /dev/null +++ b/go/adk/examples/workflow/llm-router/dataset.jsonl @@ -0,0 +1,10 @@ +{"input": "What time is it?", "expected": "question"} +{"input": "Can you explain how a Kubernetes controller works", "expected": "question"} +{"input": "Where is the nearest train station?", "expected": "question"} +{"input": "I can't believe we won!", "expected": "exclamation"} +{"input": "This is absolutely amazing!", "expected": "exclamation"} +{"input": "Stop that right now!", "expected": "exclamation"} +{"input": "The sky is blue.", "expected": "statement"} +{"input": "Kubernetes schedules pods onto nodes.", "expected": "statement"} +{"input": "I had coffee this morning.", "expected": "statement"} +{"input": "Tell me a story", "expected": "statement"} diff --git a/go/adk/examples/workflow/llm-router/deploy.sh b/go/adk/examples/workflow/llm-router/deploy.sh new file mode 100755 index 0000000000..2845192241 --- /dev/null +++ b/go/adk/examples/workflow/llm-router/deploy.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Deploy the llm-router declarative agent to the k3s cluster over ssh. +# Usage: ./deploy.sh [apply|delete] +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +action="${1:-apply}" +ssh_k3s=(ssh -o BatchMode=yes -o ConnectTimeout=10 k3s) +image=localhost:5001/kagent-dev/kagent/workflow-agent + +case "$action" in +apply) + # Harness images must be digest-pinned; resolve the current :dev build in the cluster registry. + digest="$("${ssh_k3s[@]}" "curl -fsSI -H 'Accept: application/vnd.oci.image.index.v1+json' -H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' http://${image%%/*}/v2/${image#*/}/manifests/dev" | tr -d '\r' | awk -F': ' 'tolower($1) == "docker-content-digest" { print $2 }')" + [[ "$digest" == sha256:* ]] || { echo "cannot resolve ${image}:dev; run make build-workflow-agent VERSION=dev" >&2; exit 1; } + # The controller validates the document on reconcile; a bad one is reported on the template. + "${ssh_k3s[@]}" 'kubectl create configmap llm-router-workflow -n kagent --from-file=agent-system.yaml=/dev/stdin --dry-run=client -o yaml | kubectl apply -f -' <"$here/agent-system.yaml" + sed "s|${image}:dev|${image}@${digest}|" "$here/manifests.yaml" | "${ssh_k3s[@]}" 'kubectl apply -f -' + "${ssh_k3s[@]}" 'kubectl wait --for=condition=Ready agent.api.kagent.dev/llm-router -n kagent --timeout=180s' + ;; +delete) + "${ssh_k3s[@]}" 'kubectl delete -n kagent agent.api.kagent.dev/llm-router agenttemplate.api.kagent.dev/llm-router harness.api.kagent.dev/llm-router configmap/llm-router-workflow --ignore-not-found' + ;; +*) + echo "usage: $0 [apply|delete]" >&2 + exit 2 + ;; +esac diff --git a/go/adk/examples/workflow/llm-router/evaluator.json b/go/adk/examples/workflow/llm-router/evaluator.json new file mode 100644 index 0000000000..57d8c30534 --- /dev/null +++ b/go/adk/examples/workflow/llm-router/evaluator.json @@ -0,0 +1,21 @@ +{ + "type": "llm_as_judge", + "name": "instruction_following", + "description": "Scores one LLM call of the llm-router workflow: did the reply obey every explicit constraint in that node's system instruction. Covers classify (one word, correct category) and the three branch nodes (length, tone, no questions).", + "prompt": [ + { + "role": "user", + "content": "You are checking one LLM call from a routing workflow. The call's messages include a system instruction that defines the node's task, followed by the user's message. Judge whether the reply obeys that instruction.\n\n## Messages sent to the model\n{{input}}\n\n## Model reply\n{{output}}\n\n## How to judge\n1. Find every explicit, checkable constraint in the system instruction: required output format, exact wording or word count, length limits (for example \"at most three sentences\" or \"one short sentence\"), forbidden behaviour (for example \"do not ask questions\"), and, for a classifier, the allowed labels and their definitions.\n2. Check the reply against each one. For a classifier, also decide whether the label is the correct one for the user's message under the definitions given.\n3. A reply that is accurate but breaks any constraint fails. A reply that guesses instead of saying it does not know fails when the instruction asks for that honesty.\n\nReturn true only if every constraint is met and, for a classifier, the label is correct. Return false otherwise. Judge only what is written." + } + ], + "variableMapping": [ + { "variable": "input", "source": "input" }, + { "variable": "output", "source": "output" } + ], + "modelConfig": { "provider": "google", "model": "gemini-3.8-flash" }, + "outputDefinition": { + "dataType": "BOOLEAN", + "scoreValueInstructions": "true if every instruction constraint is met (and a classifier label is correct); false otherwise.", + "scoreReasoningInstructions": "One concise sentence naming the first violated constraint, or confirming all were met." + } +} diff --git a/go/adk/examples/workflow/llm-router/manifests.yaml b/go/adk/examples/workflow/llm-router/manifests.yaml new file mode 100644 index 0000000000..81b5e3a412 --- /dev/null +++ b/go/adk/examples/workflow/llm-router/manifests.yaml @@ -0,0 +1,51 @@ +# Harness, AgentTemplate and Agent for the declarative llm-router workflow. +# The workflow document itself is delivered through a ConfigMap built from +# agent-system.yaml by deploy.sh: +# kubectl create configmap llm-router-workflow --from-file=agent-system.yaml +# +# The harness runs the generic workflow-agent image (make build-workflow-agent +# VERSION=dev). It serves whatever document workflowFrom delivers and resolves +# every model name in it, such as `reviewer`, to the template's ModelConfig. +# Harness images must be digest-pinned: deploy.sh replaces the :dev tag below +# with the digest of the current build in the cluster registry. +apiVersion: api.kagent.dev/v1alpha3 +kind: Harness +metadata: + name: llm-router + namespace: kagent +spec: + byo: {} + substrate: + snapshotPolicy: + location: s3://ate-snapshots/llm-router + workerPoolRef: + name: kagent-default + workload: + command: + - /app + image: localhost:5001/kagent-dev/kagent/workflow-agent:dev +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: AgentTemplate +metadata: + name: llm-router + namespace: kagent +spec: + description: Classifies a message as question, exclamation or statement with an LLM and routes it to a handler + modelConfig: + name: default-model-config + workflowFrom: + name: llm-router-workflow + key: agent-system.yaml +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: Agent +metadata: + name: llm-router + namespace: kagent +spec: + harnessRef: + name: llm-router + template: + ref: + name: llm-router diff --git a/go/adk/examples/workflow/writer-critics/README.md b/go/adk/examples/workflow/writer-critics/README.md new file mode 100644 index 0000000000..525b31e23c --- /dev/null +++ b/go/adk/examples/workflow/writer-critics/README.md @@ -0,0 +1,56 @@ +# writer-critics: a bounded revision loop + +A Writer drafts a paragraph, three critics review it in parallel, and the Writer +revises until all three approve or 5 turns have passed. Declarative +(`adk.declarative/v0.2`), no Go code. + +```mermaid +graph LR + S((START)) --> W[write: agent] + W --> C1[critic_clarity: llm] + W --> C2[critic_accuracy: llm] + W --> C3[critic_style: llm] + C1 --> G[gather: join] + C2 --> G + C3 --> G + G --> D{decide: cel} + D -- DONE --> P[publish] + D -- REVISE --> W +``` + +## How the loop is bounded + +`decide` keeps `state.turn`. It takes `DONE` when all three critics approve **or** +`turn >= 5`, so the cycle cannot run more than 5 times whatever the model says. + +Two build rules shaped the graph: + +- An unconditional cycle is rejected. The loop-back edge is routed (`REVISE`). +- A node with two unconditional incoming edges is rejected. `write` has one + (`START`); the loop reaches it through the routed edge. + +## Run + +The workflow runs on the generic workflow-agent image, which has no +workflow-specific code: every model name in the document (`reviewer`) resolves +to the AgentTemplate's ModelConfig. + +```bash +make build-workflow-agent VERSION=dev # from the repository root +./deploy.sh apply +``` + +In the chat each node's reply is its own message, labelled with its author: +`writer`, `critic_clarity`, `critic_accuracy`, `critic_style`, and +`writer_critics` for the loop itself. Traces go to the exporter the Harness +telemetry configures. + +## Test + +`go/adk/examples/declarative` runs this document offline through the runtime's +executor and checks that every reply arrives as a separate artifact naming its +author: + +```bash +cd go && go test ./adk/examples/declarative/ -count=1 -v +``` diff --git a/go/adk/examples/workflow/writer-critics/agent-system.yaml b/go/adk/examples/workflow/writer-critics/agent-system.yaml new file mode 100644 index 0000000000..ae45b20df4 --- /dev/null +++ b/go/adk/examples/workflow/writer-critics/agent-system.yaml @@ -0,0 +1,127 @@ +# Writer and three critics in a bounded revision loop. +# +# START -> write -+-> critic_clarity -+ +# ^ +-> critic_accuracy-+-> gather (join) -> decide -+- DONE -> publish +# | +-> critic_style ---+ | +# +----------------------------------------------- REVISE + +# +# 1. `write` is the Writer agent. It drafts from the user's topic, and on later +# turns rewrites the draft from the critics' feedback. Its reply is saved to +# state.draft (output_key), so later nodes can read the draft. +# 2. Three critics review the draft in parallel. Each answers `APPROVE` or +# `REVISE: ` as its first words. +# 3. `gather` joins the three verdicts into one map keyed by critic. +# 4. `decide` counts the turn and the approvals. It takes DONE when all three +# approve or the turn limit is reached; otherwise it takes REVISE and its +# output, the draft plus all feedback, becomes the Writer's next input. +# 5. `publish` returns the draft with how many turns it took. +# +# The loop is bounded by state.turn, not by trust in the model: `decide` always +# takes DONE on turn 5, so the cycle runs at most 5 times. An unconditional cycle +# would be rejected at build time, and so would a second unconditional edge into +# `write`; the loop-back edge is routed (REVISE), so it is conditional. +# +# The agent node is named `write` and its agent `writer`: a node id must not equal +# an agent name. +apiVersion: adk.declarative/v0.2 +kind: AgentSystem + +agents: + - name: writer_critics + agent_class: Workflow + description: A writer drafts a paragraph, three critics review it in parallel, and the writer revises for at most 5 turns + max_concurrency: 3 + nodes: + - { id: write, type: agent, agent: writer } + + - id: critic_clarity + type: llm + model: reviewer + instruction: | + You are the Clarity critic. Review the paragraph you are given for + clarity only: is it easy to follow, free of jargon without explanation, + and well organised? + Answer with `APPROVE` if it is clear. Otherwise answer + `REVISE: ` followed by one or two sentences saying what to fix. + Your first word must be APPROVE or REVISE. + + - id: critic_accuracy + type: llm + model: reviewer + instruction: | + You are the Accuracy critic. Review the paragraph you are given for + factual accuracy only: flag any claim that is wrong, overstated or + unsupported. + Answer with `APPROVE` if every claim is sound. Otherwise answer + `REVISE: ` followed by one or two sentences naming the claim to fix. + Your first word must be APPROVE or REVISE. + + - id: critic_style + type: llm + model: reviewer + instruction: | + You are the Style critic. Review the paragraph you are given for style + only: tone, concision, and wording. The paragraph should be 60 to 120 + words and read as a single paragraph with no headings or bullets. + Answer with `APPROVE` if the style is good. Otherwise answer + `REVISE: ` followed by one or two sentences saying what to change. + Your first word must be APPROVE or REVISE. + + - { id: gather, type: join } + + - id: decide + type: cel + vars: + - name: turn + expr: default(state.turn, 0) + 1 + - name: approved + expr: | + ['critic_clarity', 'critic_accuracy', 'critic_style'] + .filter(k, input[k].trim().upperAscii().startsWith('APPROVE')) + .size() + # First match wins. REVISE is explicit so the loop-back edge is routed. + routes: + - route: DONE + when: vars.approved == 3 || vars.turn >= 5 + - route: REVISE + when: "true" + # Used only on REVISE: the Writer's next input. On DONE it is ignored. + output: | + 'Revise the DRAFT below. Address every point in the CRITIC FEEDBACK. Reply with only the revised paragraph.\n\n' + + 'DRAFT:\n' + state.draft + '\n\n' + + 'CRITIC FEEDBACK:\n' + + '- Clarity: ' + input.critic_clarity + '\n' + + '- Accuracy: ' + input.critic_accuracy + '\n' + + '- Style: ' + input.critic_style + state_delta: + turn: vars.turn + approved: vars.approved + + - id: publish + type: cel + output: | + 'Final draft after ' + string(state.turn) + ' turn(s), approved by ' + string(state.approved) + ' of 3 critics:\n\n' + state.draft + + edges: + - { from: START, to: write } + - { from: write, to: critic_clarity } + - { from: write, to: critic_accuracy } + - { from: write, to: critic_style } + - { from: critic_clarity, to: gather } + - { from: critic_accuracy, to: gather } + - { from: critic_style, to: gather } + - { from: gather, to: decide } + - { from: decide, to: publish, route: DONE } + - { from: decide, to: write, route: REVISE } + + - name: writer + agent_class: LlmAgent + model: reviewer + mode: single_turn + output_key: draft + instruction: | + You are the Writer. When given a topic, write one paragraph of 60 to 120 + words about it: a single paragraph, no headings, no bullets. + When given a DRAFT and CRITIC FEEDBACK, rewrite the draft so that it + addresses every point of feedback. + Reply with only the paragraph. diff --git a/go/adk/examples/workflow/writer-critics/deploy.sh b/go/adk/examples/workflow/writer-critics/deploy.sh new file mode 100755 index 0000000000..64c68de56e --- /dev/null +++ b/go/adk/examples/workflow/writer-critics/deploy.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Deploy the writer-critics declarative agent to the k3s cluster over ssh. +# Usage: ./deploy.sh [apply|delete] +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +action="${1:-apply}" +ssh_k3s=(ssh -o BatchMode=yes -o ConnectTimeout=10 k3s) +image=localhost:5001/kagent-dev/kagent/workflow-agent + +case "$action" in +apply) + # Harness images must be digest-pinned; resolve the current :dev build in the cluster registry. + digest="$("${ssh_k3s[@]}" "curl -fsSI -H 'Accept: application/vnd.oci.image.index.v1+json' -H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' http://${image%%/*}/v2/${image#*/}/manifests/dev" | tr -d '\r' | awk -F': ' 'tolower($1) == "docker-content-digest" { print $2 }')" + [[ "$digest" == sha256:* ]] || { echo "cannot resolve ${image}:dev; run make build-workflow-agent VERSION=dev" >&2; exit 1; } + # The controller validates the document on reconcile; a bad one is reported on the template. + "${ssh_k3s[@]}" 'kubectl create configmap writer-critics-workflow -n kagent --from-file=agent-system.yaml=/dev/stdin --dry-run=client -o yaml | kubectl apply -f -' <"$here/agent-system.yaml" + sed "s|${image}:dev|${image}@${digest}|" "$here/manifests.yaml" | "${ssh_k3s[@]}" 'kubectl apply -f -' + "${ssh_k3s[@]}" 'kubectl wait --for=condition=Ready agent.api.kagent.dev/writer-critics -n kagent --timeout=180s' + ;; +delete) + "${ssh_k3s[@]}" 'kubectl delete -n kagent agent.api.kagent.dev/writer-critics agenttemplate.api.kagent.dev/writer-critics harness.api.kagent.dev/writer-critics configmap/writer-critics-workflow --ignore-not-found' + ;; +*) + echo "usage: $0 [apply|delete]" >&2 + exit 2 + ;; +esac diff --git a/go/adk/examples/workflow/writer-critics/evaluator.json b/go/adk/examples/workflow/writer-critics/evaluator.json new file mode 100644 index 0000000000..07f2b6e70c --- /dev/null +++ b/go/adk/examples/workflow/writer-critics/evaluator.json @@ -0,0 +1,21 @@ +{ + "type": "llm_as_judge", + "name": "feedback_addressed", + "description": "Scores one Writer call of the writer-critics loop: when the call is a revision (the input has a DRAFT and CRITIC FEEDBACK), did the rewrite address every REVISE point without losing what was already good. First-turn drafts have no feedback and pass.", + "prompt": [ + { + "role": "user", + "content": "You are checking one call of a Writer in a write-and-critique loop.\n\n## Messages sent to the Writer\n{{input}}\n\n## Writer reply\n{{output}}\n\n## How to judge\n1. Look in the messages for a section headed CRITIC FEEDBACK together with a DRAFT. If there is none, this is a first draft: return true.\n2. Otherwise list every feedback line that starts with REVISE. Lines that start with APPROVE need no change and must not be undone.\n3. Check that the reply addresses every REVISE point. A point is addressed only if the reply visibly changes in the way the critic asked; restating the draft unchanged fails.\n4. The reply must still be a single paragraph, with no headings or bullets, and must not introduce unrelated new claims.\n\nReturn true only if every REVISE point is addressed and the constraints in step 4 hold. Return false otherwise. Judge only what is written." + } + ], + "variableMapping": [ + { "variable": "input", "source": "input" }, + { "variable": "output", "source": "output" } + ], + "modelConfig": { "provider": "google", "model": "gemini-3.8-flash" }, + "outputDefinition": { + "dataType": "BOOLEAN", + "scoreValueInstructions": "true if this is a first draft, or every REVISE point was addressed and the paragraph constraints hold; false otherwise.", + "scoreReasoningInstructions": "One concise sentence naming the first unaddressed point, or confirming all were addressed." + } +} diff --git a/go/adk/examples/workflow/writer-critics/manifests.yaml b/go/adk/examples/workflow/writer-critics/manifests.yaml new file mode 100644 index 0000000000..c735459286 --- /dev/null +++ b/go/adk/examples/workflow/writer-critics/manifests.yaml @@ -0,0 +1,50 @@ +# Harness, AgentTemplate and Agent for the declarative writer-critics workflow. +# The workflow document is delivered through a ConfigMap built from agent-system.yaml: +# kubectl create configmap writer-critics-workflow --from-file=agent-system.yaml +# +# The harness runs the generic workflow-agent image (make build-workflow-agent +# VERSION=dev). It serves whatever document workflowFrom delivers and resolves +# every model name in it, such as `reviewer`, to the template's ModelConfig. +# Harness images must be digest-pinned: deploy.sh replaces the :dev tag below +# with the digest of the current build in the cluster registry. +apiVersion: api.kagent.dev/v1alpha3 +kind: Harness +metadata: + name: writer-critics + namespace: kagent +spec: + byo: {} + substrate: + snapshotPolicy: + location: s3://ate-snapshots/writer-critics + workerPoolRef: + name: kagent-default + workload: + command: + - /app + image: localhost:5001/kagent-dev/kagent/workflow-agent:dev +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: AgentTemplate +metadata: + name: writer-critics + namespace: kagent +spec: + description: A writer drafts a paragraph, three critics review it in parallel, and the writer revises for at most 5 turns + modelConfig: + name: default-model-config + workflowFrom: + name: writer-critics-workflow + key: agent-system.yaml +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: Agent +metadata: + name: writer-critics + namespace: kagent +spec: + harnessRef: + name: writer-critics + template: + ref: + name: writer-critics diff --git a/go/adk/examples/workflowpatterns/anti/converging_routes.yaml b/go/adk/examples/workflowpatterns/anti/converging_routes.yaml new file mode 100644 index 0000000000..03e1160fd9 --- /dev/null +++ b/go/adk/examples/workflowpatterns/anti/converging_routes.yaml @@ -0,0 +1,16 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: converging_routes + agent_class: Workflow + nodes: + - { id: classify, type: function, function: classify } + - { id: bug, type: function, function: bug_handler } + - { id: billing, type: function, function: billing_handler } + - { id: reply, type: function, function: final_report } + edges: + - { from: START, to: classify } + - { from: classify, to: bug, route: BUG } + - { from: classify, to: billing, route: BILLING } + - { from: bug, to: reply } + - { from: billing, to: reply } diff --git a/go/adk/examples/workflowpatterns/anti/fan_out_without_gather.yaml b/go/adk/examples/workflowpatterns/anti/fan_out_without_gather.yaml new file mode 100644 index 0000000000..5207989607 --- /dev/null +++ b/go/adk/examples/workflowpatterns/anti/fan_out_without_gather.yaml @@ -0,0 +1,13 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: fan_out_without_gather + agent_class: Workflow + nodes: + - { id: shout, type: function, function: upper } + - { id: whisper, type: function, function: lower } + - { id: mirror, type: function, function: reverse } + edges: + - { from: START, to: shout } + - { from: START, to: whisper } + - { from: START, to: mirror } diff --git a/go/adk/examples/workflowpatterns/anti/multicast_without_gather.yaml b/go/adk/examples/workflowpatterns/anti/multicast_without_gather.yaml new file mode 100644 index 0000000000..af2da2323c --- /dev/null +++ b/go/adk/examples/workflowpatterns/anti/multicast_without_gather.yaml @@ -0,0 +1,13 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: multicast_without_gather + agent_class: Workflow + nodes: + - { id: audience, type: function, function: audience } + - { id: audit, type: function, function: auditor } + - { id: notify, type: function, function: notifier } + edges: + - { from: START, to: audience } + - { from: audience, to: audit, route: audit } + - { from: audience, to: notify, route: notify } diff --git a/go/adk/examples/workflowpatterns/anti/schema_mismatch.yaml b/go/adk/examples/workflowpatterns/anti/schema_mismatch.yaml new file mode 100644 index 0000000000..50a84764b9 --- /dev/null +++ b/go/adk/examples/workflowpatterns/anti/schema_mismatch.yaml @@ -0,0 +1,11 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: schema_mismatch + agent_class: Workflow + nodes: + - { id: produce, type: function, function: clean, output_schema: { type: string } } + - { id: consume, type: function, function: clean, input_schema: { type: integer } } + edges: + - { from: START, to: produce } + - { from: produce, to: consume } diff --git a/go/adk/examples/workflowpatterns/anti/task_mode_node.yaml b/go/adk/examples/workflowpatterns/anti/task_mode_node.yaml new file mode 100644 index 0000000000..1212e7fdbf --- /dev/null +++ b/go/adk/examples/workflowpatterns/anti/task_mode_node.yaml @@ -0,0 +1,10 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: task_mode_node + agent_class: Workflow + nodes: + - { id: work, type: agent, agent: worker } + edges: + - { from: START, to: work } + - { name: worker, agent_class: LlmAgent, model: stub, mode: task, instruction: Do the task. } diff --git a/go/adk/examples/workflowpatterns/anti/terminal_tool.yaml b/go/adk/examples/workflowpatterns/anti/terminal_tool.yaml new file mode 100644 index 0000000000..85d0146670 --- /dev/null +++ b/go/adk/examples/workflowpatterns/anti/terminal_tool.yaml @@ -0,0 +1,11 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: terminal_tool + agent_class: Workflow + nodes: + - { id: args, type: function, function: customer_args } + - { id: lookup, type: tool, tool: lookup_customer } + edges: + - { from: START, to: args } + - { from: args, to: lookup } diff --git a/go/adk/examples/workflowpatterns/anti/unconditional_cycle.yaml b/go/adk/examples/workflowpatterns/anti/unconditional_cycle.yaml new file mode 100644 index 0000000000..5485bdf1f5 --- /dev/null +++ b/go/adk/examples/workflowpatterns/anti/unconditional_cycle.yaml @@ -0,0 +1,12 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: unconditional_cycle + agent_class: Workflow + nodes: + - { id: first, type: function, function: clean } + - { id: second, type: function, function: upper } + edges: + - { from: START, to: first } + - { from: first, to: second } + - { from: second, to: first } diff --git a/go/adk/examples/workflowpatterns/catalog.go b/go/adk/examples/workflowpatterns/catalog.go new file mode 100644 index 0000000000..b1f635354c --- /dev/null +++ b/go/adk/examples/workflowpatterns/catalog.go @@ -0,0 +1,412 @@ +package main + +import ( + "context" + "embed" + "fmt" + "path" + "reflect" + "runtime" + "slices" + "strings" + + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/memory" + "google.golang.org/adk/v2/runner" + "google.golang.org/adk/v2/session" + "google.golang.org/adk/v2/workflow" + "google.golang.org/genai" + + "github.com/kagent-dev/kagent/go/adk/pkg/declarative" +) + +//go:embed patterns/*.yaml anti/*.yaml +var patternFiles embed.FS + +// Pattern is one workflow design pattern written twice: with the ADK Go API +// and as a declarative YAML document. +type Pattern struct { + Name string + Title string + Summary string + // Shape sketches the graph. + Shape string + // Go builds the pattern with the ADK Go API. + Go func() (agent.Agent, error) + // Ordered patterns compare traces in event order; others as sorted sets, + // because branches run concurrently. + Ordered bool + Cases []Case +} + +// Case is one run of a pattern. +type Case struct { + Input string + // Replies answer, in order, the human-input requests the run raises. + Replies []string + // Want lists trace lines that must appear; Absent lists ones that must not. + Want []string + Absent []string +} + +// YAML returns the pattern's declarative document. +func (p Pattern) YAML() (string, error) { + data, err := patternFiles.ReadFile(path.Join("patterns", p.Name+".yaml")) + return string(data), err +} + +// GoFuncName is the name of the Go builder, used to quote its source. +func (p Pattern) GoFuncName() string { + full := runtime.FuncForPC(reflect.ValueOf(p.Go).Pointer()).Name() + return full[strings.LastIndex(full, ".")+1:] +} + +// BuildYAML builds the pattern from its YAML document. +func (p Pattern) BuildYAML(ctx context.Context) (agent.Agent, error) { + doc, err := p.YAML() + if err != nil { + return nil, err + } + reg, err := newRegistry() + if err != nil { + return nil, err + } + spec, err := reg.Parse([]byte(doc)) + if err != nil { + return nil, fmt.Errorf("failed to parse %s.yaml: %w", p.Name, err) + } + system, err := declarative.Build(ctx, spec, reg) + if err != nil { + return nil, fmt.Errorf("failed to build %s.yaml: %w", p.Name, err) + } + return system.Root(), nil +} + +// Run executes root once for input, answering human-input requests with +// replies, and returns its trace. +func Run(ctx context.Context, root agent.Agent, input string, replies []string) ([]string, error) { + sessions := session.InMemoryService() + created, err := sessions.Create(ctx, &session.CreateRequest{AppName: "patterns", UserID: "user"}) + if err != nil { + return nil, err + } + r, err := runner.New(runner.Config{AppName: "patterns", Agent: root, SessionService: sessions, MemoryService: memory.InMemoryService()}) + if err != nil { + return nil, err + } + + var trace []string + message := genai.NewContentFromText(input, genai.RoleUser) + for { + var pending *session.RequestInput + for ev, err := range r.Run(ctx, "user", created.Session.ID(), message, agent.RunConfig{}) { + if err != nil { + return trace, err + } + if ev.RequestedInput != nil { + pending = ev.RequestedInput + } + trace = append(trace, traceLine(ev)) + } + if pending == nil { + break + } + if len(replies) == 0 { + return trace, fmt.Errorf("run is waiting for input %q and no reply is left", pending.Message) + } + message = &genai.Content{Role: genai.RoleUser, Parts: []*genai.Part{{FunctionResponse: &genai.FunctionResponse{ + ID: pending.InterruptID, Name: workflow.WorkflowInputFunctionCallName, + Response: map[string]any{"response": replies[0]}, + }}}} + replies = replies[1:] + } + return slices.DeleteFunc(trace, func(line string) bool { return line == "" }), nil +} + +// traceLine summarizes an event as what a node produced or an agent said. +func traceLine(ev *session.Event) string { + switch { + case ev.RequestedInput != nil: + return "ask:" + ev.RequestedInput.Message + case ev.Output != nil: + return "out:" + fmt.Sprint(ev.Output) + case ev.Content != nil: + var text strings.Builder + for _, part := range ev.Content.Parts { + if part != nil { + text.WriteString(part.Text) + } + } + if text.Len() > 0 { + return "say:" + ev.Author + ":" + text.String() + } + } + return "" +} + +// Normalize orders a trace for comparison unless the pattern is ordered. +func (p Pattern) Normalize(trace []string) []string { + trace = slices.Clone(trace) + if !p.Ordered { + slices.Sort(trace) + } + return trace +} + +// Catalog lists every pattern in presentation order. +func Catalog() []Pattern { return catalog } + +// Find returns the named pattern. +func Find(name string) (Pattern, bool) { + for _, p := range catalog { + if p.Name == name { + return p, true + } + } + return Pattern{}, false +} + +var catalog = []Pattern{ + { + Name: "sequence", Title: "Sequence (pipeline)", Go: sequenceGo, Ordered: true, + Summary: "Run steps one after another; each step's output is the next step's input.", + Shape: "START → clean → upper → exclaim", + Cases: []Case{{Input: " hello ", Want: []string{"out:HELLO!"}}}, + }, + { + Name: "router", Title: "Router (content-based, with default)", Go: routerGo, + Summary: "A classifier picks exactly one branch by route; DEFAULT catches everything else.", + Shape: "START → classify ─BUG→ bug\n ├BILLING→ billing\n └DEFAULT→ fallback", + Cases: []Case{ + {Input: "bug: crash", Want: []string{"out:bug handled: bug: crash"}, Absent: []string{"out:fallback: bug: crash"}}, + {Input: "billing: invoice", Want: []string{"out:billing handled: billing: invoice"}}, + {Input: "hello", Want: []string{"out:fallback: hello"}}, + }, + }, + { + Name: "conditional", Title: "Conditional (if / else)", Go: conditionalGo, + Summary: "A boolean decision sends the message down one of two branches. Go uses BoolRoute; YAML uses the strings \"true\" and \"false\", which is what BoolRoute matches.", + Shape: "START → is_long ─true→ truncate\n └false→ keep", + Cases: []Case{ + {Input: "short", Want: []string{"out:short"}, Absent: []string{"out:short..."}}, + {Input: "a very long message", Want: []string{"out:a very lon..."}}, + }, + }, + { + Name: "fan_in", Title: "Fan-out and fan-in (scatter-gather)", Go: fanInGo, + Summary: "Several unconditional edges leave one node and its branches run concurrently. A join node is the barrier that waits for all of them and hands their outputs, keyed by node, to one aggregator. max_concurrency caps how many nodes of the graph run at once.", + Shape: "START → shout ─┐\n ├→ whisper ─┼→ gather → combine\n └→ mirror ─┘", + Cases: []Case{{Input: "Go", Want: []string{"out:mirror=oG|shout=GO|whisper=go"}}}, + }, + { + Name: "map_reduce", Title: "Map-reduce (dynamic fan-out)", Go: mapReduceGo, Ordered: true, + Summary: "A parallel worker runs one node per item of a list at run time, then a reducer combines the results. Fan-out width is data-dependent.", + Shape: "START → split → shout_all (×N items) → reduce", + Cases: []Case{{Input: "a b c", Want: []string{"out:A B C"}}}, + }, + { + Name: "retry_loop", Title: "Loop until done (conditional cycle)", Go: retryLoopGo, Ordered: true, + Summary: "A routed edge points back to an earlier node. A cycle is allowed as long as it contains a conditional edge, so it can end.", + Shape: "START → grow → check ─again→ grow\n └done→ finish", + Cases: []Case{{Input: "ab", Want: []string{"out:final: ab!!!"}}}, + }, + { + Name: "human_approval", Title: "Human approval gate (pause / resume)", Go: humanApprovalGo, Ordered: true, + Summary: "A hitl node pauses the run and asks a person; the reply resumes it and can drive a router.", + Shape: "START → draft → approve ⏸ → verdict ─APPROVED→ publish\n └REJECTED→ reject", + Cases: []Case{ + {Input: "ship it", Replies: []string{"yes"}, Want: []string{"ask:Publish this draft?", "out:published after reply: yes"}, Absent: []string{"out:rejected after reply: yes"}}, + {Input: "ship it", Replies: []string{"no"}, Want: []string{"ask:Publish this draft?", "out:rejected after reply: no"}}, + }, + }, + { + Name: "nested", Title: "Nested workflow (sub-workflow as a node)", Go: nestedGo, Ordered: true, + Summary: "A whole graph with its own START is one node of an outer graph, so a sub-flow can be reused and reasoned about alone.", + Shape: "START → prep → subflow[ START → plan → execute ] → finish", + Cases: []Case{{Input: " x ", Want: []string{"out:exec(plan(x))!"}}}, + }, + { + Name: "dynamic", Title: "Dynamic orchestrator (bounded child runs)", Go: dynamicGo, Ordered: true, + Summary: "A dynamic node runs a child node as many times as its code decides, under a hard iteration budget. In YAML the budget is max_iters and the child is declared in children.", + Shape: "START → prep → refine (runs critic ≤ 3×) → finish", + Cases: []Case{{Input: " x ", Want: []string{"out:critic(critic(critic(x)))!"}}}, + }, + { + Name: "llm_stage", Title: "LLM stage inside a graph", Go: llmStageGo, Ordered: true, + Summary: "A model-backed agent is one step of a deterministic graph. The offline stub model echoes its input, so the run needs no credentials.", + Shape: "START → prep → answer (LLM) → finish", + Cases: []Case{{Input: "hi", Want: []string{"say:answer:[stub] hi", "out:[stub] hi!"}}}, + }, + { + Name: "tool_stage", Title: "Tool stage inside a graph", Go: toolStageGo, Ordered: true, + Summary: "A tool runs as a graph node: the previous output is its arguments and its result feeds the next step.", + Shape: "START → args → lookup (tool) → describe", + Cases: []Case{{Input: "c-7", Want: []string{"out:customer c-7 is on plan pro"}}}, + }, + { + Name: "cel_router", Title: "CEL router with state (no Go code)", Go: celRouterGo, + Summary: "A cel node classifies with the CEL standard library and regular expressions, picks a route and writes extracted ids to state in the same event; later cel nodes format replies from input and state. The Go twin is the same graph with function nodes.", + Shape: "START → classify (CEL) ─BUG→ bug\n ├BILLING→ billing (reads state.ids)\n └DEFAULT→ other", + Cases: []Case{ + {Input: "App crash on login", Want: []string{"out:bug: App crash on login"}}, + {Input: "refund for c-7 and c-9", Want: []string{"out:billing for c-7,c-9"}}, + {Input: "refund please", Want: []string{"out:billing for unknown customer"}}, + {Input: "hello", Want: []string{"out:other: hello"}}, + }, + }, + { + Name: "mcp_tool", Title: "Remote MCP tool as a function node", Go: mcpToolGo, Ordered: true, + Summary: "A tool node with server: calls one tool of a remote MCP server declared in mcp_servers (URL from the environment); CEL builds its arguments and reads its structured result. The Go twin wraps an ADK mcptoolset in a function node. Both call the same streamable HTTP server.", + Shape: "START → args (CEL) → lookup (crm MCP) → describe (CEL)", + Cases: []Case{{Input: "c-7", Want: []string{"out:customer c-7 is on plan enterprise"}}}, + }, + { + Name: "typed_contract", Title: "Typed contract between steps (JSON Schema)", Go: typedContractGo, Ordered: true, + Summary: "A node can carry an input and an output JSON Schema. The engine validates what the node returns and what the next node receives, and compares the two schemas on every edge when it builds the graph.", + Shape: "START → parse (output: {name}) → greet (input: {name})", + Cases: []Case{{Input: "Ada", Want: []string{"out:hello Ada"}}}, + }, + { + Name: "resilient", Title: "Resilient step (retry and timeout)", Go: resilientGo, Ordered: true, + Summary: "A node carries a retry policy, a CEL retry predicate (retry.when) and a timeout. The step below fails twice, then succeeds on the third attempt.", + Shape: "START → unreliable (retry ×3, timeout 5s)", + Cases: []Case{{Input: "go", Want: []string{"out:recovered: go"}}}, + }, + { + Name: "fan_out_agents", Title: "Fan-out of agents (isolated by default)", Go: fanOutAgentsGo, + Summary: "Concurrent branches get their own branch of the session: neither agent is shown the other's reply, so both answer saw=1 (just the user's message). Sibling isolation is automatic for static fan-out, parallel workers and ParallelAgent.", + Shape: "START → left ─┐\n └→ right ─┴→ gather → combine", + Cases: []Case{{Input: "hi", Want: []string{"out:left=saw=1|right=saw=1"}}}, + }, + { + Name: "shared_children", Title: "Dynamic children sharing the conversation", Go: sharedChildrenGo, Ordered: true, + Summary: "By default a dynamic node runs its children on its own branch, so the second child is shown what the first one said (saw=3 against saw=1).", + Shape: "START → review (runs first, then second, shared)", + Cases: []Case{{Input: "hi", Want: []string{"out:first saw=1, second saw=3"}}}, + }, + { + Name: "isolated_children", Title: "Dynamic children isolated from each other", Go: isolatedChildrenGo, Ordered: true, + Summary: "isolation: branch (WithUseSubBranch in Go) gives every child run its own branch, so the second child is back to saw=1 and no longer sees the first one's reply. Use isolation: scope (WithIsolationScopeFromNodePath) to hide ancestor events too.", + Shape: "START → review (runs first, then second, each on its own branch)", + Cases: []Case{{Input: "hi", Want: []string{"out:first saw=1, second saw=1"}}}, + }, + { + Name: "agent_as_tool", Title: "Agent as a tool (a session of its own)", Go: agentAsToolGo, Ordered: true, + Summary: "agent_tools (agenttool in Go) runs the agent in a new session, seeded with a copy of the caller's state, so it sees only the request it was given (saw=1) and parallel calls cannot interfere. A sub_agent, by contrast, shares the caller's session.", + Shape: "caller ──call──▶ specialist (own session)", + Cases: []Case{{Input: "question", Want: []string{"say:agent_as_tool:tool said: saw=1"}}}, + }, + { + Name: "sequential_agent", Title: "Sequential agents", Go: sequentialAgentGo, Ordered: true, + Summary: "The classic ADK SequentialAgent runs its sub-agents in order; each sees the conversation so far, including what earlier agents said.", + Shape: "draft → review", + Cases: []Case{{Input: "topic", Want: []string{"say:draft:[stub] topic", "say:review:[stub] For context:[draft] said: [stub] topic"}}}, + }, + { + Name: "parallel_agent", Title: "Parallel agents (ensemble)", Go: parallelAgentGo, + Summary: "The classic ADK ParallelAgent runs its sub-agents concurrently in isolated branches.", + Shape: "optimist ∥ pessimist", + Cases: []Case{{Input: "topic", Want: []string{"say:optimist:[stub] topic", "say:pessimist:[stub] topic"}}}, + }, + { + Name: "loop_agent", Title: "Loop agent (bounded repetition)", Go: loopAgentGo, Ordered: true, + Summary: "The classic ADK LoopAgent repeats its sub-agents until max_iterations or until one escalates.", + Shape: "polish ×2", + Cases: []Case{{Input: "topic", Want: []string{"say:polish:[stub] topic", "say:polish:[stub] topic"}}}, + }, +} + +// AntiPattern is a graph that looks reasonable but is rejected. The Go and +// YAML versions must fail the same way. +type AntiPattern struct { + Name string + Title string + Why string + Go func() (agent.Agent, error) + // GoRejects is false when the ADK Go API accepts the graph and only the + // declarative validation refuses it. + GoRejects bool + // AtRun is true when the failure only shows when the graph runs. + AtRun bool + Input string + WantErr string +} + +// YAML returns the anti-pattern's declarative document. +func (a AntiPattern) YAML() (string, error) { + data, err := patternFiles.ReadFile(path.Join("anti", a.Name+".yaml")) + return string(data), err +} + +// GoFuncName is the name of the Go builder, used to quote its source. +func (a AntiPattern) GoFuncName() string { + if a.Go == nil { + return "" + } + full := runtime.FuncForPC(reflect.ValueOf(a.Go).Pointer()).Name() + return full[strings.LastIndex(full, ".")+1:] +} + +// BuildYAML builds the document, which may be rejected. +func (a AntiPattern) BuildYAML(ctx context.Context) (agent.Agent, error) { + doc, err := a.YAML() + if err != nil { + return nil, err + } + reg, err := newRegistry() + if err != nil { + return nil, err + } + spec, err := reg.Parse([]byte(doc)) + if err != nil { + return nil, err + } + system, err := declarative.Build(ctx, spec, reg) + if err != nil { + return nil, err + } + return system.Root(), nil +} + +// AntiPatterns lists the rejected graphs in presentation order. +func AntiPatterns() []AntiPattern { return antiPatterns } + +var antiPatterns = []AntiPattern{ + { + Name: "unconditional_cycle", Title: "Unconditional cycle", Go: unconditionalCycleGo, GoRejects: true, + Why: "An edge without a route that points back never ends. Put a routed edge in the cycle, as in the loop pattern.", + WantErr: "unconditional cycle", + }, + { + Name: "converging_routes", Title: "Mutually exclusive routes converging on one node", Go: convergingRoutesGo, GoRejects: true, + Why: "Only a join may receive several incoming edges. Give each route its own terminal, or end both with the same logic in separate nodes.", + WantErr: "non-JoinNode fan-in", + }, + { + Name: "fan_out_without_gather", Title: "Fan-out without a gather", Go: fanOutWithoutGatherGo, GoRejects: true, AtRun: true, + Why: "A workflow has one result. Two or more terminal nodes that produce output fail at run time; converge the branches with a join.", + Input: "Go", + WantErr: "multiple terminal nodes produced output", + }, + { + Name: "multicast_without_gather", Title: "Multicast routes without a gather", Go: multicastWithoutGatherGo, GoRejects: true, AtRun: true, + Why: "A router that emits several routes starts several branches, which hits the same one-result rule. Only multicast to branches that finish without output, or end the branches in a join.", + Input: "urgent: x", + WantErr: "multiple terminal nodes produced output", + }, + { + Name: "task_mode_node", Title: "Task-mode agent as a graph node", Go: taskModeNodeGo, GoRejects: true, + Why: "A task-mode agent is multi-turn and cannot be a static graph node. Use single_turn, or run it dynamically.", + WantErr: "task", + }, + { + Name: "schema_mismatch", Title: "Schemas that disagree across an edge", Go: schemaMismatchGo, GoRejects: true, + Why: "A step that promises a string cannot feed a step that requires an integer. The engine compares the schemas when it builds the graph, so the mistake never reaches a run.", + WantErr: "schema mismatch", + }, + { + Name: "terminal_tool", Title: "Ending a branch on a tool node", Go: nil, + Why: "The ADK accepts it, but the tool's structured result is data that no A2A client displays. The declarative validation asks for a closing function node.", + WantErr: "terminal tool node emits structured output", + }, +} diff --git a/go/adk/examples/workflowpatterns/crm.go b/go/adk/examples/workflowpatterns/crm.go new file mode 100644 index 0000000000..2b0fa844db --- /dev/null +++ b/go/adk/examples/workflowpatterns/crm.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "sync" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// crmURLEnv names the environment variable the mcp_tool YAML reads its +// server URL from. +const crmURLEnv = "PATTERNS_CRM_URL" + +var crmOnce = sync.OnceValue(func() string { + server := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "crm", Version: "v1"}, nil) + type lookupIn struct { + CustomerID string `json:"customer_id"` + } + type customer struct { + ID string `json:"id"` + Plan string `json:"plan"` + } + mcpsdk.AddTool(server, &mcpsdk.Tool{Name: "lookup_customer", Description: "Looks up a customer by id."}, + func(_ context.Context, _ *mcpsdk.CallToolRequest, in lookupIn) (*mcpsdk.CallToolResult, customer, error) { + return nil, customer{ID: in.CustomerID, Plan: "enterprise"}, nil + }) + handler := mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { return server }, nil) + return httptest.NewServer(handler).URL +}) + +// crmURL starts the demo CRM MCP server on loopback once per process and +// publishes its URL to the environment for the YAML document. +func crmURL() (string, error) { + url := crmOnce() + return url, os.Setenv(crmURLEnv, url) +} diff --git a/go/adk/examples/workflowpatterns/doc_test.go b/go/adk/examples/workflowpatterns/doc_test.go new file mode 100644 index 0000000000..b6d5c5eb90 --- /dev/null +++ b/go/adk/examples/workflowpatterns/doc_test.go @@ -0,0 +1,198 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +var update = flag.Bool("update", false, "rewrite docs/architecture/workflow-patterns.md") + +const docPath = "../../../../docs/architecture/workflow-patterns.md" + +// TestDocIsCurrent keeps the published table identical to the code that the +// other tests run. Regenerate it with: +// +// go test ./adk/examples/workflowpatterns -run TestDocIsCurrent -update +func TestDocIsCurrent(t *testing.T) { + rendered, err := renderDoc() + require.NoError(t, err) + if *update { + require.NoError(t, os.WriteFile(docPath, []byte(rendered), 0o644)) + return + } + current, err := os.ReadFile(docPath) + require.NoError(t, err) + require.Equal(t, rendered, string(current), "docs/architecture/workflow-patterns.md is stale; run the test with -update") +} + +// funcSource returns the source of a top-level function, with its comment. +func funcSource(file, name string) (string, error) { + src, err := os.ReadFile(file) + if err != nil { + return "", err + } + fset := token.NewFileSet() + parsed, err := parser.ParseFile(fset, file, src, parser.ParseComments) + if err != nil { + return "", err + } + for _, decl := range parsed.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != name { + continue + } + start := fn.Pos() + if fn.Doc != nil { + start = fn.Doc.Pos() + } + return string(src[fset.Position(start).Offset:fset.Position(fn.End()).Offset]), nil + } + return "", fmt.Errorf("function %s not found in %s", name, file) +} + +func block(lang, body string) string { + return "```" + lang + "\n" + strings.TrimRight(body, "\n") + "\n```" +} + +func renderDoc() (string, error) { + var b bytes.Buffer + b.WriteString(docIntro) + + b.WriteString("\n## Building blocks\n\n") + b.WriteString("Both columns use the same behavior, so only the wiring differs. The Go column wraps a plain function with `step` or `router`; the YAML column names the same function, which the registry exposes through `textFunc` and `routeFunc`. The behaviors themselves (`clean`, `upper`, `classify`, ...) are one-liners in `helpers.go`.\n\n") + for _, ref := range []struct{ file, name string }{ + {"helpers.go", "step"}, {"helpers.go", "router"}, {"registry.go", "textFunc"}, {"registry.go", "routeFunc"}, + } { + src, err := funcSource(ref.file, ref.name) + if err != nil { + return "", err + } + b.WriteString(block("go", src) + "\n\n") + } + + b.WriteString("## Patterns\n\n") + b.WriteString("Every row is built twice and run by the tests; the Go and YAML versions must produce identical results. The last line of each Pattern cell is one verified run.\n\n") + b.WriteString("\n\n\n") + for _, p := range Catalog() { + goSrc, err := funcSource("patterns.go", p.GoFuncName()) + if err != nil { + return "", err + } + yamlSrc, err := p.YAML() + if err != nil { + return "", err + } + fmt.Fprintf(&b, "\n\n\n\n\n", + p.Title, p.Summary, block("text", p.Shape), verifiedRun(p), block("go", goSrc), block("yaml", yamlSrc)) + } + b.WriteString("\n
PatternGo (native ADK)YAML (declarative)
\n\n**%s**\n\n%s\n\n%s\n\n%s\n\n\n\n%s\n\n\n\n%s\n\n
\n") + + b.WriteString("\n## Rejected graphs\n\n") + b.WriteString("These look reasonable and fail. Each failure is asserted for both versions, so the YAML validation does not hide a graph the Go API accepts, or the reverse.\n\n") + b.WriteString("\n\n\n") + for _, a := range AntiPatterns() { + goCell := "_The ADK Go API accepts this graph; only the declarative validation refuses it._" + if a.Go != nil { + src, err := funcSource("patterns.go", a.GoFuncName()) + if err != nil { + return "", err + } + goCell = block("go", src) + } + yamlSrc, err := a.YAML() + if err != nil { + return "", err + } + when := "when built" + if a.AtRun { + when = "when run" + } + fmt.Fprintf(&b, "\n\n\n\n\n", + a.Title, a.Why, when, block("text", "…"+a.WantErr+"…"), goCell, block("yaml", yamlSrc)) + } + b.WriteString("\n
Anti-patternGo (native ADK)YAML (declarative)
\n\n**%s**\n\n%s\n\nFails %s with:\n\n%s\n\n\n\n%s\n\n\n\n%s\n\n
\n") + b.WriteString(docOutro) + return b.String(), nil +} + +// verifiedRun shows the first documented case of a pattern. +func verifiedRun(p Pattern) string { + c := p.Cases[0] + line := fmt.Sprintf("Run: `%q`", c.Input) + for _, reply := range c.Replies { + line += fmt.Sprintf(", reply `%q`", reply) + } + return line + " → `" + c.Want[len(c.Want)-1] + "`" +} + +func TestVerifiedRunNamesATrace(t *testing.T) { + for _, p := range Catalog() { + require.NotEmpty(t, filepath.Base(p.Name)) + require.Contains(t, verifiedRun(p), p.Cases[0].Want[len(p.Cases[0].Want)-1]) + } +} + +const docIntro = `# Workflow Design Patterns + +Each pattern below is written twice: with the Go ADK API (` + "`google.golang.org/adk/v2/workflow`" + `, described in the [ADK graph documentation](https://adk.dev/graphs/)) and as a declarative ` + "`adk.declarative/v0.2`" + ` document (see [Declarative workflow agents](workflow-agents.md)). The code is not illustrative: it lives in [` + "`go/adk/examples/workflowpatterns`" + `](../../go/adk/examples/workflowpatterns), the tests run both versions on the same inputs and require the same events, and a test fails when this page is out of date. + +Run any pattern, in either form or both, from ` + "`go/`" + `: + +` + "```sh" + ` +go run ./adk/examples/workflowpatterns -list +go run ./adk/examples/workflowpatterns -pattern router -input "bug: crash" +go run ./adk/examples/workflowpatterns -pattern human_approval -input "ship it" -reply yes +go run ./adk/examples/workflowpatterns -pattern fan_in -input Go -mode yaml +go test ./adk/examples/workflowpatterns +` + "```" + ` + +The models are the offline stub, so nothing needs credentials. +` + +const docOutro = ` +## Session isolation + +Concurrent branches and subagents differ in how much of the session they can see. There are four levels, and each boundary has a default and a control. The first two rows were measured against ADK v2.6.0 by ` + "`isolation_test.go`" + ` in ` + "`pkg/declarative`" + `; a change upstream fails those tests. + +| Boundary | Sees the others' history by default | Control | +| --- | --- | --- | +| Static fan-out (several edges from one node) | no, own branch | none: always branch-isolated | +| ` + "`parallel_worker`" + ` items | no, own branch | none: always branch-isolated | +| ` + "`ParallelAgent`" + ` sub-agents | no, own branch | none: always branch-isolated | +| ` + "`SequentialAgent`" + ` / ` + "`LoopAgent`" + ` sub-agents | yes, by design | none that hides earlier agents; run them as ` + "`single_turn`" + ` agent nodes in a graph, or as ` + "`agent_tools`" + ` | +| Children of a ` + "`dynamic`" + ` node | yes | ` + "`isolation: shared | branch | scope`" + ` on the node | +| ` + "`sub_agents`" + ` of an ` + "`LlmAgent`" + ` (transfer) | yes, same session | none; call it as an ` + "`agent_tools`" + ` entry instead | +| ` + "`single_turn`" + ` agent node in a graph | no, sees only its predecessor's output | the mode is the control | +| ` + "`agent_tools`" + ` of an ` + "`LlmAgent`" + ` | no, a separate session | the agent is the control: use ` + "`agent_tools`" + ` instead of ` + "`sub_agents`" + ` | + +- **shared**: the child runs on its parent's branch and sees the whole conversation. +- **branch**: the child has its own branch. It sees its ancestors' history, not its siblings'. +- **scope**: the child also gets a scope unique to the run, so it sees only its own events. The user's message and the input you hand it still arrive; earlier agents' replies do not. +- **session** (` + "`agent_tools`" + `): the agent runs in a new session seeded with a copy of the caller's state. It never sees the caller's history, parallel calls get one session each, and its state writes are not copied back. + +The ADK graph pages do not describe branch or scope isolation, so this matrix is measured, not quoted. ` + "`include_contents: none`" + ` is not isolation. It keeps the current turn, and the replies of agents that ran earlier in that turn are part of it, so a sequential sub-agent still receives what the one before it said (and loses the user's message). + +Isolation covers history, not state. Branches share one session state, so two concurrent agents that write the same ` + "`output_key`" + ` race; validation rejects that for ` + "`ParallelAgent`" + `, for unconditional fan-out successors, and for ` + "`parallel_worker`" + ` agents. Remember that a tool agent starts from a copy of the caller's state, so do not keep secrets there that the tool agent should not see. + +## Reading the table + +- **One result.** A workflow ends in exactly one terminal output. The ADK graph pages do not state this, but the engine enforces it: branches that run concurrently must converge in a ` + "`join`" + `, or all but one must finish without output. +- **Joins need outputs.** A join releases only after all its predecessors finish, and a predecessor that finishes without an output leaves the join no value for that branch. The failure shows up downstream, away from the node that caused it, so give every branch an output and a retry policy where it can fail. +- **Cycles are not bounded.** A routed back-edge re-activates a node on each pass and nothing stops it, so the exit condition must eventually hold. When you need a hard bound, use a ` + "`dynamic`" + ` node (` + "`max_iters`" + `) or a ` + "`LoopAgent`" + ` (` + "`max_iterations`" + `). +- **Agent nodes.** The ADK documents graph agents as ` + "`single_turn`" + ` or ` + "`task`" + `. The engine also accepts ` + "`chat`" + ` for a node fed directly by START and for a dynamic child, and rejects a static ` + "`task`" + ` node; a task agent is for dynamic dispatch. The isolation patterns use ` + "`chat`" + ` on purpose, because a ` + "`single_turn`" + ` node already sees only its input. +- **Data between steps.** Pass data through node outputs, not session state. State is for values that tools, callbacks and instruction templates read, and it should stay small. Give a step an ` + "`input_schema`" + ` or ` + "`output_schema`" + ` when you want the engine to enforce the contract. +- **Routes are strings.** ` + "`BoolRoute(true)`" + ` and ` + "`IntRoute(1)`" + ` match the strings ` + "`\"true\"`" + ` and ` + "`\"1\"`" + `, which is how YAML writes them. ` + "`DEFAULT`" + ` is the ADK ` + "`Default`" + ` route. +- **Plain steps are silent.** A function node emits data for its successors. The terminal result is also rendered as message text, so A2A clients display the answer and not every intermediate step. +- **Names.** A sub-workflow node cannot share the root agent's name, and an agent node is named after its agent. +- **Where YAML stops.** YAML names behavior; it cannot contain it. Functions, tools, and models come from a ` + "`Registry`" + `, and a custom node type is Go code registered once. +` diff --git a/go/adk/examples/workflowpatterns/helpers.go b/go/adk/examples/workflowpatterns/helpers.go new file mode 100644 index 0000000000..96d36682dd --- /dev/null +++ b/go/adk/examples/workflowpatterns/helpers.go @@ -0,0 +1,246 @@ +package main + +import ( + "context" + "errors" + "fmt" + "iter" + "slices" + "strconv" + "strings" + "sync/atomic" + + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/agent/llmagent" + adkmodel "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/session" + "google.golang.org/adk/v2/tool" + "google.golang.org/adk/v2/tool/functiontool" + "google.golang.org/adk/v2/workflow" + "google.golang.org/genai" + + "github.com/kagent-dev/kagent/go/adk/pkg/declarative" +) + +// Text behavior shared by the Go and YAML versions of every pattern. + +func clean(s string) string { return strings.TrimSpace(s) } +func upper(s string) string { return strings.ToUpper(s) } +func lower(s string) string { return strings.ToLower(s) } +func exclaim(s string) string { return s + "!" } +func reverse(s string) string { r := []rune(s); slices.Reverse(r); return string(r) } +func truncate(s string) string { + if len(s) > 10 { + return s[:10] + "..." + } + return s +} +func critic(s string) string { return "critic(" + s + ")" } +func plan(s string) string { return "plan(" + s + ")" } +func execute(s string) string { return "exec(" + s + ")" } + +// tag builds a handler that prefixes its input. +func tag(prefix string) func(string) string { return func(s string) string { return prefix + s } } + +var ( + bugHandler = tag("bug handled: ") + billingHandler = tag("billing handled: ") + fallbackReply = tag("fallback: ") + auditor = tag("audited: ") + notifier = tag("notified: ") + publisher = tag("published after reply: ") + rejecter = tag("rejected after reply: ") + finalReport = tag("final: ") + draftText = tag("draft: ") +) + +// Routing decisions: each returns the routes a router node emits. + +func classify(s string) []string { + switch { + case strings.HasPrefix(s, "bug"): + return []string{"BUG"} + case strings.HasPrefix(s, "billing"): + return []string{"BILLING"} + } + return []string{"OTHER"} +} + +func isLong(s string) []string { return []string{strconv.FormatBool(len(s) > 10)} } + +// audience always audits and also notifies on urgent messages. +func audience(s string) []string { + if strings.HasPrefix(s, "urgent") { + return []string{"audit", "notify"} + } + return []string{"audit"} +} + +func longEnough(s string) []string { + if len(s) >= 5 { + return []string{"done"} + } + return []string{"again"} +} + +func verdict(s string) []string { + if s == "yes" { + return []string{"APPROVED"} + } + return []string{"REJECTED"} +} + +// Structured data between nodes. + +func words(s string) []any { + var out []any + for w := range strings.FieldsSeq(s) { + out = append(out, w) + } + return out +} + +func joinWords(items []any) string { + parts := make([]string, len(items)) + for i, item := range items { + parts[i] = fmt.Sprint(item) + } + return strings.Join(parts, " ") +} + +// combine renders the join map with sorted keys, so it is deterministic. +func combine(branches map[string]any) string { + keys := make([]string, 0, len(branches)) + for k := range branches { + keys = append(keys, k) + } + slices.Sort(keys) + parts := make([]string, len(keys)) + for i, k := range keys { + parts[i] = k + "=" + fmt.Sprint(branches[k]) + } + return strings.Join(parts, "|") +} + +func makePerson(s string) map[string]any { return map[string]any{"name": s} } + +func greetPerson(person map[string]any) string { return fmt.Sprintf("hello %v", person["name"]) } + +func customerArgs(s string) map[string]any { return map[string]any{"customer_id": s} } + +func describeCustomer(c map[string]any) string { + return fmt.Sprintf("customer %v is on plan %v", c["customer"], c["plan"]) +} + +type lookupArgs struct { + CustomerID string `json:"customer_id"` +} + +func newLookupTool() (tool.Tool, error) { + return functiontool.New(functiontool.Config{Name: "lookup_customer", Description: "Looks up a customer."}, + func(_ agent.Context, a lookupArgs) (map[string]any, error) { + return map[string]any{"customer": a.CustomerID, "plan": "pro"}, nil + }) +} + +var errTransient = errors.New("transient failure") + +// newFlaky returns a function that fails its first n calls, then succeeds. +func newFlaky(n int32) func(string) (string, error) { + var calls atomic.Int32 + return func(s string) (string, error) { + if calls.Add(1) <= n { + return "", errTransient + } + return "recovered: " + s, nil + } +} + +// Go-side node constructors. + +// step wraps a text function as a graph node. +func step(name string, f func(string) string) workflow.Node { + return workflow.NewFunctionNode(name, func(_ agent.Context, in string) (string, error) { + return f(in), nil + }, workflow.NodeConfig{}) +} + +// router wraps a routing decision as a node. It forwards its input unchanged +// and emits the routes that decide which outgoing edges run. +func router(name string, decide func(string) []string) workflow.Node { + return workflow.NewEmittingFunctionNode(name, func(ctx agent.Context, in string, emit func(*session.Event) error) (any, error) { + ev := session.NewEvent(ctx, ctx.InvocationID()) + ev.Routes = decide(in) + ev.Output = in + return nil, emit(ev) + }, workflow.NodeConfig{}) +} + +// worker returns a stub-model LlmAgent for the sequential, parallel and loop patterns. +func worker(name, instruction string) (agent.Agent, error) { + return llmagent.New(llmagent.Config{ + Name: name, Instruction: instruction, Model: declarative.NewStubModel("stub"), + }) +} + +// historyModel makes session isolation visible: it replies "saw=N", where N is +// how many text parts of conversation it was shown. An agent that shares the +// conversation sees more than one that is isolated from it. +type historyModel struct{} + +func (historyModel) Name() string { return "history" } + +func (historyModel) GenerateContent(_ context.Context, req *adkmodel.LLMRequest, _ bool) iter.Seq2[*adkmodel.LLMResponse, error] { + return func(yield func(*adkmodel.LLMResponse, error) bool) { + seen := 0 + for _, c := range req.Contents { + for _, part := range c.Parts { + if part != nil && part.Text != "" { + seen++ + } + } + } + yield(&adkmodel.LLMResponse{ + Content: genai.NewContentFromText(fmt.Sprintf("saw=%d", seen), genai.RoleModel), + TurnComplete: true, + }, nil) + } +} + +// callerModel is a parent model that calls one tool with the user's message, +// then answers with the tool's result. +type callerModel struct{ tool string } + +func (callerModel) Name() string { return "caller" } + +func (m callerModel) GenerateContent(_ context.Context, req *adkmodel.LLMRequest, _ bool) iter.Seq2[*adkmodel.LLMResponse, error] { + return func(yield func(*adkmodel.LLMResponse, error) bool) { + var request, result string + for _, c := range req.Contents { + for _, part := range c.Parts { + switch { + case part == nil: + case part.FunctionResponse != nil: + result = fmt.Sprint(part.FunctionResponse.Response["result"]) + case part.Text != "" && request == "": + request = part.Text + } + } + } + if result != "" { + yield(&adkmodel.LLMResponse{Content: genai.NewContentFromText("tool said: "+result, genai.RoleModel), TurnComplete: true}, nil) + return + } + yield(&adkmodel.LLMResponse{Content: &genai.Content{Role: genai.RoleModel, Parts: []*genai.Part{{ + FunctionCall: &genai.FunctionCall{ID: "call-1", Name: m.tool, Args: map[string]any{"request": request}}, + }}}, TurnComplete: true}, nil) + } +} + +// chatAgent returns a chat-mode agent on the history model, so its answer +// shows how much of the conversation it shares. +func chatAgent(name string) (agent.Agent, error) { + return llmagent.New(llmagent.Config{ + Name: name, Instruction: "Answer.", Model: historyModel{}, Mode: llmagent.ModeChat, + }) +} diff --git a/go/adk/examples/workflowpatterns/main.go b/go/adk/examples/workflowpatterns/main.go new file mode 100644 index 0000000000..96f31f29ee --- /dev/null +++ b/go/adk/examples/workflowpatterns/main.go @@ -0,0 +1,89 @@ +// Command workflowpatterns runs each workflow design pattern twice, once built +// with the ADK Go API and once from its declarative YAML document, and prints +// what each produced. See docs/architecture/workflow-patterns.md. +// +// go run ./adk/examples/workflowpatterns -list +// go run ./adk/examples/workflowpatterns -pattern router -input "bug: crash" +// go run ./adk/examples/workflowpatterns -pattern human_approval -input "ship it" -reply yes +package main + +import ( + "context" + "flag" + "fmt" + "os" + "slices" + "strings" + + "google.golang.org/adk/v2/agent" +) + +type repeated []string + +func (r *repeated) String() string { return strings.Join(*r, ",") } +func (r *repeated) Set(s string) error { *r = append(*r, s); return nil } + +func main() { + list := flag.Bool("list", false, "list the patterns") + name := flag.String("pattern", "", "pattern to run") + input := flag.String("input", "", "user message") + mode := flag.String("mode", "both", "go, yaml or both") + var replies repeated + flag.Var(&replies, "reply", "answer to a human-input request (repeatable)") + flag.Parse() + + if err := run(context.Background(), *list, *name, *input, *mode, replies); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(ctx context.Context, list bool, name, input, mode string, replies []string) error { + if list { + for _, p := range Catalog() { + fmt.Printf("%-18s %s\n", p.Name, p.Title) + } + return nil + } + p, ok := Find(name) + if !ok { + return fmt.Errorf("unknown pattern %q; use -list", name) + } + if !slices.Contains([]string{"go", "yaml", "both"}, mode) { + return fmt.Errorf("mode must be go, yaml or both, got %q", mode) + } + builders := []struct { + label string + build func() (agent.Agent, error) + }{ + {"go", p.Go}, + {"yaml", func() (agent.Agent, error) { return p.BuildYAML(ctx) }}, + } + traces := map[string][]string{} + for _, b := range builders { + if mode != "both" && mode != b.label { + continue + } + root, err := b.build() + if err != nil { + return fmt.Errorf("failed to build %s version: %w", b.label, err) + } + trace, err := Run(ctx, root, input, replies) + if err != nil { + return fmt.Errorf("failed to run %s version: %w", b.label, err) + } + traces[b.label] = p.Normalize(trace) + fmt.Printf("== %s ==\n", b.label) + for _, line := range traces[b.label] { + fmt.Println(line) + } + } + if mode == "both" { + if slices.Equal(traces["go"], traces["yaml"]) { + fmt.Println("== identical ==") + } else { + return fmt.Errorf("the Go and YAML versions diverged") + } + } + return nil +} diff --git a/go/adk/examples/workflowpatterns/patterns.go b/go/adk/examples/workflowpatterns/patterns.go new file mode 100644 index 0000000000..ce654e8500 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns.go @@ -0,0 +1,559 @@ +package main + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "time" + + "github.com/google/jsonschema-go/jsonschema" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/agent/llmagent" + "google.golang.org/adk/v2/agent/workflowagent" + "google.golang.org/adk/v2/agent/workflowagents/loopagent" + "google.golang.org/adk/v2/agent/workflowagents/parallelagent" + "google.golang.org/adk/v2/agent/workflowagents/sequentialagent" + "google.golang.org/adk/v2/session" + "google.golang.org/adk/v2/tool" + "google.golang.org/adk/v2/tool/agenttool" + "google.golang.org/adk/v2/tool/mcptoolset" + "google.golang.org/adk/v2/workflow" + + "github.com/kagent-dev/kagent/go/adk/pkg/declarative" +) + +// Each function below builds one pattern with the ADK Go API directly. The +// YAML twin lives in patterns/.yaml and builds the same graph through +// pkg/declarative; the tests require both to behave identically. + +func sequenceGo() (agent.Agent, error) { + return workflowagent.New(workflowagent.Config{ + Name: "sequence", + Edges: workflow.Chain(workflow.Start, step("clean", clean), step("upper", upper), step("exclaim", exclaim)), + }) +} + +func routerGo() (agent.Agent, error) { + classifier := router("classify", classify) + bug, billing, other := step("bug", bugHandler), step("billing", billingHandler), step("fallback", fallbackReply) + return workflowagent.New(workflowagent.Config{ + Name: "router", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, classifier), + []workflow.Edge{ + {From: classifier, To: bug, Route: workflow.StringRoute("BUG")}, + {From: classifier, To: billing, Route: workflow.StringRoute("BILLING")}, + {From: classifier, To: other, Route: workflow.Default}, + }, + ), + }) +} + +func conditionalGo() (agent.Agent, error) { + check := router("is_long", isLong) + short, keep := step("truncate", truncate), step("keep", clean) + return workflowagent.New(workflowagent.Config{ + Name: "conditional", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, check), + []workflow.Edge{ + {From: check, To: short, Route: workflow.BoolRoute(true)}, + {From: check, To: keep, Route: workflow.BoolRoute(false)}, + }, + ), + }) +} + +func fanInGo() (agent.Agent, error) { + shout, whisper, mirror := step("shout", upper), step("whisper", lower), step("mirror", reverse) + gather := workflow.NewJoinNode("gather") + merge := workflow.NewFunctionNode("combine", func(_ agent.Context, in map[string]any) (string, error) { + return combine(in), nil + }, workflow.NodeConfig{}) + // workflowagent.Config takes no engine options, so cap the graph with + // workflow.New and run it from a plain agent. + wf, err := workflow.New("fan_in", workflow.NewEdgeBuilder(). + AddFanOut(workflow.Start, shout, whisper, mirror). + AddFanIn(gather, shout, whisper, mirror). + Add(gather, merge). + Build(), workflow.WithMaxConcurrency(2)) + if err != nil { + return nil, err + } + return agent.New(agent.Config{Name: "fan_in", Run: wf.Run}) +} + +func mapReduceGo() (agent.Agent, error) { + split := workflow.NewFunctionNode("split", func(_ agent.Context, in string) ([]any, error) { + return words(in), nil + }, workflow.NodeConfig{}) + shoutWord := workflow.NewFunctionNode("shout_word", func(_ agent.Context, in string) (string, error) { + return upper(in), nil + }, workflow.NodeConfig{}) + shoutAll, err := workflow.NewParallelWorker("shout_all", shoutWord, 2, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + reduce := workflow.NewFunctionNode("reduce", func(_ agent.Context, in []any) (string, error) { + return joinWords(in), nil + }, workflow.NodeConfig{}) + return workflowagent.New(workflowagent.Config{ + Name: "map_reduce", + Edges: workflow.Chain(workflow.Start, split, shoutAll, reduce), + }) +} + +func retryLoopGo() (agent.Agent, error) { + grow, check, finish := step("grow", exclaim), router("check", longEnough), step("finish", finalReport) + return workflowagent.New(workflowagent.Config{ + Name: "retry_loop", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, grow, check), + []workflow.Edge{ + {From: check, To: grow, Route: workflow.StringRoute("again")}, + {From: check, To: finish, Route: workflow.StringRoute("done")}, + }, + ), + }) +} + +func humanApprovalGo() (agent.Agent, error) { + draft := step("draft", draftText) + rerun := true + approve := workflow.NewEmittingFunctionNode("approve", func(ctx agent.Context, _ any, emit func(*session.Event) error) (any, error) { + return workflow.ResumeOrRequestInput(ctx, emit, session.RequestInput{ + InterruptID: "approve-" + ctx.InvocationID(), + Message: "Publish this draft?", + }) + }, workflow.NodeConfig{RerunOnResume: &rerun}) + decide := router("verdict", verdict) + publish, reject := step("publish", publisher), step("reject", rejecter) + return workflowagent.New(workflowagent.Config{ + Name: "human_approval", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, draft, approve, decide), + []workflow.Edge{ + {From: decide, To: publish, Route: workflow.StringRoute("APPROVED")}, + {From: decide, To: reject, Route: workflow.StringRoute("REJECTED")}, + }, + ), + }) +} + +func nestedGo() (agent.Agent, error) { + inner, err := workflow.NewWorkflowNode("subflow", workflow.Chain(workflow.Start, step("plan", plan), step("execute", execute))) + if err != nil { + return nil, err + } + return workflowagent.New(workflowagent.Config{ + Name: "nested", + Edges: workflow.Chain(workflow.Start, step("prep", clean), inner, step("finish", exclaim)), + }) +} + +func dynamicGo() (agent.Agent, error) { + review := step("critic", critic) + refine := workflow.NewDynamicNode("refine", func(ctx agent.Context, in any, _ func(*session.Event) error) (any, error) { + out := in + for range 3 { + next, err := workflow.RunNode[any](ctx, review, out) + if err != nil { + return nil, err + } + out = next + } + return out, nil + }, workflow.NodeConfig{}) + return workflowagent.New(workflowagent.Config{ + Name: "dynamic", + Edges: workflow.Chain(workflow.Start, step("prep", clean), refine, step("finish", exclaim)), + }) +} + +func llmStageGo() (agent.Agent, error) { + answer, err := llmagent.New(llmagent.Config{ + Name: "answer", + Instruction: "Answer briefly.", + Model: declarative.NewStubModel("stub"), + Mode: llmagent.ModeSingleTurn, + }) + if err != nil { + return nil, err + } + answerNode, err := workflow.NewAgentNode(answer, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + return workflowagent.New(workflowagent.Config{ + Name: "llm_stage", + Edges: workflow.Chain(workflow.Start, step("prep", clean), answerNode, step("finish", exclaim)), + }) +} + +func toolStageGo() (agent.Agent, error) { + lookup, err := newLookupTool() + if err != nil { + return nil, err + } + lookupNode, err := workflow.NewNamedToolNode("lookup", lookup, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + args := workflow.NewFunctionNode("args", func(_ agent.Context, in string) (map[string]any, error) { + return customerArgs(in), nil + }, workflow.NodeConfig{}) + describe := workflow.NewFunctionNode("describe", func(_ agent.Context, in map[string]any) (string, error) { + return describeCustomer(in), nil + }, workflow.NodeConfig{}) + return workflowagent.New(workflowagent.Config{ + Name: "tool_stage", + Edges: workflow.Chain(workflow.Start, args, lookupNode, describe), + }) +} + +// personSchema is the contract between the typed_contract steps. +var personSchema = &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"name": {Type: "string"}}, + Required: []string{"name"}, +} + +func typedContractGo() (agent.Agent, error) { + parse, err := workflow.NewFunctionNodeWithSchema( + "parse", func(_ agent.Context, in string) (map[string]any, error) { return makePerson(in), nil }, + nil, personSchema, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + greet, err := workflow.NewFunctionNodeWithSchema( + "greet", func(_ agent.Context, in map[string]any) (string, error) { return greetPerson(in), nil }, + personSchema, nil, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + return workflowagent.New(workflowagent.Config{ + Name: "typed_contract", + Edges: workflow.Chain(workflow.Start, parse, greet), + }) +} + +func resilientGo() (agent.Agent, error) { + retry := workflow.DefaultRetryConfig() + retry.MaxAttempts = 3 + retry.InitialDelay, retry.MaxDelay = time.Millisecond, 2*time.Millisecond + retry.BackoffFactor, retry.Jitter = 1, 0 + retry.ShouldRetry = func(err error) bool { + return errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "transient") + } + flaky := newFlaky(2) + unreliable := workflow.NewFunctionNode("unreliable", func(_ agent.Context, in string) (string, error) { + return flaky(in) + }, workflow.NodeConfig{RetryConfig: retry, Timeout: 5 * time.Second}) + return workflowagent.New(workflowagent.Config{ + Name: "resilient", + Edges: workflow.Chain(workflow.Start, unreliable), + }) +} + +func sequentialAgentGo() (agent.Agent, error) { + draft, err := worker("draft", "Write a draft.") + if err != nil { + return nil, err + } + review, err := worker("review", "Review the draft.") + if err != nil { + return nil, err + } + return sequentialagent.New(sequentialagent.Config{AgentConfig: agent.Config{ + Name: "sequential_agent", SubAgents: []agent.Agent{draft, review}, + }}) +} + +func parallelAgentGo() (agent.Agent, error) { + optimist, err := worker("optimist", "Argue for it.") + if err != nil { + return nil, err + } + pessimist, err := worker("pessimist", "Argue against it.") + if err != nil { + return nil, err + } + return parallelagent.New(parallelagent.Config{AgentConfig: agent.Config{ + Name: "parallel_agent", SubAgents: []agent.Agent{optimist, pessimist}, + }}) +} + +func loopAgentGo() (agent.Agent, error) { + polish, err := worker("polish", "Improve the text.") + if err != nil { + return nil, err + } + return loopagent.New(loopagent.Config{ + AgentConfig: agent.Config{Name: "loop_agent", SubAgents: []agent.Agent{polish}}, + MaxIterations: 2, + }) +} + +// Anti-patterns: graphs the engine or the declarative validation rejects. + +func unconditionalCycleGo() (agent.Agent, error) { + first, second := step("first", clean), step("second", upper) + return workflowagent.New(workflowagent.Config{ + Name: "unconditional_cycle", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, first, second), + []workflow.Edge{{From: second, To: first}}, + ), + }) +} + +func convergingRoutesGo() (agent.Agent, error) { + classifier := router("classify", classify) + bug, billing, reply := step("bug", bugHandler), step("billing", billingHandler), step("reply", finalReport) + return workflowagent.New(workflowagent.Config{ + Name: "converging_routes", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, classifier), + []workflow.Edge{ + {From: classifier, To: bug, Route: workflow.StringRoute("BUG")}, + {From: classifier, To: billing, Route: workflow.StringRoute("BILLING")}, + {From: bug, To: reply}, + {From: billing, To: reply}, + }, + ), + }) +} + +func fanOutWithoutGatherGo() (agent.Agent, error) { + shout, whisper, mirror := step("shout", upper), step("whisper", lower), step("mirror", reverse) + return workflowagent.New(workflowagent.Config{ + Name: "fan_out_without_gather", + Edges: workflow.NewEdgeBuilder().AddFanOut(workflow.Start, shout, whisper, mirror).Build(), + }) +} + +func multicastWithoutGatherGo() (agent.Agent, error) { + pickAudience := router("audience", audience) + audit, notify := step("audit", auditor), step("notify", notifier) + return workflowagent.New(workflowagent.Config{ + Name: "multicast_without_gather", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, pickAudience), + []workflow.Edge{ + {From: pickAudience, To: audit, Route: workflow.StringRoute("audit")}, + {From: pickAudience, To: notify, Route: workflow.StringRoute("notify")}, + }, + ), + }) +} + +func taskModeNodeGo() (agent.Agent, error) { + task, err := llmagent.New(llmagent.Config{ + Name: "worker", Instruction: "Do the task.", Model: declarative.NewStubModel("stub"), Mode: llmagent.ModeTask, + }) + if err != nil { + return nil, err + } + node, err := workflow.NewAgentNode(task, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + return workflowagent.New(workflowagent.Config{ + Name: "task_mode_node", + Edges: workflow.Chain(workflow.Start, node), + }) +} + +// Session isolation patterns. The history model answers "saw=N", the number of +// conversation text parts it was shown, so the trace shows what each agent +// could see. + +func fanOutAgentsGo() (agent.Agent, error) { + left, err := chatAgent("left") + if err != nil { + return nil, err + } + right, err := chatAgent("right") + if err != nil { + return nil, err + } + leftNode, err := workflow.NewAgentNode(left, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + rightNode, err := workflow.NewAgentNode(right, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + gather := workflow.NewJoinNode("gather") + merge := workflow.NewFunctionNode("combine", func(_ agent.Context, in map[string]any) (string, error) { + return combine(in), nil + }, workflow.NodeConfig{}) + return workflowagent.New(workflowagent.Config{ + Name: "fan_out_agents", + Edges: workflow.NewEdgeBuilder(). + AddFanOut(workflow.Start, leftNode, rightNode). + AddFanIn(gather, leftNode, rightNode). + Add(gather, merge). + Build(), + }) +} + +// reviewersGo builds a dynamic node that runs two chat agents in turn. opts +// decide how much of the session the second one is shown. +func reviewersGo(name string, opts ...workflow.RunNodeOption) (agent.Agent, error) { + first, err := chatAgent("first") + if err != nil { + return nil, err + } + second, err := chatAgent("second") + if err != nil { + return nil, err + } + firstNode, err := workflow.NewAgentNode(first, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + secondNode, err := workflow.NewAgentNode(second, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + review := workflow.NewDynamicNode(name, func(ctx agent.Context, in any, _ func(*session.Event) error) (any, error) { + a, err := workflow.RunNode[any](ctx, firstNode, in, opts...) + if err != nil { + return nil, err + } + b, err := workflow.RunNode[any](ctx, secondNode, in, opts...) + if err != nil { + return nil, err + } + return fmt.Sprintf("first %v, second %v", a, b), nil + }, workflow.NodeConfig{}) + return workflowagent.New(workflowagent.Config{ + Name: name, + Edges: workflow.Chain(workflow.Start, review), + }) +} + +func sharedChildrenGo() (agent.Agent, error) { return reviewersGo("shared_children") } + +func isolatedChildrenGo() (agent.Agent, error) { + return reviewersGo("isolated_children", workflow.WithUseSubBranch()) +} + +func agentAsToolGo() (agent.Agent, error) { + specialist, err := llmagent.New(llmagent.Config{ + Name: "specialist", Description: "Answers one question.", Instruction: "Answer.", Model: historyModel{}, + }) + if err != nil { + return nil, err + } + return llmagent.New(llmagent.Config{ + Name: "agent_as_tool", + Instruction: "Delegate to the specialist.", + Model: callerModel{tool: "specialist"}, + Tools: []tool.Tool{agenttool.New(specialist, nil)}, + }) +} + +func schemaMismatchGo() (agent.Agent, error) { + produce, err := workflow.NewFunctionNodeWithSchema( + "produce", func(_ agent.Context, in string) (string, error) { return in, nil }, + nil, &jsonschema.Schema{Type: "string"}, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + consume, err := workflow.NewFunctionNodeWithSchema( + "consume", func(_ agent.Context, in int) (string, error) { return fmt.Sprint(in), nil }, + &jsonschema.Schema{Type: "integer"}, nil, workflow.NodeConfig{}) + if err != nil { + return nil, err + } + return workflowagent.New(workflowagent.Config{ + Name: "schema_mismatch", + Edges: workflow.Chain(workflow.Start, produce, consume), + }) +} + +var ( + bugWords = regexp.MustCompile(`\b(crash|error|bug)\b`) + billingWords = regexp.MustCompile(`\b(invoice|refund)\b`) + customerIDs = regexp.MustCompile(`\bc-[0-9]+\b`) +) + +func celRouterGo() (agent.Agent, error) { + classify := workflow.NewEmittingFunctionNode("classify", func(ctx agent.Context, in string, emit func(*session.Event) error) (any, error) { + ev := session.NewEvent(ctx, ctx.InvocationID()) + switch t := strings.ToLower(in); { + case bugWords.MatchString(t): + ev.Routes = []string{"BUG"} + case billingWords.MatchString(t): + ev.Routes = []string{"BILLING"} + default: + ev.Routes = []string{"OTHER"} + } + ev.Actions.StateDelta = map[string]any{"ids": customerIDs.FindAllString(in, -1)} + ev.Output = in + return nil, emit(ev) + }, workflow.NodeConfig{}) + bug := step("bug", func(s string) string { return "bug: " + strings.TrimSpace(s) }) + other := step("other", func(s string) string { return "other: " + strings.TrimSpace(s) }) + billing := workflow.NewFunctionNode("billing", func(ctx agent.Context, _ string) (string, error) { + ids, _ := ctx.State().Get("ids") + list, _ := ids.([]string) + if len(list) == 0 { + return "billing for unknown customer", nil + } + return "billing for " + strings.Join(list, ","), nil + }, workflow.NodeConfig{}) + return workflowagent.New(workflowagent.Config{ + Name: "cel_router", + Edges: workflow.Concat( + workflow.Chain(workflow.Start, classify), + []workflow.Edge{ + {From: classify, To: bug, Route: workflow.StringRoute("BUG")}, + {From: classify, To: billing, Route: workflow.StringRoute("BILLING")}, + {From: classify, To: other, Route: workflow.Default}, + }, + ), + }) +} + +func mcpToolGo() (agent.Agent, error) { + url, err := crmURL() + if err != nil { + return nil, err + } + toolset, err := mcptoolset.New(mcptoolset.Config{Transport: &mcpsdk.StreamableClientTransport{Endpoint: url}}) + if err != nil { + return nil, err + } + crm := tool.FilterToolset(toolset, tool.StringPredicate([]string{"lookup_customer"})) + args := workflow.NewFunctionNode("args", func(_ agent.Context, in string) (map[string]any, error) { + return map[string]any{"customer_id": strings.TrimSpace(in)}, nil + }, workflow.NodeConfig{}) + lookup := workflow.NewFunctionNode("lookup", func(ctx agent.Context, in map[string]any) (map[string]any, error) { + tools, err := crm.Tools(ctx) + if err != nil || len(tools) != 1 { + return nil, fmt.Errorf("lookup_customer is not available: %v", err) + } + runnable, ok := tools[0].(interface { + Run(agent.Context, any) (map[string]any, error) + }) + if !ok { + return nil, errors.New("lookup_customer cannot run outside a model turn") + } + return runnable.Run(ctx, in) + }, workflow.NodeConfig{}) + describe := workflow.NewFunctionNode("describe", func(_ agent.Context, in map[string]any) (string, error) { + c, _ := in["output"].(map[string]any) + return fmt.Sprintf("customer %v is on plan %v", c["id"], c["plan"]), nil + }, workflow.NodeConfig{}) + return workflowagent.New(workflowagent.Config{ + Name: "mcp_tool", + Edges: workflow.Chain(workflow.Start, args, lookup, describe), + }) +} diff --git a/go/adk/examples/workflowpatterns/patterns/agent_as_tool.yaml b/go/adk/examples/workflowpatterns/patterns/agent_as_tool.yaml new file mode 100644 index 0000000000..4ceb0ad529 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/agent_as_tool.yaml @@ -0,0 +1,9 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: agent_as_tool + agent_class: LlmAgent + model: caller + instruction: Delegate to the specialist. + agent_tools: [specialist] + - { name: specialist, agent_class: LlmAgent, model: history, description: Answers one question., instruction: Answer. } diff --git a/go/adk/examples/workflowpatterns/patterns/cel_router.yaml b/go/adk/examples/workflowpatterns/patterns/cel_router.yaml new file mode 100644 index 0000000000..9baea352b8 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/cel_router.yaml @@ -0,0 +1,35 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: cel_router + agent_class: Workflow + nodes: + # A CEL node with only route and state_delta forwards its input. + - id: classify + type: cel + # Ordered rules: the first match wins, no match takes DEFAULT. + vars: + - name: text + expr: input.lowerAscii() + routes: + - route: BUG + when: vars.text.matches(r'\b(crash|error|bug)\b') + - route: BILLING + when: vars.text.matches(r'\b(invoice|refund)\b') + state_delta: + ids: regex.extractAll(input, r'\bc-[0-9]+\b') + - id: bug + type: cel + output: "'bug: ' + input.trim()" + - id: billing + type: cel + output: | + 'billing for ' + (size(state.ids) > 0 ? state.ids.map(i, string(i)).join(',') : 'unknown customer') + - id: other + type: cel + output: "'other: ' + input.trim()" + edges: + - { from: START, to: classify } + - { from: classify, to: bug, route: BUG } + - { from: classify, to: billing, route: BILLING } + - { from: classify, to: other, route: DEFAULT } diff --git a/go/adk/examples/workflowpatterns/patterns/conditional.yaml b/go/adk/examples/workflowpatterns/patterns/conditional.yaml new file mode 100644 index 0000000000..28b39567e7 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/conditional.yaml @@ -0,0 +1,13 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: conditional + agent_class: Workflow + nodes: + - { id: is_long, type: function, function: is_long } + - { id: truncate, type: function, function: truncate } + - { id: keep, type: function, function: clean } + edges: + - { from: START, to: is_long } + - { from: is_long, to: truncate, route: "true" } + - { from: is_long, to: keep, route: "false" } diff --git a/go/adk/examples/workflowpatterns/patterns/dynamic.yaml b/go/adk/examples/workflowpatterns/patterns/dynamic.yaml new file mode 100644 index 0000000000..c0e662e0d4 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/dynamic.yaml @@ -0,0 +1,14 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: dynamic + agent_class: Workflow + nodes: + - { id: prep, type: function, function: clean } + - { id: refine, type: dynamic, function: refine, max_iters: 3, children: [critic] } + - { id: critic, type: function, function: critic } + - { id: finish, type: function, function: exclaim } + edges: + - { from: START, to: prep } + - { from: prep, to: refine } + - { from: refine, to: finish } diff --git a/go/adk/examples/workflowpatterns/patterns/fan_in.yaml b/go/adk/examples/workflowpatterns/patterns/fan_in.yaml new file mode 100644 index 0000000000..57bf63b607 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/fan_in.yaml @@ -0,0 +1,20 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: fan_in + agent_class: Workflow + max_concurrency: 2 + nodes: + - { id: shout, type: function, function: upper } + - { id: whisper, type: function, function: lower } + - { id: mirror, type: function, function: reverse } + - { id: gather, type: join } + - { id: combine, type: function, function: combine } + edges: + - { from: START, to: shout } + - { from: START, to: whisper } + - { from: START, to: mirror } + - { from: shout, to: gather } + - { from: whisper, to: gather } + - { from: mirror, to: gather } + - { from: gather, to: combine } diff --git a/go/adk/examples/workflowpatterns/patterns/fan_out_agents.yaml b/go/adk/examples/workflowpatterns/patterns/fan_out_agents.yaml new file mode 100644 index 0000000000..56cc1bc371 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/fan_out_agents.yaml @@ -0,0 +1,18 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: fan_out_agents + agent_class: Workflow + nodes: + - { id: ask_left, type: agent, agent: left } + - { id: ask_right, type: agent, agent: right } + - { id: gather, type: join } + - { id: combine, type: function, function: combine } + edges: + - { from: START, to: ask_left } + - { from: START, to: ask_right } + - { from: ask_left, to: gather } + - { from: ask_right, to: gather } + - { from: gather, to: combine } + - { name: left, agent_class: LlmAgent, model: history, mode: chat, instruction: Answer. } + - { name: right, agent_class: LlmAgent, model: history, mode: chat, instruction: Answer. } diff --git a/go/adk/examples/workflowpatterns/patterns/human_approval.yaml b/go/adk/examples/workflowpatterns/patterns/human_approval.yaml new file mode 100644 index 0000000000..af9739f1c3 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/human_approval.yaml @@ -0,0 +1,17 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: human_approval + agent_class: Workflow + nodes: + - { id: draft, type: function, function: draft_text } + - { id: approve, type: hitl, message: "Publish this draft?" } + - { id: verdict, type: function, function: verdict } + - { id: publish, type: function, function: publisher } + - { id: reject, type: function, function: rejecter } + edges: + - { from: START, to: draft } + - { from: draft, to: approve } + - { from: approve, to: verdict } + - { from: verdict, to: publish, route: APPROVED } + - { from: verdict, to: reject, route: REJECTED } diff --git a/go/adk/examples/workflowpatterns/patterns/isolated_children.yaml b/go/adk/examples/workflowpatterns/patterns/isolated_children.yaml new file mode 100644 index 0000000000..e7770ccaf4 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/isolated_children.yaml @@ -0,0 +1,13 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: isolated_children + agent_class: Workflow + nodes: + - { id: review, type: dynamic, function: review_both, children: [ask_first, ask_second], isolation: branch } + - { id: ask_first, type: agent, agent: first } + - { id: ask_second, type: agent, agent: second } + edges: + - { from: START, to: review } + - { name: first, agent_class: LlmAgent, model: history, mode: chat, instruction: Answer. } + - { name: second, agent_class: LlmAgent, model: history, mode: chat, instruction: Answer. } diff --git a/go/adk/examples/workflowpatterns/patterns/llm_stage.yaml b/go/adk/examples/workflowpatterns/patterns/llm_stage.yaml new file mode 100644 index 0000000000..90ed935857 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/llm_stage.yaml @@ -0,0 +1,13 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: llm_stage + agent_class: Workflow + nodes: + - { id: prep, type: function, function: clean } + - { id: answer, type: llm, model: stub, instruction: Answer briefly. } + - { id: finish, type: function, function: exclaim } + edges: + - { from: START, to: prep } + - { from: prep, to: answer } + - { from: answer, to: finish } diff --git a/go/adk/examples/workflowpatterns/patterns/loop_agent.yaml b/go/adk/examples/workflowpatterns/patterns/loop_agent.yaml new file mode 100644 index 0000000000..94a4e1ae19 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/loop_agent.yaml @@ -0,0 +1,8 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: loop_agent + agent_class: LoopAgent + max_iterations: 2 + sub_agents: [polish] + - { name: polish, agent_class: LlmAgent, model: stub, instruction: Improve the text. } diff --git a/go/adk/examples/workflowpatterns/patterns/map_reduce.yaml b/go/adk/examples/workflowpatterns/patterns/map_reduce.yaml new file mode 100644 index 0000000000..6f42440dc9 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/map_reduce.yaml @@ -0,0 +1,13 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: map_reduce + agent_class: Workflow + nodes: + - { id: split, type: function, function: split } + - { id: shout_all, type: parallel_worker, function: upper, max_concurrency: 2 } + - { id: reduce, type: function, function: join_words } + edges: + - { from: START, to: split } + - { from: split, to: shout_all } + - { from: shout_all, to: reduce } diff --git a/go/adk/examples/workflowpatterns/patterns/mcp_tool.yaml b/go/adk/examples/workflowpatterns/patterns/mcp_tool.yaml new file mode 100644 index 0000000000..35095d34f3 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/mcp_tool.yaml @@ -0,0 +1,25 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +mcp_servers: + - name: crm + url_from: { env: PATTERNS_CRM_URL } + tools: [lookup_customer] +agents: + - name: mcp_tool + agent_class: Workflow + nodes: + - id: args + type: cel + output: '{"customer_id": input.trim()}' + # A remote MCP tool as a function node: the input is its arguments. + - id: lookup + type: tool + server: crm + tool: lookup_customer + - id: describe + type: cel + output: "'customer %s is on plan %s'.format([input.output.id, input.output.plan])" + edges: + - { from: START, to: args } + - { from: args, to: lookup } + - { from: lookup, to: describe } diff --git a/go/adk/examples/workflowpatterns/patterns/nested.yaml b/go/adk/examples/workflowpatterns/patterns/nested.yaml new file mode 100644 index 0000000000..b5738c1f0c --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/nested.yaml @@ -0,0 +1,20 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: nested + agent_class: Workflow + nodes: + - { id: prep, type: function, function: clean } + - id: subflow + type: workflow + nodes: + - { id: plan, type: function, function: plan } + - { id: execute, type: function, function: execute } + edges: + - { from: START, to: plan } + - { from: plan, to: execute } + - { id: finish, type: function, function: exclaim } + edges: + - { from: START, to: prep } + - { from: prep, to: subflow } + - { from: subflow, to: finish } diff --git a/go/adk/examples/workflowpatterns/patterns/parallel_agent.yaml b/go/adk/examples/workflowpatterns/patterns/parallel_agent.yaml new file mode 100644 index 0000000000..6d984d2533 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/parallel_agent.yaml @@ -0,0 +1,8 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: parallel_agent + agent_class: ParallelAgent + sub_agents: [optimist, pessimist] + - { name: optimist, agent_class: LlmAgent, model: stub, instruction: Argue for it. } + - { name: pessimist, agent_class: LlmAgent, model: stub, instruction: Argue against it. } diff --git a/go/adk/examples/workflowpatterns/patterns/resilient.yaml b/go/adk/examples/workflowpatterns/patterns/resilient.yaml new file mode 100644 index 0000000000..523134c062 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/resilient.yaml @@ -0,0 +1,20 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: resilient + agent_class: Workflow + nodes: + - id: unreliable + type: function + function: flaky + timeout: 5s + retry: + max_attempts: 3 + initial_delay: 1ms + max_delay: 2ms + backoff_factor: 1 + jitter: 0 + # Retry only transient failures and timeouts; other errors fail at once. + when: error.timeout || error.message.contains("transient") + edges: + - { from: START, to: unreliable } diff --git a/go/adk/examples/workflowpatterns/patterns/retry_loop.yaml b/go/adk/examples/workflowpatterns/patterns/retry_loop.yaml new file mode 100644 index 0000000000..5f04e4e242 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/retry_loop.yaml @@ -0,0 +1,14 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: retry_loop + agent_class: Workflow + nodes: + - { id: grow, type: function, function: exclaim } + - { id: check, type: function, function: long_enough } + - { id: finish, type: function, function: final_report } + edges: + - { from: START, to: grow } + - { from: grow, to: check } + - { from: check, to: grow, route: again } + - { from: check, to: finish, route: done } diff --git a/go/adk/examples/workflowpatterns/patterns/router.yaml b/go/adk/examples/workflowpatterns/patterns/router.yaml new file mode 100644 index 0000000000..49cecaf845 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/router.yaml @@ -0,0 +1,15 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: router + agent_class: Workflow + nodes: + - { id: classify, type: function, function: classify } + - { id: bug, type: function, function: bug_handler } + - { id: billing, type: function, function: billing_handler } + - { id: fallback, type: function, function: fallback_reply } + edges: + - { from: START, to: classify } + - { from: classify, to: bug, route: BUG } + - { from: classify, to: billing, route: BILLING } + - { from: classify, to: fallback, route: DEFAULT } diff --git a/go/adk/examples/workflowpatterns/patterns/sequence.yaml b/go/adk/examples/workflowpatterns/patterns/sequence.yaml new file mode 100644 index 0000000000..a195b94b56 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/sequence.yaml @@ -0,0 +1,13 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: sequence + agent_class: Workflow + nodes: + - { id: clean, type: function, function: clean } + - { id: upper, type: function, function: upper } + - { id: exclaim, type: function, function: exclaim } + edges: + - { from: START, to: clean } + - { from: clean, to: upper } + - { from: upper, to: exclaim } diff --git a/go/adk/examples/workflowpatterns/patterns/sequential_agent.yaml b/go/adk/examples/workflowpatterns/patterns/sequential_agent.yaml new file mode 100644 index 0000000000..9e5801b22b --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/sequential_agent.yaml @@ -0,0 +1,8 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: sequential_agent + agent_class: SequentialAgent + sub_agents: [draft, review] + - { name: draft, agent_class: LlmAgent, model: stub, instruction: Write a draft. } + - { name: review, agent_class: LlmAgent, model: stub, instruction: Review the draft. } diff --git a/go/adk/examples/workflowpatterns/patterns/shared_children.yaml b/go/adk/examples/workflowpatterns/patterns/shared_children.yaml new file mode 100644 index 0000000000..b275f61f2b --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/shared_children.yaml @@ -0,0 +1,13 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: shared_children + agent_class: Workflow + nodes: + - { id: review, type: dynamic, function: review_both, children: [ask_first, ask_second] } + - { id: ask_first, type: agent, agent: first } + - { id: ask_second, type: agent, agent: second } + edges: + - { from: START, to: review } + - { name: first, agent_class: LlmAgent, model: history, mode: chat, instruction: Answer. } + - { name: second, agent_class: LlmAgent, model: history, mode: chat, instruction: Answer. } diff --git a/go/adk/examples/workflowpatterns/patterns/tool_stage.yaml b/go/adk/examples/workflowpatterns/patterns/tool_stage.yaml new file mode 100644 index 0000000000..9d5949418e --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/tool_stage.yaml @@ -0,0 +1,13 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: tool_stage + agent_class: Workflow + nodes: + - { id: args, type: function, function: customer_args } + - { id: lookup, type: tool, tool: lookup_customer } + - { id: describe, type: function, function: describe_customer } + edges: + - { from: START, to: args } + - { from: args, to: lookup } + - { from: lookup, to: describe } diff --git a/go/adk/examples/workflowpatterns/patterns/typed_contract.yaml b/go/adk/examples/workflowpatterns/patterns/typed_contract.yaml new file mode 100644 index 0000000000..130525bda8 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/typed_contract.yaml @@ -0,0 +1,17 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: typed_contract + agent_class: Workflow + nodes: + - id: parse + type: function + function: make_person + output_schema: { type: object, properties: { name: { type: string } }, required: [name] } + - id: greet + type: function + function: greet_person + input_schema: { type: object, properties: { name: { type: string } }, required: [name] } + edges: + - { from: START, to: parse } + - { from: parse, to: greet } diff --git a/go/adk/examples/workflowpatterns/patterns_test.go b/go/adk/examples/workflowpatterns/patterns_test.go new file mode 100644 index 0000000000..af29a91843 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestPatternsGoAndYAMLAgree runs every case against the ADK Go version and +// the declarative YAML version and requires the same trace from both, plus +// the documented result. +func TestPatternsGoAndYAMLAgree(t *testing.T) { + for _, p := range Catalog() { + t.Run(p.Name, func(t *testing.T) { + require.NotEmpty(t, p.Cases, "a pattern without a case proves nothing") + for _, c := range p.Cases { + t.Run(c.Input+fmtReplies(c.Replies), func(t *testing.T) { + goAgent, err := p.Go() + require.NoError(t, err) + goTrace, err := Run(t.Context(), goAgent, c.Input, c.Replies) + require.NoError(t, err) + + yamlAgent, err := p.BuildYAML(t.Context()) + require.NoError(t, err) + yamlTrace, err := Run(t.Context(), yamlAgent, c.Input, c.Replies) + require.NoError(t, err) + + require.Equal(t, p.Normalize(goTrace), p.Normalize(yamlTrace), "Go and YAML versions diverged") + for _, want := range c.Want { + require.Contains(t, goTrace, want) + } + for _, absent := range c.Absent { + require.NotContains(t, goTrace, absent) + } + }) + } + }) + } +} + +func fmtReplies(replies []string) string { + if len(replies) == 0 { + return "" + } + return " reply=" + replies[0] +} + +func TestCatalogIsConsistent(t *testing.T) { + seen := map[string]bool{} + for _, p := range Catalog() { + require.False(t, seen[p.Name], "duplicate pattern %s", p.Name) + seen[p.Name] = true + _, err := p.YAML() + require.NoError(t, err, "pattern %s has no patterns/%s.yaml", p.Name, p.Name) + require.NotEmpty(t, p.GoFuncName()) + require.NotEmpty(t, p.Title) + require.NotEmpty(t, p.Summary) + } + entries, err := patternFiles.ReadDir("patterns") + require.NoError(t, err) + require.Len(t, entries, len(seen), "every YAML file belongs to a catalog entry") + + antiSeen := map[string]bool{} + for _, a := range AntiPatterns() { + require.False(t, antiSeen[a.Name], "duplicate anti-pattern %s", a.Name) + antiSeen[a.Name] = true + _, err := a.YAML() + require.NoError(t, err, "anti-pattern %s has no anti/%s.yaml", a.Name, a.Name) + require.NotEmpty(t, a.WantErr) + } + entries, err = patternFiles.ReadDir("anti") + require.NoError(t, err) + require.Len(t, entries, len(antiSeen), "every anti-pattern YAML file belongs to a catalog entry") +} + +// TestAntiPatternsFailTheSameWay checks that the engine rejects each bad graph +// whether it is built in Go or declared in YAML, and that the message names +// the problem. +func TestAntiPatternsFailTheSameWay(t *testing.T) { + for _, a := range AntiPatterns() { + t.Run(a.Name, func(t *testing.T) { + if !a.AtRun { + if a.Go != nil { + _, err := a.Go() + require.ErrorContains(t, err, a.WantErr, "ADK Go version") + } + _, err := a.BuildYAML(t.Context()) + require.ErrorContains(t, err, a.WantErr, "YAML version") + return + } + + // Accepted when built, rejected when run. + goAgent, err := a.Go() + require.NoError(t, err) + _, err = Run(t.Context(), goAgent, a.Input, nil) + require.ErrorContains(t, err, a.WantErr, "ADK Go version") + + yamlAgent, err := a.BuildYAML(t.Context()) + require.NoError(t, err) + _, err = Run(t.Context(), yamlAgent, a.Input, nil) + require.ErrorContains(t, err, a.WantErr, "YAML version") + }) + } +} diff --git a/go/adk/examples/workflowpatterns/registry.go b/go/adk/examples/workflowpatterns/registry.go new file mode 100644 index 0000000000..b7d309f4a2 --- /dev/null +++ b/go/adk/examples/workflowpatterns/registry.go @@ -0,0 +1,132 @@ +package main + +import ( + "errors" + "fmt" + + "google.golang.org/adk/v2/agent" + + "github.com/kagent-dev/kagent/go/adk/pkg/declarative" +) + +// textFunc registers a text function for function nodes. +func textFunc(f func(string) string) declarative.Func { + return func(_ agent.Context, in any) (declarative.Result, error) { + return declarative.Output(f(fmt.Sprint(in))), nil + } +} + +// routeFunc registers a routing decision: the input is forwarded unchanged +// and the chosen routes select the outgoing edges. +func routeFunc(f func(string) []string) declarative.Func { + return func(_ agent.Context, in any) (declarative.Result, error) { + return declarative.Route(in, f(fmt.Sprint(in))...), nil + } +} + +// newRegistry returns a fresh registry holding every behavior the YAML +// patterns name. Each pattern run gets its own, so stateful functions such as +// the flaky step start from zero. +func newRegistry() (*declarative.Registry, error) { + reg := declarative.NewRegistry() + if _, err := crmURL(); err != nil { + return nil, err + } + if err := reg.RegisterModel("history", historyModel{}); err != nil { + return nil, err + } + if err := reg.RegisterModel("caller", callerModel{tool: "specialist"}); err != nil { + return nil, err + } + + functions := map[string]declarative.Func{ + "clean": textFunc(clean), "upper": textFunc(upper), "lower": textFunc(lower), + "exclaim": textFunc(exclaim), "reverse": textFunc(reverse), "truncate": textFunc(truncate), + "critic": textFunc(critic), "plan": textFunc(plan), "execute": textFunc(execute), + "bug_handler": textFunc(bugHandler), "billing_handler": textFunc(billingHandler), + "fallback_reply": textFunc(fallbackReply), "auditor": textFunc(auditor), + "notifier": textFunc(notifier), "publisher": textFunc(publisher), + "rejecter": textFunc(rejecter), "final_report": textFunc(finalReport), + "draft_text": textFunc(draftText), + + "classify": routeFunc(classify), "is_long": routeFunc(isLong), "audience": routeFunc(audience), + "long_enough": routeFunc(longEnough), "verdict": routeFunc(verdict), + + "split": func(_ agent.Context, in any) (declarative.Result, error) { + return declarative.Output(words(fmt.Sprint(in))), nil + }, + "join_words": func(_ agent.Context, in any) (declarative.Result, error) { + items, _ := in.([]any) + return declarative.Output(joinWords(items)), nil + }, + "combine": func(_ agent.Context, in any) (declarative.Result, error) { + branches, _ := in.(map[string]any) + return declarative.Output(combine(branches)), nil + }, + "make_person": func(_ agent.Context, in any) (declarative.Result, error) { + return declarative.Output(makePerson(fmt.Sprint(in))), nil + }, + "greet_person": func(_ agent.Context, in any) (declarative.Result, error) { + person, _ := in.(map[string]any) + return declarative.Output(greetPerson(person)), nil + }, + "customer_args": func(_ agent.Context, in any) (declarative.Result, error) { + return declarative.Output(customerArgs(fmt.Sprint(in))), nil + }, + "describe_customer": func(_ agent.Context, in any) (declarative.Result, error) { + customer, _ := in.(map[string]any) + return declarative.Output(describeCustomer(customer)), nil + }, + } + flaky := newFlaky(2) + functions["flaky"] = func(_ agent.Context, in any) (declarative.Result, error) { + out, err := flaky(fmt.Sprint(in)) + return declarative.Output(out), err + } + for name, fn := range functions { + if err := reg.RegisterFunction(name, fn); err != nil { + return nil, err + } + } + + // A dynamic node that runs two reviewers in turn and reports both answers. + if err := reg.RegisterDynamic("review_both", func(_ agent.Context, in any, env *declarative.DynamicEnv) (any, error) { + first, err := env.RunChild("ask_first", in) + if err != nil { + return nil, err + } + second, err := env.RunChild("ask_second", in) + if err != nil { + return nil, err + } + return fmt.Sprintf("first %v, second %v", first, second), nil + }); err != nil { + return nil, err + } + + // A dynamic node decides at run time how often to run its child. + if err := reg.RegisterDynamic("refine", func(_ agent.Context, in any, env *declarative.DynamicEnv) (any, error) { + out := in + for { + next, err := env.RunChild("critic", out) + if errors.Is(err, declarative.ErrIterationBudget) { + return out, nil + } + if err != nil { + return nil, err + } + out = next + } + }); err != nil { + return nil, err + } + + lookup, err := newLookupTool() + if err != nil { + return nil, err + } + if err := reg.RegisterTool(lookup); err != nil { + return nil, err + } + return reg, nil +} diff --git a/go/adk/pkg/a2a/metadata.go b/go/adk/pkg/a2a/metadata.go index e91ed29ae4..68e9ff494d 100644 --- a/go/adk/pkg/a2a/metadata.go +++ b/go/adk/pkg/a2a/metadata.go @@ -67,6 +67,7 @@ func canonicalizeADKPart(part *a2atype.Part) { func canonicalizeADKMetadata(metadata map[string]any) { copyADKMetadata(metadata, adka2a.ToA2AMetaKey("usage_metadata"), apia2a.UsageMetadataKey) + copyADKMetadata(metadata, adka2a.ToA2AMetaKey("author"), apia2a.AuthorMetadataKey) for key := range metadata { if strings.HasPrefix(key, "adk_") { delete(metadata, key) diff --git a/go/adk/pkg/a2a/metadata_test.go b/go/adk/pkg/a2a/metadata_test.go index edfcda0434..9a677a4bb0 100644 --- a/go/adk/pkg/a2a/metadata_test.go +++ b/go/adk/pkg/a2a/metadata_test.go @@ -16,7 +16,7 @@ func TestCanonicalizeADKEvent(t *testing.T) { "adk_is_long_running": true, "adk_thought": true, } - artifact := &a2atype.Artifact{Parts: []*a2atype.Part{part}} + artifact := &a2atype.Artifact{Parts: []*a2atype.Part{part}, Metadata: map[string]any{"adk_author": "critic"}} event := &a2atype.TaskArtifactUpdateEvent{ Artifact: artifact, Metadata: map[string]any{ @@ -33,6 +33,9 @@ func TestCanonicalizeADKEvent(t *testing.T) { if part.Metadata[apia2a.ThoughtMetadataKey] != true { t.Fatalf("a thought must stay marked once ADK's keys go: %#v", part.Metadata) } + if artifact.Metadata[apia2a.AuthorMetadataKey] != "critic" { + t.Fatalf("the author must survive for the chat to attribute the reply: %#v", artifact.Metadata) + } if _, ok := part.Metadata["adk_is_long_running"]; ok { t.Fatalf("long-running adapter metadata leaked: %#v", part.Metadata) } diff --git a/go/adk/pkg/agent/agent.go b/go/adk/pkg/agent/agent.go index b5363b6288..70dcd835ae 100644 --- a/go/adk/pkg/agent/agent.go +++ b/go/adk/pkg/agent/agent.go @@ -17,6 +17,7 @@ import ( adkmodel "google.golang.org/adk/v2/model" adkgemini "google.golang.org/adk/v2/model/gemini" "google.golang.org/adk/v2/tool" + "google.golang.org/adk/v2/tool/agenttool" "google.golang.org/adk/v2/tool/loadmemorytool" "google.golang.org/adk/v2/tool/preloadmemorytool" "google.golang.org/adk/v2/tool/skilltoolset" @@ -57,16 +58,31 @@ func CreateGoogleADKAgent(ctx context.Context, config *configv1.ADKConfig, agent if err := protovalidate.Validate(config); err != nil { return nil, err } - return createGoogleADKAgent(ctx, config.Agent, agentName, stsPlugin, runtimeconfig.ProcessConfig(config), extraTools...) + if config.Agent.Workflow != nil { + return nil, fmt.Errorf("agent config carries a declarative workflow, which only BYO images run") + } + if config.Agent.GetIsolation() != configv1.ADKAgentIsolation_ADK_AGENT_ISOLATION_UNSPECIFIED { + return nil, fmt.Errorf("the root agent sets isolation, but it has no parent to isolate from") + } + return createGoogleADKAgent(ctx, config.Agent, agentName, stsPlugin, runtimeconfig.ProcessConfig(config), false, extraTools...) } -func createGoogleADKAgent(ctx context.Context, agentConfig *configv1.ADKAgentConfig, agentName string, stsPlugin *sts.TokenPropagationPlugin, runtime *configv1.ADKRuntimeConfig, extraTools ...tool.Tool) (agent.Agent, error) { +// createGoogleADKAgent builds one agent and its subagents. standalone marks an +// agent that runs as a tool in a session of its own: nothing it does can reach +// a person, so it gets no ask_user tool and cannot require approvals. +func createGoogleADKAgent(ctx context.Context, agentConfig *configv1.ADKAgentConfig, agentName string, stsPlugin *sts.TokenPropagationPlugin, runtime *configv1.ADKRuntimeConfig, standalone bool, extraTools ...tool.Tool) (agent.Agent, error) { log := logging.FromContext(ctx) if agentConfig == nil { return nil, fmt.Errorf("agent config is required") } + if standalone { + if err := checkStandalone(agentConfig); err != nil { + return nil, err + } + } + propagateToken := runtime.GetTokenPropagation().GetEnabled() var dynamicHeaderProvider mcp.DynamicHeaderProvider if stsPlugin != nil { @@ -117,7 +133,10 @@ func createGoogleADKAgent(ctx context.Context, agentConfig *configv1.ADKAgentCon log.InfoContext(ctx, "wired remote A2A agent tool", "name", remoteAgent.Name, "url", remoteAgent.Url) } - localTools, err := buildAgentTools(agentConfig, remoteAgentTools, extraTools, builtinTools.GetDisableAskUser(), log) + // A standalone subagent cannot pause for the user, and runtime defaults may + // disable ask_user entirely. + withAskUser := !standalone && !builtinTools.GetDisableAskUser() + localTools, err := buildAgentTools(agentConfig, remoteAgentTools, extraTools, withAskUser, log) if err != nil { return nil, err } @@ -135,12 +154,23 @@ func createGoogleADKAgent(ctx context.Context, agentConfig *configv1.ADKAgentCon if agentName == "" { agentName = "agent" } + // A subagent either joins the parent's tree and shares its session, or runs + // as a tool in a session of its own. var subAgents []agent.Agent for _, childConfig := range agentConfig.SubAgents { - child, err := createGoogleADKAgent(ctx, childConfig, childConfig.Name, stsPlugin, runtime) + isolated := childConfig.GetIsolation() == configv1.ADKAgentIsolation_ADK_AGENT_ISOLATION_SESSION + if isolated && runtime.GetTokenPropagation().GetStsWellKnownUri() != "" { + return nil, fmt.Errorf("subagent %q is isolated, which cannot be combined with STS token exchange: the exchanged token is cached per session", childConfig.Name) + } + // A shared child of a standalone agent runs in that agent's session too. + child, err := createGoogleADKAgent(ctx, childConfig, childConfig.Name, stsPlugin, runtime, standalone || isolated) if err != nil { return nil, fmt.Errorf("failed to create sub-agent %q: %w", childConfig.Name, err) } + if isolated { + localTools = append(localTools, agenttool.New(child, nil)) + continue + } subAgents = append(subAgents, child) } @@ -226,7 +256,7 @@ func usesRawOutputSchema(model *configv1.ADKModelConfig) bool { } } -func buildAgentTools(agentConfig *configv1.ADKAgentConfig, remoteAgentTools, extraTools []tool.Tool, disableAskUser bool, log *slog.Logger) ([]tool.Tool, error) { +func buildAgentTools(agentConfig *configv1.ADKAgentConfig, remoteAgentTools, extraTools []tool.Tool, withAskUser bool, log *slog.Logger) ([]tool.Tool, error) { var localTools []tool.Tool if agentConfig.Memory != nil { log.Info("memory configuration detected, adding memory tools") @@ -238,7 +268,7 @@ func buildAgentTools(agentConfig *configv1.ADKAgentConfig, remoteAgentTools, ext localTools = append(localTools, remoteAgentTools...) localTools = append(localTools, extraTools...) - if !disableAskUser { + if withAskUser { askUserTool, err := tools.NewAskUserTool() if err != nil { return nil, fmt.Errorf("failed to create ask_user tool: %w", err) @@ -248,6 +278,23 @@ func buildAgentTools(agentConfig *configv1.ADKAgentConfig, remoteAgentTools, ext return localTools, nil } +// checkStandalone rejects what an agent that runs as a tool in its own session +// cannot do: pause for a person. Its conversation ends with the tool call, so +// an approval request would never be answered. +func checkStandalone(agentConfig *configv1.ADKAgentConfig) error { + for _, server := range agentConfig.HttpTools { + if server.RequireApproval { + return fmt.Errorf("isolated subagent %q cannot pause for approval, but the MCP server %s requires it", agentConfig.Name, server.GetParams().GetUrl()) + } + } + for _, server := range agentConfig.SseTools { + if server.RequireApproval { + return fmt.Errorf("isolated subagent %q cannot pause for approval, but the MCP server %s requires it", agentConfig.Name, server.GetParams().GetUrl()) + } + } + return nil +} + // generateContentConfig returns the agent-level generation config derived from // the model definition, or nil when the model doesn't specify any. ADK seeds // each LLMRequest.Config from this value, so per-request mutations (e.g. in diff --git a/go/adk/pkg/agent/agent_test.go b/go/adk/pkg/agent/agent_test.go index 607313d3bf..ef9d163702 100644 --- a/go/adk/pkg/agent/agent_test.go +++ b/go/adk/pkg/agent/agent_test.go @@ -718,3 +718,14 @@ func TestUsesRawOutputSchema(t *testing.T) { }) } } + +func TestCreateGoogleADKAgentRejectsDeclarativeWorkflow(t *testing.T) { + cfg := &configv1.ADKConfig{Agent: &configv1.ADKAgentConfig{ + Model: &configv1.ADKModelConfig{Model: "gpt-4o", Provider: &configv1.ADKModelConfig_Openai{Openai: &configv1.ADKOpenAI{}}}, + Workflow: &configv1.ADKWorkflowConfig{ + Document: []byte(`{}`), Sha256: "0000000000000000000000000000000000000000000000000000000000000000", + }, + }} + _, err := CreateGoogleADKAgent(t.Context(), cfg, "agent", nil) + require.ErrorContains(t, err, "declarative workflow, which only BYO images run") +} diff --git a/go/adk/pkg/agent/isolation_test.go b/go/adk/pkg/agent/isolation_test.go new file mode 100644 index 0000000000..cb00d4e87a --- /dev/null +++ b/go/adk/pkg/agent/isolation_test.go @@ -0,0 +1,217 @@ +package agent + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" + adkagent "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/runner" + adksession "google.golang.org/adk/v2/session" + "google.golang.org/genai" + + configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" +) + +// scriptedModels is an OpenAI-compatible server for a parent and a subagent. The +// parent calls the subagent's tool once per request in its first reply and then +// answers with the tool results. The subagent answers with what it was shown. +type scriptedModels struct { + parentCalls []string // requests the parent sends to the "reviewer" tool, in one reply + + mu sync.Mutex + child []childRequest + parentTx [][]string // tool names offered to the parent, per request +} + +type childRequest struct { + Messages string // role: content, one per line + Tools []string // tool names offered +} + +type chatRequest struct { + Model string `json:"model"` + Messages []struct { + Role string `json:"role"` + Content any `json:"content"` + } `json:"messages"` + Tools []struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + } `json:"tools"` +} + +func (s *scriptedModels) serve(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req chatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode model request: %v", err) + http.Error(w, "bad request", http.StatusBadRequest) + return + } + var tools []string + for _, tool := range req.Tools { + tools = append(tools, tool.Function.Name) + } + var transcript []string + toolResults := 0 + for _, m := range req.Messages { + transcript = append(transcript, fmt.Sprintf("%s: %v", m.Role, m.Content)) + if m.Role == "tool" { + toolResults++ + } + } + + w.Header().Set("Content-Type", "application/json") + reply := func(message string) { + _, _ = fmt.Fprintf(w, `{"id":"x","object":"chat.completion","model":"x","choices":[{"index":0,"message":%s,"finish_reason":"stop"}]}`, message) + } + if req.Model == "child-model" { + s.mu.Lock() + s.child = append(s.child, childRequest{Messages: strings.Join(transcript, "\n"), Tools: tools}) + s.mu.Unlock() + reply(`{"role":"assistant","content":"child-answer"}`) + return + } + + s.mu.Lock() + s.parentTx = append(s.parentTx, tools) + s.mu.Unlock() + if toolResults > 0 { + reply(fmt.Sprintf(`{"role":"assistant","content":%q}`, fmt.Sprintf("parent saw %d tool results", toolResults))) + return + } + calls := make([]string, len(s.parentCalls)) + for i, request := range s.parentCalls { + args, _ := json.Marshal(map[string]string{"request": request}) + calls[i] = fmt.Sprintf(`{"id":"call-%d","type":"function","function":{"name":"reviewer","arguments":%q}}`, i, string(args)) + } + reply(`{"role":"assistant","content":null,"tool_calls":[` + strings.Join(calls, ",") + `]}`) + })) + t.Cleanup(server.Close) + return server +} + +func openAIModel(url, name string) *configv1.ADKModelConfig { + return &configv1.ADKModelConfig{Model: name, Provider: &configv1.ADKModelConfig_Openai{Openai: &configv1.ADKOpenAI{BaseUrl: url}}} +} + +func isolationConfig(url string, isolation configv1.ADKAgentIsolation) *configv1.ADKConfig { + return &configv1.ADKConfig{Agent: &configv1.ADKAgentConfig{ + Model: openAIModel(url, "parent-model"), Instruction: "coordinate", Stream: new(false), + SubAgents: []*configv1.ADKAgentConfig{{ + Name: "reviewer", Description: "reviews work", Model: openAIModel(url, "child-model"), + Instruction: "review", Stream: new(false), Isolation: isolation, + }}, + }} +} + +func runParent(t *testing.T, root adkagent.Agent, input string) { + t.Helper() + sessions := adksession.InMemoryService() + r, err := runner.New(runner.Config{AppName: "test", Agent: root, SessionService: sessions}) + require.NoError(t, err) + created, err := sessions.Create(t.Context(), &adksession.CreateRequest{AppName: "test", UserID: "user"}) + require.NoError(t, err) + for _, err := range r.Run(t.Context(), "user", created.Session.ID(), genai.NewContentFromText(input, genai.RoleUser), adkagent.RunConfig{}) { + require.NoError(t, err) + } +} + +func TestIsolatedSubAgentRunsAsAToolInASessionOfItsOwn(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "test-key") + models := &scriptedModels{parentCalls: []string{"task one", "task two", "task three"}} + server := models.serve(t) + + root, err := CreateGoogleADKAgent(t.Context(), isolationConfig(server.URL, configv1.ADKAgentIsolation_ADK_AGENT_ISOLATION_SESSION), "parent", nil) + require.NoError(t, err) + require.Empty(t, root.SubAgents(), "an isolated subagent is a tool, not a transfer target") + + runParent(t, root, "the parent's private conversation") + + require.Len(t, models.child, 3, "each parallel call runs in its own session") + for _, call := range models.child { + require.NotContains(t, call.Messages, "private conversation", "the subagent must not see the parent's history") + seen := 0 + for _, task := range models.parentCalls { + if strings.Contains(call.Messages, task) { + seen++ + } + } + require.Equal(t, 1, seen, "a call saw another call's request:\n%s", call.Messages) + require.NotContains(t, call.Tools, "ask_user", "an isolated subagent cannot reach a person") + } + require.Contains(t, models.parentTx[0], "reviewer", "the parent calls the subagent as a tool") + require.Contains(t, models.parentTx[0], "ask_user", "the parent can still ask the user") +} + +func TestSharedSubAgentStaysATransferTarget(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "test-key") + models := &scriptedModels{} + server := models.serve(t) + for _, isolation := range []configv1.ADKAgentIsolation{ + configv1.ADKAgentIsolation_ADK_AGENT_ISOLATION_UNSPECIFIED, + configv1.ADKAgentIsolation_ADK_AGENT_ISOLATION_SHARED, + } { + t.Run(isolation.String(), func(t *testing.T) { + root, err := CreateGoogleADKAgent(t.Context(), isolationConfig(server.URL, isolation), "parent", nil) + require.NoError(t, err) + require.Len(t, root.SubAgents(), 1) + require.Equal(t, "reviewer", root.SubAgents()[0].Name()) + }) + } +} + +func TestIsolationIsRejectedWhereItCannotWork(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "test-key") + const url = "http://127.0.0.1:1" + tests := []struct { + name string + mutate func(*configv1.ADKConfig) + want string + }{ + {"root agent", func(c *configv1.ADKConfig) { + c.Agent.Isolation = configv1.ADKAgentIsolation_ADK_AGENT_ISOLATION_SESSION + }, "the root agent sets isolation"}, + {"approval on an MCP server", func(c *configv1.ADKConfig) { + c.Agent.SubAgents[0].HttpTools = []*configv1.ADKHTTPMCPServerConfig{{ + Params: &configv1.ADKHTTPConnectionParams{Url: "http://tools.example.com/mcp"}, RequireApproval: true, + }} + }, `isolated subagent "reviewer" cannot pause for approval`}, + {"approval on a shared child of an isolated subagent", func(c *configv1.ADKConfig) { + c.Agent.SubAgents[0].SubAgents = []*configv1.ADKAgentConfig{{ + Name: "checker", Description: "checks work", Model: openAIModel("http://127.0.0.1:1", "child-model"), Instruction: "check", + HttpTools: []*configv1.ADKHTTPMCPServerConfig{{ + Params: &configv1.ADKHTTPConnectionParams{Url: "http://tools.example.com/mcp"}, RequireApproval: true, + }}, + }} + }, `isolated subagent "checker" cannot pause for approval`}, + {"STS token exchange", func(c *configv1.ADKConfig) { + c.Runtime = &configv1.ADKRuntimeConfig{TokenPropagation: &configv1.TokenPropagationConfig{ + Enabled: true, StsWellKnownUri: new("https://sts.example.com/.well-known/oauth-authorization-server"), + }} + }, "cannot be combined with STS token exchange"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := isolationConfig(url, configv1.ADKAgentIsolation_ADK_AGENT_ISOLATION_SESSION) + tt.mutate(config) + _, err := CreateGoogleADKAgent(t.Context(), config, "parent", nil) + require.ErrorContains(t, err, tt.want) + }) + } +} + +func TestUndefinedIsolationValueIsInvalid(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "test-key") + config := isolationConfig("http://127.0.0.1:1", configv1.ADKAgentIsolation(99)) + _, err := CreateGoogleADKAgent(t.Context(), config, "parent", nil) + require.Error(t, err, "protovalidate rejects a value the enum does not define") +} diff --git a/go/adk/pkg/config/contract_test.go b/go/adk/pkg/config/contract_test.go index be4134d3d8..d2c994a3f0 100644 --- a/go/adk/pkg/config/contract_test.go +++ b/go/adk/pkg/config/contract_test.go @@ -3,6 +3,7 @@ package config_test import ( "encoding/json" "os" + "strings" "testing" "time" @@ -113,3 +114,26 @@ func TestSharedNumericValidation(t *testing.T) { }) } } + +func TestWorkflowConfigRoundTripAndValidation(t *testing.T) { + document := []byte(`{"apiVersion":"adk.declarative/v0.2","kind":"AgentSystem","agents":[]}`) + digest := strings.Repeat("a", 64) + cfg := &configv1.ADKConfig{Agent: &configv1.ADKAgentConfig{Workflow: &configv1.ADKWorkflowConfig{Document: document, Sha256: digest}}} + raw, err := runtimeconfig.Marshal(cfg) + require.NoError(t, err) + parsed := new(configv1.ADKConfig) + require.NoError(t, runtimeconfig.Unmarshal(raw, parsed)) + require.True(t, proto.Equal(cfg, parsed)) + + for name, workflow := range map[string]*configv1.ADKWorkflowConfig{ + "empty document": {Sha256: digest}, + "oversized document": {Document: make([]byte, 65537), Sha256: digest}, + "short digest": {Document: document, Sha256: "abc"}, + "uppercase digest": {Document: document, Sha256: strings.Repeat("A", 64)}, + } { + t.Run(name, func(t *testing.T) { + _, err := runtimeconfig.Marshal(&configv1.ADKConfig{Agent: &configv1.ADKAgentConfig{Workflow: workflow}}) + require.Error(t, err) + }) + } +} diff --git a/go/adk/pkg/declarative/build.go b/go/adk/pkg/declarative/build.go new file mode 100644 index 0000000000..e1d5c18a5d --- /dev/null +++ b/go/adk/pkg/declarative/build.go @@ -0,0 +1,315 @@ +package declarative + +import ( + "context" + "fmt" + "slices" + + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/agent/llmagent" + "google.golang.org/adk/v2/agent/workflowagent" + "google.golang.org/adk/v2/agent/workflowagents/loopagent" + "google.golang.org/adk/v2/agent/workflowagents/parallelagent" + "google.golang.org/adk/v2/agent/workflowagents/sequentialagent" + "google.golang.org/adk/v2/tool" + "google.golang.org/adk/v2/tool/agenttool" + "google.golang.org/adk/v2/workflow" + + adkoutputschema "github.com/kagent-dev/kagent/go/adk/pkg/outputschema" +) + +// System is the set of agents built from one document. +type System struct { + root agent.Agent + agents map[string]agent.Agent + names []string + // servers are the toolsets of the document's MCP servers. + servers map[string]tool.Toolset +} + +// Root is the first agent of the document. +func (s *System) Root() agent.Agent { return s.root } + +// Agent returns the agent declared under name. +func (s *System) Agent(name string) (agent.Agent, bool) { + a, ok := s.agents[name] + return a, ok +} + +// Names lists the declared agents in document order. +func (s *System) Names() []string { return slices.Clone(s.names) } + +// Loader exposes every declared agent, root first, to ADK launchers and +// servers that select an agent by name. +func (s *System) Loader() (agent.Loader, error) { + others := make([]agent.Agent, 0, len(s.names)-1) + for _, name := range s.names[1:] { + others = append(others, s.agents[name]) + } + return agent.NewMultiLoader(s.root, others...) +} + +// Build validates spec against reg and constructs every declared agent. An +// agent that is reachable from several places (sub_agents, agent nodes) is +// built once and shared. Graph semantics such as reachability, unconditional +// cycles and fan-in are enforced by the ADK workflow engine and reported with +// the agent they belong to. A model resolver's own failures also surface here, +// because only it can tell whether a name resolves. +func Build(ctx context.Context, spec *Spec, reg *Registry) (*System, error) { + if reg == nil { + reg = NewRegistry() + } + if err := reg.Validate(spec); err != nil { + return nil, err + } + b := &builder{ + ctx: ctx, + reg: reg, + specs: make(map[string]*AgentSpec, len(spec.Agents)), + built: make(map[string]agent.Agent, len(spec.Agents)), + servers: make(map[string]tool.Toolset, len(spec.MCPServers)), + } + sys := &System{agents: b.built, servers: b.servers} + if err := b.connectMCPServers(spec.MCPServers); err != nil { + _ = sys.Close() + return nil, err + } + for i := range spec.Agents { + b.specs[spec.Agents[i].Name] = &spec.Agents[i] + sys.names = append(sys.names, spec.Agents[i].Name) + } + for _, name := range sys.names { + if _, err := b.agent(name); err != nil { + _ = sys.Close() + return nil, err + } + } + sys.root = b.built[sys.names[0]] + return sys, nil +} + +// builder memoizes agents by name. Validation guarantees the references it +// follows are acyclic: sub_agents form a forest and agent nodes only target +// LlmAgents, which never reference a Workflow. +type builder struct { + ctx context.Context + reg *Registry + specs map[string]*AgentSpec + built map[string]agent.Agent + servers map[string]tool.Toolset +} + +func (b *builder) agent(name string) (agent.Agent, error) { + if a, ok := b.built[name]; ok { + return a, nil + } + spec := b.specs[name] + var ( + a agent.Agent + err error + ) + switch spec.AgentClass { + case ClassWorkflow: + a, err = b.workflowAgent(spec) + case ClassLlmAgent: + a, err = b.llmAgent(spec) + case ClassSequential, ClassParallel, ClassLoop: + a, err = b.composite(spec) + } + if err != nil { + return nil, fmt.Errorf("failed to build agent %q: %w", name, err) + } + b.built[name] = a + return a, nil +} + +func (b *builder) subAgents(names []string) ([]agent.Agent, error) { + subs := make([]agent.Agent, 0, len(names)) + for _, n := range names { + a, err := b.agent(n) + if err != nil { + return nil, err + } + subs = append(subs, a) + } + return subs, nil +} + +func (b *builder) llmAgent(spec *AgentSpec) (agent.Agent, error) { + return b.newLLM(spec.Name, spec.Description, &spec.LLMSpec, spec.Mode, spec.SubAgents, spec.AgentTools, spec.DisallowTransferToParent, spec.DisallowTransferToPeers) +} + +// newLLM is shared by LlmAgent agents and inline llm nodes, which pass their +// own effective mode. +func (b *builder) newLLM(name, description string, l *LLMSpec, mode Mode, subAgents, agentTools []string, noParent, noPeers bool) (agent.Agent, error) { + llm, err := b.reg.resolveModel(b.ctx, l.Model) + if err != nil { + return nil, err + } + toolList, toolsets, err := b.resolveTools(l.Tools) + if err != nil { + return nil, err + } + subs, err := b.subAgents(subAgents) + if err != nil { + return nil, err + } + instruction, err := sourcedText(l.Instruction, l.InstructionFrom) + if err != nil { + return nil, fmt.Errorf("instruction_from: %w", err) + } + globalInstruction, err := sourcedText(l.GlobalInstruction, l.GlobalInstructionFrom) + if err != nil { + return nil, fmt.Errorf("global_instruction_from: %w", err) + } + // An agent tool runs in a session of its own, so it never joins the tree. + for _, name := range agentTools { + target, err := b.agent(name) + if err != nil { + return nil, err + } + toolList = append(toolList, agenttool.New(target, nil)) + } + cfg := llmagent.Config{ + Name: name, + Description: description, + Model: llm, + Instruction: instruction, + GlobalInstruction: globalInstruction, + Tools: toolList, + Toolsets: toolsets, + SubAgents: subs, + OutputKey: l.OutputKey, + Mode: llmagent.Mode(mode), + IncludeContents: llmagent.IncludeContents(l.IncludeContents), + DisallowTransferToParent: noParent, + DisallowTransferToPeers: noPeers, + } + if len(l.OutputSchema) > 0 { + if cfg.OutputSchema, err = adkoutputschema.ToGenAISchema(l.OutputSchema); err != nil { + return nil, fmt.Errorf("failed to convert output_schema: %w", err) + } + } + if err := b.reg.applyCallbacks(&cfg, l.Callbacks); err != nil { + return nil, fmt.Errorf("callbacks: %w", err) + } + return llmagent.New(cfg) +} + +// composite builds the classic ADK workflow agents, which orchestrate their +// sub-agents without a graph. +func (b *builder) composite(spec *AgentSpec) (agent.Agent, error) { + subs, err := b.subAgents(spec.SubAgents) + if err != nil { + return nil, err + } + cfg := agent.Config{Name: spec.Name, Description: spec.Description, SubAgents: subs} + switch spec.AgentClass { + case ClassSequential: + return sequentialagent.New(sequentialagent.Config{AgentConfig: cfg}) + case ClassParallel: + return parallelagent.New(parallelagent.Config{AgentConfig: cfg}) + default: + return loopagent.New(loopagent.Config{AgentConfig: cfg, MaxIterations: spec.MaxIterations}) + } +} + +func (b *builder) workflowAgent(spec *AgentSpec) (agent.Agent, error) { + nodes, err := b.nodes(spec.Nodes, spec.Edges, true) + if err != nil { + return nil, err + } + edges := toEdges(nodes, spec.Edges, spec.Entry) + subs, err := b.subAgents(spec.SubAgents) + if err != nil { + return nil, err + } + if spec.MaxConcurrency > 0 { + return cappedWorkflowAgent(spec.Name, spec.Description, subs, edges, spec.MaxConcurrency) + } + return workflowagent.New(workflowagent.Config{ + Name: spec.Name, + Description: spec.Description, + SubAgents: subs, + Edges: edges, + }) +} + +// scope holds the nodes of one graph by document id. Dynamic nodes resolve +// their children from it when they run, after every node has been built. +type scope struct { + nodes map[string]workflow.Node +} + +// nodes builds every node of one graph scope. terminal reports whether the +// scope's terminal nodes end the run; those nodes render their output for +// clients that only display message text. +func (b *builder) nodes(specs []NodeSpec, edges []EdgeSpec, terminal bool) (map[string]workflow.Node, error) { + bc := &BuildContext{Context: b.ctx, Registry: b.reg, Agent: b.agent} + sc := &scope{nodes: make(map[string]workflow.Node, len(specs))} + outgoing := make(map[string]bool, len(edges)) + for _, e := range edges { + outgoing[e.From] = true + } + children := map[string]bool{} + for i := range specs { + for _, c := range specs[i].Children { + children[c] = true + } + } + for i := range specs { + n := &specs[i] + node, err := b.node(bc, sc, n, terminal && !outgoing[n.ID] && !children[n.ID]) + if err != nil { + return nil, fmt.Errorf("failed to build node %q: %w", n.ID, err) + } + sc.nodes[n.ID] = node + } + return sc.nodes, nil +} + +// toEdges resolves document edges against built nodes. entry is sugar for a +// START edge and is skipped when the edge already exists. +func toEdges(nodes map[string]workflow.Node, specs []EdgeSpec, entry string) []workflow.Edge { + resolve := func(id string) workflow.Node { + if id == Start { + return workflow.Start + } + return nodes[id] + } + edges := make([]workflow.Edge, 0, len(specs)+1) + hasEntry := false + for _, e := range specs { + edge := workflow.Edge{From: resolve(e.From), To: resolve(e.To)} + switch e.Route { + case "": + case RouteDefault: + edge.Route = workflow.Default + default: + edge.Route = workflow.StringRoute(e.Route) + } + hasEntry = hasEntry || (e.From == Start && e.To == entry && e.Route == "") + edges = append(edges, edge) + } + if entry != "" && !hasEntry { + edges = append([]workflow.Edge{{From: workflow.Start, To: nodes[entry]}}, edges...) + } + return edges +} + +// resolveTools splits an LLM's tools list into tools and toolsets: registered +// tools, declared MCP servers, and registered toolsets. +func (b *builder) resolveTools(names []string) ([]tool.Tool, []tool.Toolset, error) { + var tools []tool.Tool + var toolsets []tool.Toolset + for _, name := range names { + if t, ok := b.reg.tools[name]; ok { + tools = append(tools, t) + } else if ts, ok := b.toolset(name); ok { + toolsets = append(toolsets, ts) + } else { + return nil, nil, fmt.Errorf("tool %q is not registered", name) + } + } + return tools, toolsets, nil +} diff --git a/go/adk/pkg/declarative/build_test.go b/go/adk/pkg/declarative/build_test.go new file mode 100644 index 0000000000..8d9daff18b --- /dev/null +++ b/go/adk/pkg/declarative/build_test.go @@ -0,0 +1,653 @@ +package declarative + +import ( + "context" + "errors" + "fmt" + "iter" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/adk/v2/agent" + adkmodel "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/runner" + "google.golang.org/adk/v2/session" + "google.golang.org/adk/v2/workflow" + "google.golang.org/genai" +) + +func userMessage(text string) *genai.Content { return genai.NewContentFromText(text, genai.RoleUser) } + +// outputs returns the non-nil event outputs in arrival order. +func outputs(events []*session.Event) []any { + var out []any + for _, ev := range events { + if ev.Output != nil { + out = append(out, ev.Output) + } + } + return out +} + +func requestedInput(events []*session.Event) *session.RequestInput { + for _, ev := range events { + if ev.RequestedInput != nil { + return ev.RequestedInput + } + } + return nil +} + +func buildShowcase(t *testing.T) *System { + t.Helper() + sys, err := Build(t.Context(), loadShowcase(t), testRegistry(t)) + require.NoError(t, err) + return sys +} + +func TestShowcaseRoutesEachBranch(t *testing.T) { + sys := buildShowcase(t) + tests := []struct { + name string + input string + final any + }{ + {"BUG runs parallel worker then nested workflow", "bug x y", + "summary:exec(plan([item:bug item:x item:y]))"}, + {"BILLING runs the tool node", "bill me", + "summary:map[customer:c-42 plan:pro]"}, + {"SUPPORT runs the agent node", "help me", "summary:[stub] help me"}, + {"unmatched route takes DEFAULT", "zzz", "item:zzz"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, id := newSession(t) + got := outputs(run(t, sys.Root(), userMessage(tt.input), svc, id)) + require.Equal(t, tt.final, got[len(got)-1]) + }) + } +} + +func TestShowcaseFanOutJoinsEveryBranch(t *testing.T) { + svc, id := newSession(t) + got := outputs(run(t, buildShowcase(t).Root(), userMessage("zzz"), svc, id)) + require.Contains(t, got, map[string]any{"branch_a": "a", "branch_b": "b", "branch_c": "c"}) +} + +func TestShowcaseOtherPausesForApprovalAndResumes(t *testing.T) { + sys := buildShowcase(t) + svc, id := newSession(t) + + paused := run(t, sys.Root(), userMessage("other thing"), svc, id) + req := requestedInput(paused) + require.NotNil(t, req, "hitl node must request input") + require.Equal(t, "Confirm final reply before send (yes/no)", req.Message) + // The dynamic node spent exactly max_iters child runs before the pause. + require.Contains(t, outputs(paused), "[stub] other thing after 2 runs") + for _, o := range outputs(paused) { + require.NotContains(t, fmt.Sprint(o), "summary:", "terminal must not run before approval") + } + + reply := &genai.Content{Role: genai.RoleUser, Parts: []*genai.Part{{FunctionResponse: &genai.FunctionResponse{ + ID: req.InterruptID, Name: workflow.WorkflowInputFunctionCallName, + Response: map[string]any{"response": "yes"}, + }}}} + resumed := run(t, sys.Root(), reply, svc, id) + got := outputs(resumed) + require.Equal(t, "summary:yes", got[len(got)-1]) +} + +func TestSystemAccessors(t *testing.T) { + sys := buildShowcase(t) + require.Equal(t, "multi_agent_root", sys.Root().Name()) + require.Equal(t, []string{"multi_agent_root", "specialist_helper", "code_expert", "learning_coordinator", "docs_expert"}, sys.Names()) + + coordinator, ok := sys.Agent("learning_coordinator") + require.True(t, ok) + require.Equal(t, "docs_expert", coordinator.SubAgents()[0].Name()) + _, ok = sys.Agent("missing") + require.False(t, ok) + + // An agent used by sub_agents and by an agent node is one shared instance. + helper, _ := sys.Agent("specialist_helper") + require.Same(t, helper, sys.Root().SubAgents()[0]) +} + +func TestBuildNilRegistryUsesDefaults(t *testing.T) { + spec, err := Parse([]byte(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - { name: solo, agent_class: LlmAgent, model: stub, instruction: hi } +`)) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, nil) + require.NoError(t, err) + svc, id := newSession(t) + require.Contains(t, outputsOrText(run(t, sys.Root(), userMessage("ping"), svc, id)), "[stub] ping") +} + +func outputsOrText(events []*session.Event) []string { + var out []string + for _, ev := range events { + if ev.Content != nil { + for _, p := range ev.Content.Parts { + if p != nil && p.Text != "" { + out = append(out, p.Text) + } + } + } + } + return out +} + +func TestRetryPolicyRerunsFailingFunction(t *testing.T) { + var calls atomic.Int32 + reg := NewRegistry() + require.NoError(t, reg.RegisterFunction("flaky", func(agent.Context, any) (Result, error) { + if calls.Add(1) < 3 { + return Result{}, errors.New("transient") + } + return Output("recovered"), nil + })) + spec, err := reg.Parse([]byte(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: wf + agent_class: Workflow + nodes: + - id: only + type: function + function: flaky + retry: { max_attempts: 3, initial_delay: 1ms, max_delay: 2ms, backoff_factor: 1, jitter: 0 } + edges: [{ from: START, to: only }] +`)) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, reg) + require.NoError(t, err) + + svc, id := newSession(t) + require.Equal(t, []any{"recovered"}, outputs(run(t, sys.Root(), userMessage("go"), svc, id))) + require.EqualValues(t, 3, calls.Load()) +} + +// TestRetryWhenSelectsRetriableErrors covers retry.when, the CEL predicate +// behind ADK's ShouldRetry. +func TestRetryWhenSelectsRetriableErrors(t *testing.T) { + for _, tc := range []struct { + name, when string + failures []error + calls int32 + wantErr string + }{ + {"matching errors are retried", "error.message.matches(r'(?i)503|unavailable')", + []error{errors.New("upstream 503"), errors.New("service unavailable")}, 3, ""}, + {"other errors fail at once", "error.message.matches(r'503')", + []error{errors.New("invalid customer id")}, 1, "invalid customer id"}, + {"timeouts are classified", "error.timeout", + []error{fmt.Errorf("call: %w", context.DeadlineExceeded)}, 2, ""}, + {"input validation errors are never retried", "true", + []error{fmt.Errorf("bad input: %w", workflow.ErrInputValidation)}, 1, "bad input"}, + {"a failing expression does not retry", "error.missing == 'x'", + []error{errors.New("boom")}, 1, "boom"}, + } { + t.Run(tc.name, func(t *testing.T) { + var calls atomic.Int32 + reg := NewRegistry() + require.NoError(t, reg.RegisterFunction("flaky", func(agent.Context, any) (Result, error) { + if n := int(calls.Add(1)); n <= len(tc.failures) { + return Result{}, tc.failures[n-1] + } + return Output("recovered"), nil + })) + spec, err := reg.Parse([]byte(fmt.Sprintf(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: wf + agent_class: Workflow + nodes: + - id: only + type: function + function: flaky + retry: { max_attempts: 3, initial_delay: 1ms, jitter: 0, when: %q } + edges: [{ from: START, to: only }] +`, tc.when))) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, reg) + require.NoError(t, err) + svc, id := newSession(t) + events, err := tryRun(t, sys, svc, id, "go") + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + } else { + require.NoError(t, err) + require.Equal(t, []any{"recovered"}, outputs(events)) + } + require.Equal(t, tc.calls, calls.Load()) + }) + } + _, err := NewRegistry().Parse([]byte(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: wf + agent_class: Workflow + nodes: [{ id: c, type: cel, output: "'x'", retry: { when: "state.x == 1" } }] + edges: [{ from: START, to: c }] +`)) + require.ErrorContains(t, err, "undeclared reference to 'state'", "retry.when sees only the error") +} + +func TestBuildFailures(t *testing.T) { + const header = "apiVersion: adk.declarative/v0.2\nkind: AgentSystem\nagents:\n" + tests := []struct { + name string + doc string + want string + }{ + {"unconditional cycle is rejected by the engine", header + ` + - name: wf + agent_class: Workflow + nodes: + - { id: a, type: function, function: prefix } + - { id: b, type: function, function: prefix } + edges: + - { from: START, to: a } + - { from: a, to: b } + - { from: b, to: a }`, `agent "wf"`}, + {"fan-in to a non-join node is rejected by the engine", header + ` + - name: wf + agent_class: Workflow + nodes: + - { id: a, type: function, function: prefix } + - { id: b, type: function, function: prefix } + - { id: c, type: function, function: prefix } + edges: + - { from: START, to: a } + - { from: START, to: b } + - { from: a, to: c } + - { from: b, to: c }`, `agent "wf"`}, + {"task mode agent cannot be a graph node", header + ` + - { name: worker, agent_class: LlmAgent, model: stub, instruction: hi, mode: task } + - name: wf + agent_class: Workflow + nodes: [{ id: n, type: agent, agent: worker }] + edges: [{ from: START, to: n }]`, `agent "wf"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := testRegistry(t) + spec, err := reg.Parse([]byte(tt.doc)) + require.NoError(t, err, "document must be valid; the failure belongs to Build") + _, err = Build(t.Context(), spec, reg) + require.ErrorContains(t, err, tt.want) + require.False(t, IsValidation(err), "build failures are not document validation failures") + }) + } +} + +func TestBuildRejectsInvalidDocuments(t *testing.T) { + _, err := Build(t.Context(), &Spec{}, NewRegistry()) + require.True(t, IsValidation(err)) +} + +func TestModelResolverIsConsultedAfterRegisteredModels(t *testing.T) { + reg := NewRegistry() + var asked []string + reg.SetModelResolver(func(_ context.Context, name string) (adkmodel.LLM, error) { + asked = append(asked, name) + if name == "forbidden" { + return nil, errors.New("denied") + } + return NewStubModel(name), nil + }) + build := func(model string) error { + spec, err := reg.Parse([]byte(fmt.Sprintf(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - { name: a, agent_class: LlmAgent, model: %q, instruction: hi } +`, model))) + require.NoError(t, err) + _, err = Build(t.Context(), spec, reg) + return err + } + require.NoError(t, build("stub")) + require.Empty(t, asked, "registered models win over the resolver") + require.NoError(t, build("gpt-4o")) + require.ErrorContains(t, build("forbidden"), "denied") + require.Equal(t, []string{"gpt-4o", "forbidden"}, asked) +} + +func TestCustomNodeType(t *testing.T) { + reg := NewRegistry() + require.NoError(t, reg.RegisterNodeType("shout", NodeRule{Required: []string{"message"}}, + func(_ *BuildContext, spec *NodeSpec) (workflow.Node, error) { + return workflow.NewFunctionNode(spec.ID, func(_ agent.Context, in string) (string, error) { + return in + spec.Message, nil + }, workflow.NodeConfig{}), nil + })) + doc := []byte(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: wf + agent_class: Workflow + nodes: [{ id: s, type: shout, message: "!" }] + edges: [{ from: START, to: s }] +`) + _, err := Parse(doc) + require.ErrorContains(t, err, `unknown node type "shout"`, "plain Parse does not know custom types") + + spec, err := reg.Parse(doc) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, reg) + require.NoError(t, err) + svc, id := newSession(t) + require.Equal(t, []any{"hey!"}, outputs(run(t, sys.Root(), userMessage("hey"), svc, id))) + + bad, err := reg.Parse([]byte(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - { name: wf, agent_class: Workflow, nodes: [{ id: s, type: shout }], edges: [{ from: START, to: s }] } +`)) + require.Nil(t, bad) + require.ErrorContains(t, err, "agents[0].nodes[0].message: required for node type shout") +} + +func TestParallelWorkerWrapsTool(t *testing.T) { + reg := testRegistry(t) + require.NoError(t, reg.RegisterFunction("split_customers", func(agent.Context, any) (Result, error) { + return Output([]any{map[string]any{"customer_id": "c-1"}, map[string]any{"customer_id": "c-2"}}), nil + })) + spec, err := reg.Parse([]byte(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: fanout + agent_class: Workflow + nodes: + - { id: split, type: function, function: split_customers } + - { id: lookups, type: parallel_worker, tool: lookup_customer } + - { id: finish, type: function, function: summarize_join } + edges: + - { from: START, to: split } + - { from: split, to: lookups } + - { from: lookups, to: finish } +`)) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, reg) + require.NoError(t, err) + svc, id := newSession(t) + got := outputs(run(t, sys.Root(), userMessage("go"), svc, id)) + require.Equal(t, "summary:[map[customer:c-1 plan:pro] map[customer:c-2 plan:pro]]", got[len(got)-1]) +} + +func TestParallelWorkerWrapsAgent(t *testing.T) { + reg := testRegistry(t) + require.NoError(t, reg.RegisterFunction("topics", func(agent.Context, any) (Result, error) { + return Output([]any{"go", "k8s"}), nil + })) + spec, err := reg.Parse([]byte(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: fanout + agent_class: Workflow + nodes: + - { id: split, type: function, function: topics } + - { id: explain, type: parallel_worker, agent: explainer, max_concurrency: 1 } + - { id: finish, type: function, function: summarize_join } + edges: + - { from: START, to: split } + - { from: split, to: explain } + - { from: explain, to: finish } + - { name: explainer, agent_class: LlmAgent, model: stub, mode: single_turn, instruction: Explain it. } +`)) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, reg) + require.NoError(t, err) + svc, id := newSession(t) + got := outputs(run(t, sys.Root(), userMessage("go"), svc, id)) + require.Equal(t, "summary:[[stub] go [stub] k8s]", got[len(got)-1]) +} + +func TestCompositeAgents(t *testing.T) { + data, err := os.ReadFile("testdata/composites.yaml") + require.NoError(t, err) + reg := NewRegistry() + spec, err := reg.Parse(data) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, reg) + require.NoError(t, err) + + authors := func(name string) []string { + a, ok := sys.Agent(name) + require.True(t, ok) + svc, id := newSession(t) + var out []string + for _, ev := range run(t, a, userMessage("topic"), svc, id) { + if ev.Content != nil && ev.Author != "" { + out = append(out, ev.Author) + } + } + return out + } + require.Equal(t, []string{"draft", "review"}, authors("pipeline"), "sequential keeps declaration order") + require.ElementsMatch(t, []string{"optimist", "pessimist"}, authors("panel")) + require.Equal(t, []string{"polish", "polish"}, authors("refiner"), "loop stops at max_iterations") +} + +func TestTerminalStructuredOutputIsRenderedAsText(t *testing.T) { + reg := testRegistry(t) + require.NoError(t, reg.RegisterFunction("report", func(agent.Context, any) (Result, error) { + return Output(map[string]any{"status": "ok", "count": 2}), nil + })) + require.NoError(t, reg.RegisterFunction("pass", func(_ agent.Context, in any) (Result, error) { + return Output(map[string]any{"mid": in}), nil + })) + spec, err := reg.Parse([]byte(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: wf + agent_class: Workflow + nodes: + - { id: first, type: function, function: pass } + - id: nested + type: workflow + nodes: [{ id: inner, type: function, function: report }] + edges: [{ from: START, to: inner }] + edges: + - { from: START, to: first } + - { from: first, to: nested } +`)) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, reg) + require.NoError(t, err) + + svc, id := newSession(t) + var texts []string + for _, ev := range run(t, sys.Root(), userMessage("go"), svc, id) { + if ev.Content != nil { + for _, p := range ev.Content.Parts { + texts = append(texts, p.Text) + } + } + } + require.Equal(t, []string{`{"count":2,"status":"ok"}`}, texts, + "only the terminal node's structured output is shown; the intermediate one stays data") +} + +func TestIntermediateStringOutputsStayOutOfTheChat(t *testing.T) { + reg := testRegistry(t) + spec, err := reg.Parse([]byte(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: wf + agent_class: Workflow + nodes: + - { id: first, type: function, function: prefix } + - { id: second, type: function, function: prefix } + edges: [{ from: START, to: first }, { from: first, to: second }] +`)) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, reg) + require.NoError(t, err) + + svc, id := newSession(t) + var texts []string + for _, ev := range run(t, sys.Root(), userMessage("go"), svc, id) { + if ev.Content != nil { + for _, p := range ev.Content.Parts { + texts = append(texts, p.Text) + } + } + } + require.Equal(t, []string{"item:item:go"}, texts, "only the terminal result is shown, as with native function nodes") +} + +func TestChatModeAgentNodeKeepsItsDeclaredMode(t *testing.T) { + reg := NewRegistry() + spec, err := reg.Parse([]byte(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: wf + agent_class: Workflow + nodes: [{ id: ask, type: agent, agent: coordinator }] + edges: [{ from: START, to: ask }] + - { name: coordinator, agent_class: LlmAgent, model: stub, mode: chat, instruction: Coordinate. } +`)) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, reg) + require.NoError(t, err) + svc, id := newSession(t) + require.Contains(t, outputsOrText(run(t, sys.Root(), userMessage("hi"), svc, id)), "[stub] hi") +} + +func TestNodeTimeoutCancelsSlowFunction(t *testing.T) { + reg := NewRegistry() + require.NoError(t, reg.RegisterFunction("slow", func(ctx agent.Context, _ any) (Result, error) { + select { + case <-ctx.Done(): + return Result{}, ctx.Err() + case <-time.After(5 * time.Second): + return Output("too late"), nil + } + })) + spec, err := reg.Parse([]byte(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: wf + agent_class: Workflow + nodes: [{ id: only, type: function, function: slow, timeout: 20ms }] + edges: [{ from: START, to: only }] +`)) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, reg) + require.NoError(t, err) + + svc, id := newSession(t) + r, err := runner.New(runner.Config{AppName: "test", Agent: sys.Root(), SessionService: svc}) + require.NoError(t, err) + var runErr error + for _, err := range r.Run(t.Context(), "user", id, userMessage("go"), agent.RunConfig{}) { + if err != nil { + runErr = err + } + } + require.ErrorIs(t, runErr, context.DeadlineExceeded) +} + +// capturingModel records the requests it receives. +type capturingModel struct { + adkmodel.LLM + requests chan *adkmodel.LLMRequest +} + +func (m capturingModel) GenerateContent(ctx context.Context, req *adkmodel.LLMRequest, stream bool) iter.Seq2[*adkmodel.LLMResponse, error] { + m.requests <- req + return m.LLM.GenerateContent(ctx, req, stream) +} + +func TestLLMOptionsReachTheModel(t *testing.T) { + reg := NewRegistry() + model := capturingModel{LLM: NewStubModel("spy"), requests: make(chan *adkmodel.LLMRequest, 4)} + require.NoError(t, reg.RegisterModel("spy", model)) + spec, err := reg.Parse([]byte(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: solo + agent_class: LlmAgent + model: spy + instruction: Answer plainly. + global_instruction: Always be brief. + output_key: reply + output_schema: + type: object + properties: + answer: { type: string } + required: [answer] +`)) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, reg) + require.NoError(t, err) + + svc, id := newSession(t) + run(t, sys.Root(), userMessage("hi"), svc, id) + req := <-model.requests + instruction := req.Config.SystemInstruction.Parts[0].Text + require.Contains(t, instruction, "Always be brief.") + require.Contains(t, instruction, "Answer plainly.") + require.NotNil(t, req.Config.ResponseSchema, "output_schema must reach the model request") + require.Equal(t, "application/json", req.Config.ResponseMIMEType) + require.Contains(t, req.Config.ResponseSchema.Required, "answer") +} + +func TestSystemLoader(t *testing.T) { + sys := buildShowcase(t) + loader, err := sys.Loader() + require.NoError(t, err) + require.Equal(t, "multi_agent_root", loader.RootAgent().Name()) + require.ElementsMatch(t, sys.Names(), loader.ListAgents()) + a, err := loader.LoadAgent("learning_coordinator") + require.NoError(t, err) + require.Equal(t, "learning_coordinator", a.Name()) +} + +func TestStubModel(t *testing.T) { + m := NewStubModel("stub-x") + require.Equal(t, "stub-x", m.Name()) + next, stop := iter.Pull2(m.GenerateContent(t.Context(), &adkmodel.LLMRequest{Contents: []*genai.Content{ + genai.NewContentFromText("first", genai.RoleUser), + genai.NewContentFromText("reply", genai.RoleModel), + genai.NewContentFromText("last", genai.RoleUser), + }}, false)) + defer stop() + resp, err, ok := next() + require.True(t, ok) + require.NoError(t, err) + require.Equal(t, "[stub-x] last", resp.Content.Parts[0].Text) + require.True(t, resp.TurnComplete) + + next2, stop2 := iter.Pull2(m.GenerateContent(t.Context(), nil, false)) + defer stop2() + resp, _, _ = next2() + require.Equal(t, "[stub-x] ", resp.Content.Parts[0].Text) +} + +func TestOutputTextFallsBackWhenJSONFails(t *testing.T) { + require.Equal(t, "plain", outputText("plain")) + require.Equal(t, `{"a":1}`, outputText(map[string]any{"a": 1})) + require.Equal(t, fmt.Sprint(make(chan int))[:2], outputText(make(chan int))[:2], "unmarshalable values degrade to fmt") +} diff --git a/go/adk/pkg/declarative/capped_workflow.go b/go/adk/pkg/declarative/capped_workflow.go new file mode 100644 index 0000000000..0d18519b05 --- /dev/null +++ b/go/adk/pkg/declarative/capped_workflow.go @@ -0,0 +1,86 @@ +package declarative + +import ( + "encoding/json" + "fmt" + "iter" + + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/session" + "google.golang.org/adk/v2/workflow" + "google.golang.org/genai" +) + +// cappedWorkflowAgent builds a Workflow agent whose graph runs at most +// maxConcurrency nodes at once. +// +// workflowagent.Config does not accept workflow.WithMaxConcurrency, so this +// mirrors workflowagent.New: a fresh turn runs the graph and a turn that +// answers a hitl request resumes it. The agent is not tagged as a workflow +// agent for telemetry, so its span is named invoke_agent. +func cappedWorkflowAgent(name, description string, subs []agent.Agent, edges []workflow.Edge, maxConcurrency int) (agent.Agent, error) { + w, err := workflow.New(name, edges, workflow.WithMaxConcurrency(maxConcurrency)) + if err != nil { + return nil, err + } + return agent.New(agent.Config{ + Name: name, + Description: description, + SubAgents: subs, + Run: func(ctx agent.InvocationContext) iter.Seq2[*session.Event, error] { + return runOrResume(ctx, w) + }, + }) +} + +func runOrResume(ctx agent.InvocationContext, w *workflow.Workflow) iter.Seq2[*session.Event, error] { + responses := inputResponses(ctx.UserContent()) + if len(responses) == 0 { + return w.Run(ctx) + } + // The runner reuses the paused run's invocation ID on resume, which keeps + // an earlier completed run of the session out of the rebuilt state. + state, err := w.ReconstructRunState(ctx.Session(), ctx.InvocationID()) + if err != nil { + return func(yield func(*session.Event, error) bool) { + yield(nil, fmt.Errorf("failed to rebuild workflow %q for resume: %w", w.Name(), err)) + } + } + if state == nil { + return w.Run(ctx) + } + return w.Resume(agent.Promote(ctx), state, responses) +} + +// inputResponses returns the answers to workflow input requests by interrupt +// ID, decoded the same way as workflowagent. +func inputResponses(c *genai.Content) map[string]any { + if c == nil { + return nil + } + responses := map[string]any{} + for _, p := range c.Parts { + if p == nil || p.FunctionResponse == nil || p.FunctionResponse.Name != workflow.WorkflowInputFunctionCallName { + continue + } + responses[p.FunctionResponse.ID] = decodeInputResponse(p.FunctionResponse.Response) + } + return responses +} + +func decodeInputResponse(response map[string]any) any { + if raw, ok := response["response"]; ok { + if s, isStr := raw.(string); isStr { + var decoded any + if json.Unmarshal([]byte(s), &decoded) == nil { + return decoded + } + return s + } + return raw + } + if payload, ok := response["payload"]; ok { + return payload + } + return response +} diff --git a/go/adk/pkg/declarative/capped_workflow_test.go b/go/adk/pkg/declarative/capped_workflow_test.go new file mode 100644 index 0000000000..89698ca421 --- /dev/null +++ b/go/adk/pkg/declarative/capped_workflow_test.go @@ -0,0 +1,60 @@ +package declarative + +import ( + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/adk/v2/agent" +) + +// peakFanOut runs four parallel branches that each hold a slot for a while and +// returns the largest number of branches that ran at once. +func peakFanOut(t *testing.T, maxConcurrency string) int64 { + t.Helper() + var active, peak atomic.Int64 + reg := NewRegistry() + require.NoError(t, reg.RegisterFunction("work", func(agent.Context, any) (Result, error) { + n := active.Add(1) + for p := peak.Load(); n > p && !peak.CompareAndSwap(p, n); p = peak.Load() { + } + time.Sleep(50 * time.Millisecond) + active.Add(-1) + return Output("done"), nil + })) + require.NoError(t, reg.RegisterFunction("gathered", constant("gathered"))) + spec, err := reg.Parse([]byte(docHeader + ` - name: fan + agent_class: Workflow +` + maxConcurrency + ` nodes: + - { id: a, type: function, function: work } + - { id: b, type: function, function: work } + - { id: c, type: function, function: work } + - { id: d, type: function, function: work } + - { id: gather, type: join } + - { id: done, type: function, function: gathered } + edges: + - { from: START, to: a } + - { from: a, to: gather } + - { from: START, to: b } + - { from: b, to: gather } + - { from: START, to: c } + - { from: c, to: gather } + - { from: START, to: d } + - { from: d, to: gather } + - { from: gather, to: done } +`)) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, reg) + require.NoError(t, err) + svc, id := newSession(t) + got := outputs(run(t, sys.Root(), userMessage("go"), svc, id)) + require.Equal(t, "gathered", got[len(got)-1]) + return peak.Load() +} + +func TestWorkflowMaxConcurrencyCapsFanOut(t *testing.T) { + require.Equal(t, int64(2), peakFanOut(t, " max_concurrency: 2\n")) + require.Equal(t, int64(1), peakFanOut(t, " max_concurrency: 1\n")) + require.Greater(t, peakFanOut(t, ""), int64(2), "without a cap every branch starts at once") +} diff --git a/go/adk/pkg/declarative/cel.go b/go/adk/pkg/declarative/cel.go new file mode 100644 index 0000000000..83854d5ace --- /dev/null +++ b/go/adk/pkg/declarative/cel.go @@ -0,0 +1,429 @@ +package declarative + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + "strings" + + "github.com/google/cel-go/cel" + celast "github.com/google/cel-go/common/ast" + "github.com/google/cel-go/common/operators" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/common/types/traits" + "github.com/google/cel-go/ext" + "google.golang.org/adk/v2/agent" + "google.golang.org/genai" +) + +// CEL variables. Every scope sees the ADK context of the running agent; each +// scope adds the value it decides on. +const ( + celState = "state" // session state, map(string, dyn) + celSession = "session" // {id, app_name, user_id} + celUser = "user" // {id, content}: content is the text that started the invocation + celInvocation = "invocation" // {id, branch, agent_name} + celInput = "input" // node input (cel nodes) + celVars = "vars" // named values of a cel node + celRequest = "request" // {model, text, contents, system_instruction, tools} (before_model) + celResponse = "response" // {text, partial, turn_complete, error, finish_reason, function_calls} (after_model) + celTool = "tool" // {name, args} and, after the call, {result, error} + celError = "error" // {message, timeout, canceled} (retry.when only) +) + +// celScope selects the variables an expression may use. +type celScope string + +const ( + scopeNode celScope = "node" + scopeBeforeModel celScope = "before_model" + scopeAfterModel celScope = "after_model" + scopeBeforeTool celScope = "before_tool" + scopeAfterTool celScope = "after_tool" + // scopeRetry sees only the error: ADK decides on a retry without the + // agent context. + scopeRetry celScope = "retry" +) + +// celCostLimit bounds the work of one evaluation so that an expression cannot +// stall a run. +const celCostLimit = 1_000_000 + +var celObject = cel.MapType(cel.StringType, cel.DynType) + +var celEnvs = func() map[celScope]*cel.Env { + extra := map[celScope]cel.EnvOption{ + scopeNode: cel.Lib(nodeVars{}), + scopeBeforeModel: cel.Variable(celRequest, celObject), + scopeAfterModel: cel.Variable(celResponse, celObject), + scopeBeforeTool: cel.Variable(celTool, celObject), + scopeAfterTool: cel.Variable(celTool, celObject), + scopeRetry: cel.Variable(celError, celObject), + } + envs := make(map[celScope]*cel.Env, len(extra)) + for scope, opt := range extra { + opts := []cel.EnvOption{opt} + if scope != scopeRetry { + opts = append(opts, + cel.Variable(celState, celObject), + cel.Variable(celSession, celObject), + cel.Variable(celUser, celObject), + cel.Variable(celInvocation, celObject)) + } + env, err := cel.NewEnv(append(opts, + // The cel-go standard extensions: cel.bind locals, strings, + // base64, lists, math, sets, RE2 regex helpers (regex.extract + // returns an optional, hence optional types). + cel.OptionalTypes(), ext.Bindings(), ext.Strings(), ext.Encoders(), + ext.Lists(), ext.Math(), ext.Sets(), ext.Regex(), + helperLibrary, + )...) + if err != nil { + panic(fmt.Sprintf("declarative CEL environment %s: %v", scope, err)) + } + envs[scope] = env + } + return envs +}() + +// nodeVars declares the variables of cel node expressions. +type nodeVars struct{} + +func (nodeVars) CompileOptions() []cel.EnvOption { + return []cel.EnvOption{cel.Variable(celInput, cel.DynType), cel.Variable(celVars, celObject)} +} + +func (nodeVars) ProgramOptions() []cel.ProgramOption { return nil } + +// celResult constrains the static type of an expression. +type celResult int + +const ( + resultAny celResult = iota + resultBool + resultRoutes +) + +// expression is a compiled CEL program. +type expression struct { + source string + program cel.Program + // varRefs are the names read as vars.. + varRefs []string +} + +// compileCEL type-checks source in scope. Dynamically typed results are +// accepted and checked again when evaluated. +func compileCEL(scope celScope, source string, want celResult) (*expression, error) { + env := celEnvs[scope] + ast, issues := env.Compile(source) + if issues != nil && issues.Err() != nil { + return nil, fmt.Errorf("compile CEL %q: %w", source, issues.Err()) + } + out := ast.OutputType() + switch want { + case resultBool: + if !out.IsAssignableType(cel.BoolType) && !out.IsExactType(cel.DynType) { + return nil, fmt.Errorf("CEL %q has type %s, want bool", source, out) + } + case resultRoutes: + if !out.IsAssignableType(cel.StringType) && !out.IsAssignableType(cel.ListType(cel.StringType)) && !out.IsExactType(cel.DynType) { + return nil, fmt.Errorf("CEL %q has type %s, want string or list(string)", source, out) + } + } + program, err := env.Program(ast, cel.CostLimit(celCostLimit)) + if err != nil { + return nil, fmt.Errorf("plan CEL %q: %w", source, err) + } + return &expression{source: source, program: program, varRefs: varRefs(ast.NativeRep().Expr())}, nil +} + +// varRefs returns the field names selected from the vars variable. +func varRefs(e celast.Expr) []string { + var refs []string + celast.PreOrderVisit(e, celast.NewExprVisitor(func(e celast.Expr) { + if e.Kind() != celast.SelectKind { + return + } + sel := e.AsSelect() + if op := sel.Operand(); op.Kind() == celast.IdentKind && op.AsIdent() == celVars { + refs = append(refs, sel.FieldName()) + } + })) + return refs +} + +func (e *expression) eval(vars map[string]any) (any, error) { + val, _, err := e.program.Eval(vars) + if err != nil { + return nil, fmt.Errorf("evaluate CEL %q: %w", e.source, err) + } + return nativeValue(val) +} + +func (e *expression) evalBool(vars map[string]any) (bool, error) { + out, err := e.eval(vars) + if err != nil { + return false, err + } + b, ok := out.(bool) + if !ok { + return false, fmt.Errorf("CEL %q returned %T, want bool", e.source, out) + } + return b, nil +} + +// evalRoutes evaluates a route expression. Empty strings select no route. +func (e *expression) evalRoutes(vars map[string]any) ([]string, error) { + out, err := e.eval(vars) + if err != nil { + return nil, err + } + switch v := out.(type) { + case nil: + return nil, nil + case string: + if v == "" { + return nil, nil + } + return []string{v}, nil + case []any: + routes := make([]string, 0, len(v)) + for _, item := range v { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf("CEL %q returned list item %T, want string", e.source, item) + } + if s != "" { + routes = append(routes, s) + } + } + return routes, nil + } + return nil, fmt.Errorf("CEL %q returned %T, want string or list of strings", e.source, out) +} + +// contextVars binds the ADK context shared by every scope. +func contextVars(ctx agent.ReadonlyContext) map[string]any { + state := map[string]any{} + if ctx == nil { + return map[string]any{celState: state, celSession: map[string]any{}, celUser: map[string]any{}, celInvocation: map[string]any{}} + } + if rs := ctx.ReadonlyState(); rs != nil { + maps.Insert(state, rs.All()) + } + return map[string]any{ + celState: plainValue(state), + celSession: map[string]any{"id": ctx.SessionID(), "app_name": ctx.AppName(), "user_id": ctx.UserID()}, + celUser: map[string]any{"id": ctx.UserID(), "content": contentText(ctx.UserContent())}, + celInvocation: map[string]any{"id": ctx.InvocationID(), "branch": ctx.Branch(), "agent_name": ctx.AgentName()}, + } +} + +// plainValue converts runtime values into JSON-shaped Go values so CEL sees +// maps, lists, strings, int64 and float64 whatever produced them. +func plainValue(v any) any { + switch t := v.(type) { + case nil, string, bool, int64, float64: + return t + case int: + return int64(t) + case *genai.Content: + return contentText(t) + } + raw, err := json.Marshal(v) + if err != nil { + return fmt.Sprint(v) + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var out any + if err := dec.Decode(&out); err != nil { + return fmt.Sprint(v) + } + return normalizeNumbers(out) +} + +func normalizeNumbers(v any) any { + switch t := v.(type) { + case json.Number: + if i, err := t.Int64(); err == nil { + return i + } + f, _ := t.Float64() + return f + case map[string]any: + for k, item := range t { + t[k] = normalizeNumbers(item) + } + case []any: + for i, item := range t { + t[i] = normalizeNumbers(item) + } + } + return v +} + +func contentText(c *genai.Content) string { + if c == nil { + return "" + } + var b strings.Builder + for _, p := range c.Parts { + if p != nil { + b.WriteString(p.Text) + } + } + return b.String() +} + +// nativeValue converts a CEL result into plain Go values. +func nativeValue(val ref.Val) (any, error) { + if types.IsError(val) { + return nil, fmt.Errorf("CEL error: %v", val) + } + switch v := val.(type) { + case types.Null: + return nil, nil + case types.String: + return string(v), nil + case types.Bool: + return bool(v), nil + case types.Int: + return int64(v), nil + case types.Uint: + return int64(v), nil + case types.Double: + return float64(v), nil + case *types.Optional: + // An empty optional, such as a failed regex.extract, is null. + if !v.HasValue() { + return nil, nil + } + return nativeValue(v.GetValue()) + case traits.Mapper: + out := map[string]any{} + it := v.Iterator() + for it.HasNext() == types.True { + key := it.Next() + k, ok := key.(types.String) + if !ok { + return nil, fmt.Errorf("CEL map key %v is not a string", key) + } + item, err := nativeValue(v.Get(key)) + if err != nil { + return nil, err + } + out[string(k)] = item + } + return out, nil + case traits.Lister: + out := []any{} + it := v.Iterator() + for it.HasNext() == types.True { + item, err := nativeValue(it.Next()) + if err != nil { + return nil, err + } + out = append(out, item) + } + return out, nil + } + return val.Value(), nil +} + +// helperLibrary adds the agentgateway CEL helpers, so expressions read the same +// across kagent and agentgateway: +// +// json(string) -> dyn parse JSON text +// toJson(dyn) -> string encode a value as JSON text +// x.with(name, body) bind x to name inside body +// default(a.b[k], fallback) the field or index value, or fallback when missing +var helperLibrary = cel.Lib(helperLib{}) + +type helperLib struct{} + +func (helperLib) LibraryName() string { return "kagent.declarative.helpers" } + +func (helperLib) ProgramOptions() []cel.ProgramOption { return nil } + +func (helperLib) CompileOptions() []cel.EnvOption { + return []cel.EnvOption{ + cel.Macros( + cel.ReceiverMacro("with", 2, withMacro), + cel.GlobalMacro("default", 2, defaultMacro), + ), + cel.Function("toJson", cel.Overload("to_json_dyn", []*cel.Type{cel.DynType}, cel.StringType, + cel.UnaryBinding(func(v ref.Val) ref.Val { + native, err := nativeValue(v) + if err != nil { + return types.NewErr("toJson: %v", err) + } + data, err := json.Marshal(native) + if err != nil { + return types.NewErr("toJson: %v", err) + } + return types.String(data) + }))), + cel.Function("json", cel.Overload("json_string", []*cel.Type{cel.StringType}, cel.DynType, + cel.UnaryBinding(func(v ref.Val) ref.Val { + s, ok := v.(types.String) + if !ok { + return types.MaybeNoSuchOverloadErr(v) + } + dec := json.NewDecoder(strings.NewReader(string(s))) + dec.UseNumber() + var out any + if err := dec.Decode(&out); err != nil { + return types.NewErr("json: %v", err) + } + if dec.More() { + return types.NewErr("json: trailing data") + } + return types.DefaultTypeAdapter.NativeToValue(normalizeNumbers(out)) + }))), + } +} + +// withMacro expands x.with(n, body) like cel.bind(n, x, body). +func withMacro(mef cel.MacroExprFactory, target celast.Expr, args []celast.Expr) (celast.Expr, *cel.Error) { + if args[0].Kind() != celast.IdentKind { + return nil, mef.NewError(args[0].ID(), "with() variable name must be a simple identifier") + } + name := args[0].AsIdent() + return mef.NewComprehension(mef.NewList(), "#unused", name, target, + mef.NewLiteral(types.False), mef.NewIdent(name), args[1]), nil +} + +// defaultMacro expands default(a.b[k], f) to a.?b[?k].orValue(f). +func defaultMacro(mef cel.MacroExprFactory, _ celast.Expr, args []celast.Expr) (celast.Expr, *cel.Error) { + opt, ok := optionalAccess(mef, args[0]) + if !ok { + return nil, mef.NewError(args[0].ID(), "default() needs a field or index access such as a.b or m[k]") + } + return mef.NewMemberCall("orValue", opt, args[1]), nil +} + +// optionalAccess rewrites the field and index accesses of e into their +// optional forms; it reports false when e has none. +func optionalAccess(mef cel.MacroExprFactory, e celast.Expr) (celast.Expr, bool) { + switch e.Kind() { + case celast.SelectKind: + sel := e.AsSelect() + if sel.IsTestOnly() { + return e, false + } + operand, _ := optionalAccess(mef, sel.Operand()) + return mef.NewCall(operators.OptSelect, operand, mef.NewLiteral(types.String(sel.FieldName()))), true + case celast.CallKind: + call := e.AsCall() + if call.FunctionName() != operators.Index || len(call.Args()) != 2 { + return e, false + } + operand, _ := optionalAccess(mef, call.Args()[0]) + return mef.NewCall(operators.OptIndex, operand, call.Args()[1]), true + } + return e, false +} diff --git a/go/adk/pkg/declarative/cel_node.go b/go/adk/pkg/declarative/cel_node.go new file mode 100644 index 0000000000..13298e1c4d --- /dev/null +++ b/go/adk/pkg/declarative/cel_node.go @@ -0,0 +1,155 @@ +package declarative + +import ( + "fmt" + "slices" + + "github.com/google/jsonschema-go/jsonschema" + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/workflow" +) + +// celProgram is a compiled cel node. +type celProgram struct { + vars []namedExpression + output *expression + route *expression + rules []routeRule + delta map[string]*expression + keys []string +} + +type namedExpression struct { + name string + expr *expression +} + +// routeRule is a compiled routes entry; a nil when always matches. +type routeRule struct { + when *expression + route string +} + +func compileCELNode(n *NodeSpec) (*celProgram, error) { + p := &celProgram{delta: map[string]*expression{}} + var err error + for i, v := range n.Vars { + e, err := compileCEL(scopeNode, v.Expr, resultAny) + if err != nil { + return nil, fmt.Errorf("vars[%d]: %w", i, err) + } + p.vars = append(p.vars, namedExpression{name: v.Name, expr: e}) + } + for i, r := range n.Routes { + rule := routeRule{route: r.Route} + if r.When != "" { + if rule.when, err = compileCEL(scopeNode, r.When, resultBool); err != nil { + return nil, fmt.Errorf("routes[%d].when: %w", i, err) + } + } + p.rules = append(p.rules, rule) + } + if n.Output != "" { + if p.output, err = compileCEL(scopeNode, n.Output, resultAny); err != nil { + return nil, fmt.Errorf("output: %w", err) + } + } + if n.Route != "" { + if p.route, err = compileCEL(scopeNode, n.Route, resultRoutes); err != nil { + return nil, fmt.Errorf("route: %w", err) + } + } + for key, src := range n.StateDelta { + if p.delta[key], err = compileCEL(scopeNode, src, resultAny); err != nil { + return nil, fmt.Errorf("state_delta.%s: %w", key, err) + } + p.keys = append(p.keys, key) + } + slices.Sort(p.keys) + return p, nil +} + +// celNode evaluates expressions over the node input and the ADK context. Its +// output defaults to its input, so a node that only routes or writes state +// passes data through unchanged. All expressions see the state as it was +// before the node ran. +func celNode(n *NodeSpec, in, out *jsonschema.Schema, cfg workflow.NodeConfig, render bool) (workflow.Node, error) { + p, err := compileCELNode(n) + if err != nil { + return nil, err + } + body := func(ctx agent.Context, input any) (any, error) { + vars := contextVars(ctx) + vars[celInput] = plainValue(input) + if err := p.evalVars(vars); err != nil { + return nil, err + } + // The node runs concurrently across sessions and branches, so every + // run needs its own err. + var err error + result := input + if p.output != nil { + if result, err = p.output.eval(vars); err != nil { + return nil, err + } + } + var routes []string + if p.route != nil { + if routes, err = p.route.evalRoutes(vars); err != nil { + return nil, err + } + } + if p.rules != nil { + if routes, err = p.matchRoute(vars); err != nil { + return nil, err + } + } + var delta map[string]any + if len(p.keys) > 0 { + delta = make(map[string]any, len(p.keys)) + for _, k := range p.keys { + if delta[k], err = p.delta[k].eval(vars); err != nil { + return nil, fmt.Errorf("state_delta.%s: %w", k, err) + } + } + } + if len(routes) == 0 && delta == nil && (!render || result == nil) { + return result, nil + } + ev := resultEvent(ctx, result, routes, render) + ev.Actions.StateDelta = delta + return ev, nil + } + return workflow.NewFunctionNodeWithSchema[any, any](n.ID, body, in, out, cfg) +} + +// evalVars evaluates the vars in order into vars["vars"]. +func (p *celProgram) evalVars(vars map[string]any) error { + values := make(map[string]any, len(p.vars)) + vars[celVars] = values + for _, v := range p.vars { + out, err := v.expr.eval(vars) + if err != nil { + return fmt.Errorf("vars.%s: %w", v.name, err) + } + values[v.name] = out + } + return nil +} + +// matchRoute returns the route of the first matching rule, or none. +func (p *celProgram) matchRoute(vars map[string]any) ([]string, error) { + for i, r := range p.rules { + if r.when == nil { + return []string{r.route}, nil + } + ok, err := r.when.evalBool(vars) + if err != nil { + return nil, fmt.Errorf("routes[%d].when: %w", i, err) + } + if ok { + return []string{r.route}, nil + } + } + return nil, nil +} diff --git a/go/adk/pkg/declarative/cel_test.go b/go/adk/pkg/declarative/cel_test.go new file mode 100644 index 0000000000..dd26998cf2 --- /dev/null +++ b/go/adk/pkg/declarative/cel_test.go @@ -0,0 +1,126 @@ +package declarative + +import ( + "strconv" + "sync" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/genai" +) + +func TestCELVarsRoutesAndHelpers(t *testing.T) { + sys := buildDoc(t, NewRegistry(), head+` +agents: + - name: vars_wf + agent_class: Workflow + nodes: + - id: classify + type: cel + vars: + - { name: text, expr: input.lowerAscii() } + - { name: refund, expr: "vars.text.contains('refund')" } + routes: + - { route: URGENT, when: "vars.text.contains('outage')" } + - { route: BILLING, when: vars.refund } + output: | + json('{"user": {"name": "ann"}}').with(doc, [ + vars.text, + default(doc.user.name, 'anon'), + default(doc.user.tier, 'free'), + default(state['missing'], 'none'), + toJson({'refund': vars.refund}), + ].join(' ')) + - { id: urgent, type: cel, output: "'urgent: ' + input" } + - { id: billing, type: cel, output: "'billing: ' + input" } + - { id: other, type: cel, output: "'other: ' + input" } + edges: + - { from: START, to: classify } + - { from: classify, to: urgent, route: URGENT } + - { from: classify, to: billing, route: BILLING } + - { from: classify, to: other, route: DEFAULT } +`) + for in, want := range map[string]string{ + "Outage and REFUND": `urgent: outage and refund ann free none {"refund":true}`, + "Refund please": `billing: refund please ann free none {"refund":true}`, + "Hello": `other: hello ann free none {"refund":false}`, + } { + svc, id := newSession(t) + reply := replyText(run(t, sys.Root(), genai.NewContentFromText(in, genai.RoleUser), svc, id)) + require.Equal(t, want, reply, in) + } +} + +// TestCELNodeRunsConcurrently runs one built cel node from many sessions at +// once. Each run must see only its own evaluation error; run it with -race. +func TestCELNodeRunsConcurrently(t *testing.T) { + sys := buildDoc(t, NewRegistry(), head+` +agents: + - name: double + agent_class: Workflow + nodes: + - { id: double, type: cel, output: "string(int(input) * 2)" } + edges: + - { from: START, to: double } +`) + const runs = 32 + errs := make([]error, runs) + replies := make([]string, runs) + var wg sync.WaitGroup + for i := range runs { + wg.Go(func() { + input := strconv.Itoa(i) + if i%2 == 1 { + input = "not a number" + } + svc, id := newSession(t) + events, err := tryRun(t, sys, svc, id, input) + errs[i], replies[i] = err, replyText(events) + }) + } + wg.Wait() + for i := range runs { + if i%2 == 1 { + require.Error(t, errs[i], "run %d", i) + continue + } + require.NoError(t, errs[i], "run %d", i) + require.Equal(t, strconv.Itoa(i*2), replies[i], "run %d", i) + } +} + +func TestCELVarsAndRoutesValidation(t *testing.T) { + reg := NewRegistry() + for _, tc := range []struct{ node, want string }{ + {`{ id: c, type: cel, vars: [{ name: "1x", expr: "1" }], output: "'x'" }`, "must be an identifier"}, + {`{ id: c, type: cel, vars: [{ name: a, expr: "1" }, { name: a, expr: "2" }], output: "'x'" }`, `duplicate var "a"`}, + {`{ id: c, type: cel, vars: [{ name: a, expr: "1 +" }], output: "'x'" }`, "vars[0].expr"}, + {`{ id: c, type: cel, route: "'A'", routes: [{ route: B }] }`, "route and routes are mutually exclusive"}, + {`{ id: c, type: cel, routes: [{ when: "true" }] }`, "routes[0].route: required"}, + {`{ id: c, type: cel, routes: [{ route: A }, { route: B, when: "true" }] }`, "only the last rule may omit when"}, + {`{ id: c, type: cel, routes: [{ route: A, when: "'yes'" }] }`, "routes[0].when"}, + {`{ id: c, type: cel, vars: [{ name: a, expr: "vars.b" }, { name: b, expr: "1" }], output: "'x'" }`, "vars.b is not declared before"}, + {`{ id: c, type: cel, vars: [{ name: a, expr: "1" }], output: "vars.nope" }`, "vars.nope is not declared"}, + {`{ id: c, type: cel, vars: [{ name: a, expr: "1" }] }`, "at least one of output, route, routes, state_delta"}, + {`{ id: c, type: cel, output: "default(input, 'x')" }`, "default() needs a field or index access"}, + {`{ id: c, type: cel, output: "input.with('n', 1)" }`, "with() variable name must be a simple identifier"}, + } { + _, err := reg.Parse([]byte(head + ` +agents: + - name: w + agent_class: Workflow + nodes: [` + tc.node + `] + edges: [{ from: START, to: c }]`)) + require.ErrorContains(t, err, tc.want, tc.node) + } +} + +func TestEdgeToENDExplainsTerminals(t *testing.T) { + _, err := NewRegistry().Parse([]byte(head + ` +agents: + - name: w + agent_class: Workflow + nodes: [{ id: c, type: cel, output: "'x'" }] + edges: [{ from: START, to: c }, { from: c, to: END }]`)) + require.ErrorContains(t, err, "there is no END node") +} diff --git a/go/adk/pkg/declarative/doc.go b/go/adk/pkg/declarative/doc.go new file mode 100644 index 0000000000..be4f69378c --- /dev/null +++ b/go/adk/pkg/declarative/doc.go @@ -0,0 +1,19 @@ +// Package declarative compiles an adk.declarative/v0.2 AgentSystem document +// into native Google ADK Go agents. +// +// The package is split along the repository's semantic/pragmatic boundary: +// +// - spec.go and load.go define and decode the document (data only). +// - validate.go checks the document without touching any runtime +// dependency. Every violation is reported with its field path. A Registry +// extends the same pass with a check that every name resolves. +// - registry.go holds the Go values a document may reference by name +// (functions, tools, models, node types). +// - build.go and nodes.go resolve a validated document against a Registry +// and produce ADK workflow nodes, edges, and agents, including the +// Sequential, Parallel and Loop agents. +// +// A document never carries code. Behavior is attached by name through the +// Registry, and new node types can be added with Registry.RegisterNodeType +// without modifying this package. +package declarative diff --git a/go/adk/pkg/declarative/harness.go b/go/adk/pkg/declarative/harness.go new file mode 100644 index 0000000000..d6d606b974 --- /dev/null +++ b/go/adk/pkg/declarative/harness.go @@ -0,0 +1,45 @@ +package declarative + +import ( + "fmt" + + "github.com/kagent-dev/kagent/go/adk/pkg/tools" +) + +// HarnessTools are the basic tools of the kagent harness that +// RegisterHarnessTools adds to the local tool library: read and write a file, +// list a directory, and run a shell command. Relative paths and the shell's +// working directory are the workspace; isolation comes from the sandbox the +// workflow runs in. +var HarnessTools = []string{"read_file", "write_file", "list_files", "bash"} + +// HarnessConfig selects the workspace of the harness tools. +type HarnessConfig struct { + // Workspace is the working directory; empty uses the actor workspace. + Workspace string + // DisableShell leaves out the bash tool. + DisableShell bool +} + +// RegisterHarnessTools registers the HarnessTools so that documents can use +// them as tool nodes (type: tool, tool: write_file) and in LLM tool lists. +// They are opt-in: a registry without them cannot touch the filesystem. +func (r *Registry) RegisterHarnessTools(cfg HarnessConfig) error { + builtin, err := tools.NewBuiltinTools(tools.BuiltinToolsConfig{Workspace: cfg.Workspace, DisableBash: cfg.DisableShell}) + if err != nil { + return fmt.Errorf("failed to create harness tools: %w", err) + } + wanted := map[string]bool{} + for _, name := range HarnessTools { + wanted[name] = true + } + for _, t := range builtin { + if !wanted[t.Name()] { + continue + } + if err := r.RegisterTool(t); err != nil { + return err + } + } + return nil +} diff --git a/go/adk/pkg/declarative/harness_test.go b/go/adk/pkg/declarative/harness_test.go new file mode 100644 index 0000000000..21369df41d --- /dev/null +++ b/go/adk/pkg/declarative/harness_test.go @@ -0,0 +1,76 @@ +package declarative + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/genai" +) + +// TestHarnessToolsAsWorkflowNodes chains the harness tools as tool nodes; CEL +// builds each call's arguments from the previous result and the context. +func TestHarnessToolsAsWorkflowNodes(t *testing.T) { + workspace := t.TempDir() + reg := NewRegistry() + require.NoError(t, reg.RegisterHarnessTools(HarnessConfig{Workspace: workspace})) + sys := buildDoc(t, reg, head+` +agents: + - name: files + agent_class: Workflow + nodes: + - id: note + type: cel + output: '{"file_path": "notes/" + session.user_id + ".txt", "content": user.content.upperAscii()}' + - { id: write, type: tool, tool: write_file } + - { id: read_args, type: cel, output: '{"file_path": "notes/user.txt"}' } + - { id: read, type: tool, tool: read_file } + - { id: list_args, type: cel, output: '{"path": "notes"}', state_delta: { content: input.result } } + - { id: list, type: tool, tool: list_files } + - { id: shell_args, type: cel, output: '{"command": "wc -c < notes/user.txt | tr -d \" \""}', state_delta: { listing: input.result } } + - { id: shell, type: tool, tool: bash } + - id: report + type: cel + output: | + 'read=%s listed=%s bytes=%s'.format([ + state.content.contains('HELLO FILES') ? 'ok' : state.content, + state.listing.contains('user.txt') ? 'ok' : state.listing, + input.result.trim()]) + edges: + - { from: START, to: note } + - { from: note, to: write } + - { from: write, to: read_args } + - { from: read_args, to: read } + - { from: read, to: list_args } + - { from: list_args, to: list } + - { from: list, to: shell_args } + - { from: shell_args, to: shell } + - { from: shell, to: report } +`) + svc, id := newSession(t) + reply := replyText(run(t, sys.Root(), genai.NewContentFromText("hello files", genai.RoleUser), svc, id)) + require.Contains(t, reply, "read=ok listed=ok bytes=11") + data, err := os.ReadFile(filepath.Join(workspace, "notes", "user.txt")) + require.NoError(t, err) + require.Equal(t, "HELLO FILES", string(data)) +} + +func TestHarnessToolsAreOptIn(t *testing.T) { + _, err := NewRegistry().Parse([]byte(head + ` +agents: + - name: w + agent_class: Workflow + nodes: [{ id: t, type: tool, tool: bash }] + edges: [{ from: START, to: t }] +`)) + require.ErrorContains(t, err, `"bash"`) + + reg := NewRegistry() + require.NoError(t, reg.RegisterHarnessTools(HarnessConfig{Workspace: t.TempDir(), DisableShell: true})) + require.True(t, reg.hasTool("read_file")) + require.True(t, reg.hasTool("write_file")) + require.True(t, reg.hasTool("list_files")) + require.False(t, reg.hasTool("bash")) + require.False(t, reg.hasTool("edit_file"), "only the basic harness tools are registered") +} diff --git a/go/adk/pkg/declarative/helpers_test.go b/go/adk/pkg/declarative/helpers_test.go new file mode 100644 index 0000000000..993d29c2ce --- /dev/null +++ b/go/adk/pkg/declarative/helpers_test.go @@ -0,0 +1,137 @@ +package declarative + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/memory" + "google.golang.org/adk/v2/runner" + "google.golang.org/adk/v2/session" + "google.golang.org/adk/v2/tool/functiontool" + "google.golang.org/genai" +) + +func userText(ctx agent.Context) string { + var sb strings.Builder + if uc := ctx.UserContent(); uc != nil { + for _, p := range uc.Parts { + if p != nil { + sb.WriteString(p.Text) + } + } + } + return sb.String() +} + +func constant(v string) Func { + return func(agent.Context, any) (Result, error) { return Output(v), nil } +} + +// routeByText shapes the output for the branch it selects, because each +// successor expects a different input type. +func routeByText(ctx agent.Context, _ any) (Result, error) { + text := userText(ctx) + switch { + case strings.HasPrefix(text, "bug"): + words := []any{} + for w := range strings.FieldsSeq(text) { + words = append(words, w) + } + return Route(words, "BUG"), nil + case strings.HasPrefix(text, "bill"): + return Route(map[string]any{"customer_id": "c-42"}, "BILLING"), nil + case strings.HasPrefix(text, "help"): + return Route(text, "SUPPORT"), nil + case strings.HasPrefix(text, "other"): + return Route(text, "OTHER"), nil + } + return Route(text, "UNMATCHED"), nil +} + +func testRegistry(t testing.TB) *Registry { + t.Helper() + reg := NewRegistry() + for name, fn := range map[string]Func{ + "branch_a": constant("a"), + "branch_b": constant("b"), + "branch_c": constant("c"), + "route_by_text": routeByText, + "prefix": func(_ agent.Context, in any) (Result, error) { return Output(fmt.Sprintf("item:%v", in)), nil }, + "plan": func(_ agent.Context, in any) (Result, error) { return Output(fmt.Sprintf("plan(%v)", in)), nil }, + "execute": func(_ agent.Context, in any) (Result, error) { return Output(fmt.Sprintf("exec(%v)", in)), nil }, + "summarize_join": func(_ agent.Context, in any) (Result, error) { return Output(fmt.Sprintf("summary:%v", in)), nil }, + "critic_step": func(_ agent.Context, in any) (Result, error) { return Output(fmt.Sprintf("critiqued(%v)", in)), nil }, + } { + require.NoError(t, reg.RegisterFunction(name, fn)) + } + require.NoError(t, reg.RegisterDynamic("critique", func(_ agent.Context, in any, env *DynamicEnv) (any, error) { + for { + if _, err := env.RunChild("critic", in); errors.Is(err, ErrIterationBudget) { + return fmt.Sprintf("%v after %d runs", in, env.Runs()), nil + } else if err != nil { + return nil, err + } + } + })) + type lookupArgs struct { + CustomerID string `json:"customer_id"` + } + lookup, err := functiontool.New(functiontool.Config{Name: "lookup_customer", Description: "Looks up a customer."}, + func(_ agent.Context, a lookupArgs) (map[string]any, error) { + return map[string]any{"customer": a.CustomerID, "plan": "pro"}, nil + }) + require.NoError(t, err) + require.NoError(t, reg.RegisterTool(lookup)) + return reg +} + +func loadShowcase(t testing.TB) *Spec { + t.Helper() + data, err := os.ReadFile("testdata/showcase.yaml") + require.NoError(t, err) + spec, err := testRegistry(t).Parse(data) + require.NoError(t, err) + return spec +} + +// run drives root once and returns every event. +func run(t testing.TB, root agent.Agent, content *genai.Content, sessions session.Service, sessionID string) []*session.Event { + t.Helper() + r, err := runner.New(runner.Config{AppName: "test", Agent: root, SessionService: sessions, MemoryService: memory.InMemoryService()}) + require.NoError(t, err) + var events []*session.Event + for ev, err := range r.Run(context.Background(), "user", sessionID, content, agent.RunConfig{}) { + require.NoError(t, err) + events = append(events, ev) + } + return events +} + +func newSession(t testing.TB) (session.Service, string) { + t.Helper() + svc := session.InMemoryService() + resp, err := svc.Create(context.Background(), &session.CreateRequest{AppName: "test", UserID: "user"}) + require.NoError(t, err) + return svc, resp.Session.ID() +} + +// tryRun runs the root agent once and returns the first error instead of failing. +func tryRun(t testing.TB, sys *System, sessions session.Service, sessionID, input string) ([]*session.Event, error) { + t.Helper() + r, err := runner.New(runner.Config{AppName: "test", Agent: sys.Root(), SessionService: sessions, MemoryService: memory.InMemoryService()}) + require.NoError(t, err) + var events []*session.Event + for ev, err := range r.Run(context.Background(), "user", sessionID, genai.NewContentFromText(input, genai.RoleUser), agent.RunConfig{}) { + if err != nil { + return events, err + } + events = append(events, ev) + } + return events, nil +} diff --git a/go/adk/pkg/declarative/hooks.go b/go/adk/pkg/declarative/hooks.go new file mode 100644 index 0000000000..c4aa5e6386 --- /dev/null +++ b/go/adk/pkg/declarative/hooks.go @@ -0,0 +1,328 @@ +package declarative + +import ( + "fmt" + "slices" + + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/agent/llmagent" + adkmodel "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/tool" + "google.golang.org/genai" +) + +// HookPoint names an ADK callback point that hooks can guard. +type HookPoint string + +const ( + HookBeforeModel HookPoint = "before_model" + HookAfterModel HookPoint = "after_model" + HookBeforeTool HookPoint = "before_tool" + HookAfterTool HookPoint = "after_tool" +) + +// Verdict is the outcome of one hook rule. +type Verdict int + +const ( + // Continue defers to the next rule. A chain where every rule continues + // lets the call run. + Continue Verdict = iota + // Accept lets the call run and skips the remaining rules. + Accept + // Reject blocks the call with Decision.Message. + Reject +) + +// Decision is what a Hook returns. +type Decision struct { + Verdict Verdict + // Message explains a rejection to the model or the user. + Message string +} + +// HookEvent is the call a Hook decides on. Only the fields of its Point are +// set. +type HookEvent struct { + Point HookPoint + // Request is the model request (before_model). + Request *adkmodel.LLMRequest + // Response and ModelError are the model result (after_model). + Response *adkmodel.LLMResponse + ModelError error + // Tool and Args describe a tool call (before_tool, after_tool). + Tool tool.Tool + Args map[string]any + // Result and ToolError are the tool result (after_tool). + Result map[string]any + ToolError error +} + +// Hook decides in Go whether a model or tool call may proceed. Hooks run on +// the agent's hot path; keep them fast and free of side effects that a retry +// would repeat. +type Hook func(ctx agent.Context, ev *HookEvent) (Decision, error) + +// hookMetadataKey marks responses and tool results produced by a rejection, +// so clients and tests can tell them from real model or tool output. +const hookMetadataKey = "kagent_hook" + +// hookRule is one compiled HookSpec. +type hookRule struct { + when *expression + action HookAction + message string + fn Hook +} + +// hookChain evaluates rules in document order. +type hookChain []hookRule + +// decide returns the first accept or reject. vars binds the CEL variables of +// the point and is called at most once. +func (c hookChain) decide(ctx agent.Context, ev *HookEvent, vars func() map[string]any) (Decision, error) { + var bound map[string]any + for _, r := range c { + if r.when != nil { + if bound == nil { + bound = vars() + } + ok, err := r.when.evalBool(bound) + if err != nil { + return Decision{}, fmt.Errorf("%s hook: %w", ev.Point, err) + } + if !ok { + continue + } + } + if r.fn != nil { + d, err := r.fn(ctx, ev) + if err != nil { + return Decision{}, fmt.Errorf("%s hook: %w", ev.Point, err) + } + if d.Verdict == Continue { + continue + } + return d, nil + } + if r.action == HookReject { + return Decision{Verdict: Reject, Message: r.message}, nil + } + return Decision{Verdict: Accept}, nil + } + return Decision{Verdict: Continue}, nil +} + +// compileHooks compiles the rules of one point. Validate already checked +// them, so errors here mean the registry changed after validation. +func (r *Registry) compileHooks(scope celScope, specs []HookSpec) (hookChain, error) { + chain := make(hookChain, 0, len(specs)) + for i, s := range specs { + rule := hookRule{action: s.Action, message: s.Message} + if s.When != "" { + when, err := compileCEL(scope, s.When, resultBool) + if err != nil { + return nil, fmt.Errorf("%s[%d].when: %w", scope, i, err) + } + rule.when = when + } + if s.Function != "" { + fn, ok := r.hooks[s.Function] + if !ok { + return nil, fmt.Errorf("%s[%d]: hook %q is not registered", scope, i, s.Function) + } + rule.fn = fn + } + chain = append(chain, rule) + } + return chain, nil +} + +// applyCallbacks installs one ADK callback per declared point. +func (r *Registry) applyCallbacks(cfg *llmagent.Config, cb *CallbacksSpec) error { + if cb == nil { + return nil + } + if len(cb.BeforeModel) > 0 { + chain, err := r.compileHooks(scopeBeforeModel, cb.BeforeModel) + if err != nil { + return err + } + cfg.BeforeModelCallbacks = append(cfg.BeforeModelCallbacks, beforeModel(chain)) + } + if len(cb.AfterModel) > 0 { + chain, err := r.compileHooks(scopeAfterModel, cb.AfterModel) + if err != nil { + return err + } + cfg.AfterModelCallbacks = append(cfg.AfterModelCallbacks, afterModel(chain)) + } + if len(cb.BeforeTool) > 0 { + chain, err := r.compileHooks(scopeBeforeTool, cb.BeforeTool) + if err != nil { + return err + } + cfg.BeforeToolCallbacks = append(cfg.BeforeToolCallbacks, beforeTool(chain)) + } + if len(cb.AfterTool) > 0 { + chain, err := r.compileHooks(scopeAfterTool, cb.AfterTool) + if err != nil { + return err + } + cfg.AfterToolCallbacks = append(cfg.AfterToolCallbacks, afterTool(chain)) + } + return nil +} + +// beforeModel answers a rejected request with the rejection message instead +// of calling the model. +func beforeModel(chain hookChain) llmagent.BeforeModelCallback { + return func(ctx agent.Context, req *adkmodel.LLMRequest) (*adkmodel.LLMResponse, error) { + ev := &HookEvent{Point: HookBeforeModel, Request: req} + d, err := chain.decide(ctx, ev, func() map[string]any { + vars := contextVars(ctx) + vars[celRequest] = requestVars(req) + return vars + }) + if err != nil || d.Verdict != Reject { + return nil, err + } + return rejectedResponse(HookBeforeModel, d.Message), nil + } +} + +// afterModel replaces a rejected response with the rejection message. +func afterModel(chain hookChain) llmagent.AfterModelCallback { + return func(ctx agent.Context, resp *adkmodel.LLMResponse, respErr error) (*adkmodel.LLMResponse, error) { + ev := &HookEvent{Point: HookAfterModel, Response: resp, ModelError: respErr} + d, err := chain.decide(ctx, ev, func() map[string]any { + vars := contextVars(ctx) + vars[celResponse] = responseVars(resp, respErr) + return vars + }) + if err != nil || d.Verdict != Reject { + return nil, err + } + return rejectedResponse(HookAfterModel, d.Message), nil + } +} + +// beforeTool skips a rejected tool call and returns the rejection to the +// model as the tool result, so the model can recover. +func beforeTool(chain hookChain) llmagent.BeforeToolCallback { + return func(ctx agent.Context, t tool.Tool, args map[string]any) (map[string]any, error) { + ev := &HookEvent{Point: HookBeforeTool, Tool: t, Args: args} + d, err := chain.decide(ctx, ev, func() map[string]any { + vars := contextVars(ctx) + vars[celTool] = toolVars(t, args, nil, nil) + return vars + }) + if err != nil || d.Verdict != Reject { + return nil, err + } + return rejectedToolResult(HookBeforeTool, d.Message), nil + } +} + +// afterTool replaces a rejected tool result before the model sees it. +func afterTool(chain hookChain) llmagent.AfterToolCallback { + return func(ctx agent.Context, t tool.Tool, args, result map[string]any, toolErr error) (map[string]any, error) { + ev := &HookEvent{Point: HookAfterTool, Tool: t, Args: args, Result: result, ToolError: toolErr} + d, err := chain.decide(ctx, ev, func() map[string]any { + vars := contextVars(ctx) + vars[celTool] = toolVars(t, args, result, toolErr) + return vars + }) + if err != nil || d.Verdict != Reject { + return nil, err + } + return rejectedToolResult(HookAfterTool, d.Message), nil + } +} + +func rejectedResponse(point HookPoint, message string) *adkmodel.LLMResponse { + return &adkmodel.LLMResponse{ + Content: genai.NewContentFromText(message, genai.RoleModel), + TurnComplete: true, + CustomMetadata: map[string]any{hookMetadataKey: map[string]any{"point": string(point), "verdict": "reject"}}, + } +} + +func rejectedToolResult(point HookPoint, message string) map[string]any { + return map[string]any{"error": message, hookMetadataKey: map[string]any{"point": string(point), "verdict": "reject"}} +} + +func requestVars(req *adkmodel.LLMRequest) map[string]any { + out := map[string]any{"model": "", "text": "", "contents": []any{}, "system_instruction": "", "tools": []any{}} + if req == nil { + return out + } + out["model"] = req.Model + out["text"] = lastUserText(req) + contents := make([]any, 0, len(req.Contents)) + for _, c := range req.Contents { + if c != nil { + contents = append(contents, map[string]any{"role": c.Role, "text": contentText(c)}) + } + } + out["contents"] = contents + if req.Config != nil { + out["system_instruction"] = contentText(req.Config.SystemInstruction) + } + names := make([]string, 0, len(req.Tools)) + for name := range req.Tools { + names = append(names, name) + } + slices.Sort(names) + tools := make([]any, len(names)) + for i, n := range names { + tools[i] = n + } + out["tools"] = tools + return out +} + +func responseVars(resp *adkmodel.LLMResponse, respErr error) map[string]any { + out := map[string]any{"text": "", "partial": false, "turn_complete": false, "error": "", "finish_reason": "", "function_calls": []any{}} + if respErr != nil { + out["error"] = respErr.Error() + } + if resp == nil { + return out + } + out["text"] = contentText(resp.Content) + out["partial"] = resp.Partial + out["turn_complete"] = resp.TurnComplete + out["finish_reason"] = string(resp.FinishReason) + if resp.ErrorMessage != "" && respErr == nil { + out["error"] = resp.ErrorMessage + } + calls := []any{} + if resp.Content != nil { + for _, p := range resp.Content.Parts { + if p != nil && p.FunctionCall != nil { + calls = append(calls, p.FunctionCall.Name) + } + } + } + out["function_calls"] = calls + return out +} + +func toolVars(t tool.Tool, args, result map[string]any, toolErr error) map[string]any { + out := map[string]any{"name": "", "args": plainValue(nonNilMap(args)), "result": plainValue(nonNilMap(result)), "error": ""} + if t != nil { + out["name"] = t.Name() + } + if toolErr != nil { + out["error"] = toolErr.Error() + } + return out +} + +func nonNilMap(m map[string]any) map[string]any { + if m == nil { + return map[string]any{} + } + return m +} diff --git a/go/adk/pkg/declarative/isolation_test.go b/go/adk/pkg/declarative/isolation_test.go new file mode 100644 index 0000000000..24c1384009 --- /dev/null +++ b/go/adk/pkg/declarative/isolation_test.go @@ -0,0 +1,370 @@ +package declarative + +import ( + "context" + "fmt" + "iter" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/adk/v2/agent" + adkmodel "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/session" + "google.golang.org/genai" +) + +// probeModel replies with a fixed marker and records what each call was shown. +// When after is set the call waits for that probe's first reply, so a reader +// that is not isolated would have the other agent's reply in its history. +type probeModel struct { + name string + after *probeModel + done chan struct{} + once sync.Once + + mu sync.Mutex + seen []string +} + +func newProbe(name string, after *probeModel) *probeModel { + return &probeModel{name: name, after: after, done: make(chan struct{})} +} + +func (p *probeModel) marker() string { return "REPLY-" + strings.ToUpper(p.name) } +func (p *probeModel) Name() string { return p.name } + +func (p *probeModel) GenerateContent(_ context.Context, req *adkmodel.LLMRequest, _ bool) iter.Seq2[*adkmodel.LLMResponse, error] { + return func(yield func(*adkmodel.LLMResponse, error) bool) { + if p.after != nil { + select { + case <-p.after.done: + case <-time.After(2 * time.Second): + } + } + p.mu.Lock() + p.seen = append(p.seen, promptText(req)) + p.mu.Unlock() + p.once.Do(func() { close(p.done) }) + yield(&adkmodel.LLMResponse{Content: genai.NewContentFromText(p.marker(), genai.RoleModel), TurnComplete: true}, nil) + } +} + +func promptText(req *adkmodel.LLMRequest) string { + var sb strings.Builder + for _, c := range req.Contents { + for _, part := range c.Parts { + if part != nil { + sb.WriteString(part.Text + "\n") + } + } + } + return sb.String() +} + +func (p *probeModel) calls() []string { + p.mu.Lock() + defer p.mu.Unlock() + return append([]string(nil), p.seen...) +} + +// sawAnyOf reports whether any call was shown text containing marker. +func (p *probeModel) sawAnyOf(marker string) bool { + for _, s := range p.calls() { + if strings.Contains(s, marker) { + return true + } + } + return false +} + +func buildWith(t *testing.T, doc string, setup func(*Registry), probes ...*probeModel) *System { + t.Helper() + reg := testRegistry(t) + for _, p := range probes { + require.NoError(t, reg.RegisterModel(p.name, p)) + } + if setup != nil { + setup(reg) + } + spec, err := reg.Parse([]byte(doc)) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, reg) + require.NoError(t, err) + return sys +} + +func runOnce(t *testing.T, sys *System, input string) { + t.Helper() + svc, id := newSession(t) + run(t, sys.Root(), userMessage(input), svc, id) +} + +const ( + docHead = "apiVersion: adk.declarative/v0.2\nkind: AgentSystem\nagents:\n" + agentA = " - { name: agent_a, agent_class: LlmAgent, model: probe_a, mode: chat, instruction: A }\n" + agentB = " - { name: agent_b, agent_class: LlmAgent, model: probe_b, mode: chat, instruction: B }\n" +) + +// TestIsolationLevels records, for each way of running two agents, whether the +// second one is shown the first one's reply. It documents the ADK defaults +// the isolation controls build on, so a change upstream fails here. +func TestIsolationLevels(t *testing.T) { + dynamicDoc := func(isolation string) string { + return docHead + ` - name: wf + agent_class: Workflow + nodes: + - { id: orchestrate, type: dynamic, function: two_children, children: [child_a, child_b]` + isolation + ` } + - { id: child_a, type: agent, agent: agent_a } + - { id: child_b, type: agent, agent: agent_b } + - { id: end, type: function, function: summarize_join } + edges: + - { from: START, to: orchestrate } + - { from: orchestrate, to: end } +` + agentA + agentB + } + twoChildren := func(reg *Registry) { + require.NoError(t, reg.RegisterDynamic("two_children", func(_ agent.Context, in any, env *DynamicEnv) (any, error) { + if _, err := env.RunChild("child_a", in); err != nil { + return nil, err + } + return env.RunChild("child_b", in) + })) + } + + tests := []struct { + name string + doc string + setup func(*Registry) + bSeesA bool + }{ + { + name: "static fan-out is branch-isolated", + doc: docHead + ` - name: wf + agent_class: Workflow + nodes: + - { id: na, type: agent, agent: agent_a } + - { id: nb, type: agent, agent: agent_b } + - { id: gather, type: join } + - { id: end, type: function, function: summarize_join } + edges: + - { from: START, to: na } + - { from: START, to: nb } + - { from: na, to: gather } + - { from: nb, to: gather } + - { from: gather, to: end } +` + agentA + agentB, + bSeesA: false, + }, + { + name: "ParallelAgent is branch-isolated", + doc: docHead + ` - { name: panel, agent_class: ParallelAgent, sub_agents: [agent_a, agent_b] } +` + strings.ReplaceAll(agentA+agentB, "mode: chat, ", ""), + bSeesA: false, + }, + { + name: "SequentialAgent shares the conversation", + doc: docHead + ` - { name: seq, agent_class: SequentialAgent, sub_agents: [agent_a, agent_b] } +` + strings.ReplaceAll(agentA+agentB, "mode: chat, ", ""), + bSeesA: true, + }, + {name: "dynamic children share the conversation by default", doc: dynamicDoc(""), setup: twoChildren, bSeesA: true}, + {name: "dynamic children with isolation shared", doc: dynamicDoc(", isolation: shared"), setup: twoChildren, bSeesA: true}, + {name: "dynamic children with isolation branch", doc: dynamicDoc(", isolation: branch"), setup: twoChildren, bSeesA: false}, + {name: "dynamic children with isolation scope", doc: dynamicDoc(", isolation: scope"), setup: twoChildren, bSeesA: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := newProbe("probe_a", nil) + b := newProbe("probe_b", a) + runOnce(t, buildWith(t, tt.doc, tt.setup, a, b), "start") + require.NotEmpty(t, b.calls(), "the second agent must run") + require.Equal(t, tt.bSeesA, b.sawAnyOf(a.marker()), "second agent saw: %q", b.calls()) + }) + } +} + +func TestParallelWorkerItemsAreBranchIsolated(t *testing.T) { + a := newProbe("probe_a", nil) + sys := buildWith(t, docHead+` - name: wf + agent_class: Workflow + nodes: + - { id: items, type: function, function: two_items } + - { id: each, type: parallel_worker, agent: agent_a, max_concurrency: 1 } + - { id: end, type: function, function: summarize_join } + edges: + - { from: START, to: items } + - { from: items, to: each } + - { from: each, to: end } +`+agentA, func(reg *Registry) { + require.NoError(t, reg.RegisterFunction("two_items", func(agent.Context, any) (Result, error) { + return Output([]any{"one", "two"}), nil + })) + }, a) + runOnce(t, sys, "start") + calls := a.calls() + require.Len(t, calls, 2) + require.NotContains(t, calls[1], a.marker(), "an item must not see the previous item's reply") +} + +// scriptedCaller is a parent model that calls the named tool once per request +// and then answers with what the tool returned. +type scriptedCaller struct { + tool string + requests []string +} + +func (m *scriptedCaller) Name() string { return "scripted_caller" } + +func (m *scriptedCaller) GenerateContent(_ context.Context, req *adkmodel.LLMRequest, _ bool) iter.Seq2[*adkmodel.LLMResponse, error] { + return func(yield func(*adkmodel.LLMResponse, error) bool) { + var results []string + for _, c := range req.Contents { + for _, part := range c.Parts { + if part != nil && part.FunctionResponse != nil { + results = append(results, fmt.Sprint(part.FunctionResponse.Response)) + } + } + } + if len(results) > 0 { + yield(&adkmodel.LLMResponse{Content: genai.NewContentFromText("tool results: "+strings.Join(results, "; "), genai.RoleModel), TurnComplete: true}, nil) + return + } + parts := make([]*genai.Part, len(m.requests)) + for i, request := range m.requests { + parts[i] = &genai.Part{FunctionCall: &genai.FunctionCall{ + ID: fmt.Sprintf("call-%d", i), Name: m.tool, Args: map[string]any{"request": request}, + }} + } + yield(&adkmodel.LLMResponse{Content: &genai.Content{Role: genai.RoleModel, Parts: parts}, TurnComplete: true}, nil) + } +} + +const agentToolDoc = docHead + ` - name: parent + agent_class: LlmAgent + model: scripted_caller + instruction: Delegate. + agent_tools: [child] + - { name: child, agent_class: LlmAgent, model: probe_child, instruction: Do the task. } +` + +func TestAgentToolRunsInASessionOfItsOwn(t *testing.T) { + child := newProbe("probe_child", nil) + caller := &scriptedCaller{tool: "child", requests: []string{"task-1"}} + sys := buildWith(t, agentToolDoc, func(reg *Registry) { require.NoError(t, reg.RegisterModel("scripted_caller", caller)) }, child) + runOnce(t, sys, "the parent's private conversation") + + calls := child.calls() + require.Len(t, calls, 1) + require.Contains(t, calls[0], "task-1") + require.NotContains(t, calls[0], "private conversation", "the tool must not be shown the caller's history") +} + +func TestParallelAgentToolCallsDoNotShareASession(t *testing.T) { + child := newProbe("probe_child", nil) + caller := &scriptedCaller{tool: "child", requests: []string{"task-1", "task-2", "task-3"}} + sys := buildWith(t, agentToolDoc, func(reg *Registry) { require.NoError(t, reg.RegisterModel("scripted_caller", caller)) }, child) + runOnce(t, sys, "go") + + calls := child.calls() + require.Len(t, calls, 3, "one session per call") + for _, call := range calls { + shown := 0 + for _, task := range caller.requests { + if strings.Contains(call, task) { + shown++ + } + } + require.Equal(t, 1, shown, "a call saw another call's request: %q", call) + require.NotContains(t, call, child.marker(), "a call saw another call's reply") + } +} + +func TestAgentToolSessionStateIsACopy(t *testing.T) { + child := newProbe("probe_child", nil) + caller := &scriptedCaller{tool: "child", requests: []string{"task-1"}} + // The child writes its reply under its own output_key; the parent must not see it. + doc := strings.Replace(agentToolDoc, "instruction: Do the task. }", "instruction: Do the task., output_key: child_result }", 1) + sys := buildWith(t, doc, func(reg *Registry) { require.NoError(t, reg.RegisterModel("scripted_caller", caller)) }, child) + + svc, id := newSession(t) + run(t, sys.Root(), userMessage("go"), svc, id) + stored, err := svc.Get(context.Background(), &session.GetRequest{AppName: "test", UserID: "user", SessionID: id}) + require.NoError(t, err) + _, leaked := stored.Session.State().Get("child_result") + require.Error(t, leaked, "the tool's state writes stay in its own session") +} + +// TestDynamicIsolationScopeHidesAncestorEvents separates scope from branch: a +// child on its own branch still sees what ran before the dynamic node, while a +// scoped child sees only its own events. +func TestDynamicIsolationScopeHidesAncestorEvents(t *testing.T) { + doc := func(isolation string) string { + return docHead + ` - name: wf + agent_class: Workflow + nodes: + - { id: upstream, type: agent, agent: agent_up } + - { id: orchestrate, type: dynamic, function: one_child, children: [child_b], isolation: ` + isolation + ` } + - { id: child_b, type: agent, agent: agent_b } + - { id: end, type: function, function: summarize_join } + edges: + - { from: START, to: upstream } + - { from: upstream, to: orchestrate } + - { from: orchestrate, to: end } + - { name: agent_up, agent_class: LlmAgent, model: probe_up, mode: chat, instruction: Up } +` + agentB + } + for isolation, wantUpstream := range map[string]bool{"shared": true, "branch": true, "scope": false} { + t.Run(isolation, func(t *testing.T) { + up := newProbe("probe_up", nil) + b := newProbe("probe_b", up) + sys := buildWith(t, doc(isolation), func(reg *Registry) { + require.NoError(t, reg.RegisterDynamic("one_child", func(_ agent.Context, in any, env *DynamicEnv) (any, error) { + // The child's own input is fixed, so what it sees of the upstream + // reply can only come from session history. + return env.RunChild("child_b", "the task") + })) + }, up, b) + runOnce(t, sys, "start") + require.NotEmpty(t, b.calls()) + require.Equal(t, wantUpstream, b.sawAnyOf(up.marker()), "child saw: %q", b.calls()) + }) + } +} + +// TestIncludeContentsNoneIsNotIsolation pins a trap: include_contents none +// keeps only the current turn, and the earlier agents' replies are part of it. +// It narrows what a sub-agent sees of the user's history; it does not hide +// what the agents before it said. +func TestIncludeContentsNoneIsNotIsolation(t *testing.T) { + a := newProbe("probe_a", nil) + b := newProbe("probe_b", nil) + doc := docHead + " - { name: seq, agent_class: SequentialAgent, sub_agents: [agent_a, agent_b] }\n" + + strings.ReplaceAll(agentA, "mode: chat, ", "") + + strings.Replace(strings.ReplaceAll(agentB, "mode: chat, ", ""), "instruction: B }", "instruction: B, include_contents: none }", 1) + runOnce(t, buildWith(t, doc, nil, a, b), "start") + require.True(t, b.sawAnyOf(a.marker()), "the earlier agent's reply still reaches the second agent: %q", b.calls()) +} + +// TestSingleTurnAgentNodesSeeOnlyTheirInput is the in-graph way to run agents +// one after another without sharing the conversation: a single_turn agent node +// is shown its predecessor's output and nothing else. +func TestSingleTurnAgentNodesSeeOnlyTheirInput(t *testing.T) { + a := newProbe("probe_a", nil) + b := newProbe("probe_b", nil) + doc := docHead + ` - name: wf + agent_class: Workflow + nodes: + - { id: first, type: agent, agent: agent_a } + - { id: second, type: agent, agent: agent_b } + - { id: end, type: function, function: summarize_join } + edges: [{ from: START, to: first }, { from: first, to: second }, { from: second, to: end }] +` + strings.ReplaceAll(agentA, "mode: chat", "mode: single_turn") + strings.ReplaceAll(agentB, "mode: chat", "mode: single_turn") + runOnce(t, buildWith(t, doc, nil, a, b), "the user's private message") + calls := b.calls() + require.Len(t, calls, 1) + require.Contains(t, calls[0], a.marker(), "it receives its predecessor's output as input") + require.NotContains(t, calls[0], "private message", "and not the user's conversation") +} diff --git a/go/adk/pkg/declarative/load.go b/go/adk/pkg/declarative/load.go new file mode 100644 index 0000000000..b038cdc918 --- /dev/null +++ b/go/adk/pkg/declarative/load.go @@ -0,0 +1,50 @@ +package declarative + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + + "sigs.k8s.io/yaml" + + configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" +) + +// Parse decodes and validates an AgentSystem document. Unknown fields are +// errors so that a misspelled key cannot silently change behavior. +// +// Documents that use node types added with Registry.RegisterNodeType must be +// parsed with Registry.Parse instead. +func Parse(data []byte) (*Spec, error) { + spec, err := decode(data) + if err != nil { + return nil, err + } + if err := Validate(spec); err != nil { + return nil, err + } + return spec, nil +} + +func decode(data []byte) (*Spec, error) { + var spec Spec + if err := yaml.UnmarshalStrict(data, &spec); err != nil { + return nil, fmt.Errorf("failed to decode %s document: %w", KindAgentSystem, err) + } + return &spec, nil +} + +// ParseConfig decodes the document that the controller validated and delivered +// in the runtime config, and validates it against r. It refuses a document +// whose digest differs from the one the controller recorded, so a corrupted or +// swapped config fails loudly instead of running different behavior. +func (r *Registry) ParseConfig(c *configv1.ADKWorkflowConfig) (*Spec, error) { + if c == nil { + return nil, fmt.Errorf("workflow config is required") + } + sum := sha256.Sum256(c.Document) + if got := hex.EncodeToString(sum[:]); got != c.Sha256 { + return nil, fmt.Errorf("workflow document digest %s does not match the recorded digest %s", got, c.Sha256) + } + return r.Parse(c.Document) +} diff --git a/go/adk/pkg/declarative/load_test.go b/go/adk/pkg/declarative/load_test.go new file mode 100644 index 0000000000..b3034c725a --- /dev/null +++ b/go/adk/pkg/declarative/load_test.go @@ -0,0 +1,41 @@ +package declarative + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" + + configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" +) + +func workflowConfig(document string) *configv1.ADKWorkflowConfig { + sum := sha256.Sum256([]byte(document)) + return &configv1.ADKWorkflowConfig{Document: []byte(document), Sha256: hex.EncodeToString(sum[:])} +} + +func TestParseConfig(t *testing.T) { + const document = `{"apiVersion":"adk.declarative/v0.2","kind":"AgentSystem","agents":[{"name":"solo","agent_class":"LlmAgent","model":"stub","instruction":"hi"}]}` + + spec, err := NewRegistry().ParseConfig(workflowConfig(document)) + require.NoError(t, err) + require.Equal(t, "solo", spec.Agents[0].Name) + + t.Run("nil config", func(t *testing.T) { + _, err := NewRegistry().ParseConfig(nil) + require.ErrorContains(t, err, "workflow config is required") + }) + t.Run("digest mismatch", func(t *testing.T) { + cfg := workflowConfig(document) + cfg.Document = []byte(document + " ") + _, err := NewRegistry().ParseConfig(cfg) + require.ErrorContains(t, err, "does not match the recorded digest") + }) + t.Run("document is validated against the registry", func(t *testing.T) { + unregistered := `{"apiVersion":"adk.declarative/v0.2","kind":"AgentSystem","agents":[{"name":"solo","agent_class":"LlmAgent","model":"nope","instruction":"hi"}]}` + _, err := NewRegistry().ParseConfig(workflowConfig(unregistered)) + require.True(t, IsValidation(err)) + require.ErrorContains(t, err, `agents[0].model: model "nope" is not registered`) + }) +} diff --git a/go/adk/pkg/declarative/mcp.go b/go/adk/pkg/declarative/mcp.go new file mode 100644 index 0000000000..8ad446db1d --- /dev/null +++ b/go/adk/pkg/declarative/mcp.go @@ -0,0 +1,243 @@ +package declarative + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "slices" + "sync" + + "github.com/google/jsonschema-go/jsonschema" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/tool" + "google.golang.org/adk/v2/tool/mcptoolset" + "google.golang.org/adk/v2/workflow" +) + +// MCPServer is a declared MCP server with its sources resolved. +type MCPServer struct { + Name string + URL string + Headers map[string]string + // Tools is the allowlist; empty allows every tool. + Tools []string +} + +// MCPConnector creates the toolset of a declared MCP server. It must not +// contact the server: tools are listed when an agent or node first needs +// them, so a server that is down fails a run, not the build. +type MCPConnector func(ctx context.Context, server MCPServer) (tool.Toolset, error) + +// SetMCPConnector replaces the default streamable HTTP connector, for example +// with one that adds per-request credentials. +func (r *Registry) SetMCPConnector(c MCPConnector) { r.mcpConnector = c } + +// ConnectMCP is the default MCPConnector: an ADK MCP toolset over streamable +// HTTP that sends the server's headers and keeps only allowlisted tools. +func ConnectMCP(_ context.Context, server MCPServer) (tool.Toolset, error) { + transport := &mcpsdk.StreamableClientTransport{Endpoint: server.URL} + if len(server.Headers) > 0 { + transport.HTTPClient = &http.Client{Transport: &headerTransport{headers: server.Headers, base: http.DefaultTransport}} + } + ts, err := mcptoolset.New(mcptoolset.Config{Transport: transport}) + if err != nil { + return nil, fmt.Errorf("failed to create MCP toolset %q: %w", server.Name, err) + } + closer, _ := ts.(io.Closer) + if len(server.Tools) > 0 { + ts = tool.FilterToolset(ts, tool.StringPredicate(server.Tools)) + } + return &serverToolset{Toolset: ts, name: server.Name, closer: closer}, nil +} + +type headerTransport struct { + headers map[string]string + base http.RoundTripper +} + +func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + for k, v := range t.headers { + req.Header.Set(k, v) + } + return t.base.RoundTrip(req) +} + +// serverToolset reports the document's server name, so traces and errors name +// the server the author declared. +type serverToolset struct { + tool.Toolset + name string + // closer is the unfiltered MCP toolset; FilterToolset hides its Close. + closer io.Closer +} + +func (n *serverToolset) Name() string { return n.name } + +func (n *serverToolset) Close() error { + if n.closer != nil { + return n.closer.Close() + } + if c, ok := n.Toolset.(io.Closer); ok { + return c.Close() + } + return nil +} + +// resolveMCPServer resolves the env sources of a declared server. +func resolveMCPServer(s *MCPServerSpec) (MCPServer, error) { + url, err := sourcedText(s.URL, s.URLFrom) + if err != nil { + return MCPServer{}, fmt.Errorf("url_from: %w", err) + } + headers := make(map[string]string, len(s.Headers)) + for _, h := range s.Headers { + v, err := sourcedText(h.Value, h.ValueFrom) + if err != nil { + return MCPServer{}, fmt.Errorf("headers[%s]: %w", h.Name, err) + } + headers[h.Name] = v + } + return MCPServer{Name: s.Name, URL: url, Headers: headers, Tools: slices.Clone(s.Tools)}, nil +} + +// connectMCPServers creates the toolsets of the document's servers. +func (b *builder) connectMCPServers(servers []MCPServerSpec) error { + connect := b.reg.mcpConnector + if connect == nil { + connect = ConnectMCP + } + for i := range servers { + s := &servers[i] + resolved, err := resolveMCPServer(s) + if err != nil { + return fmt.Errorf("mcp server %q: %w", s.Name, err) + } + ts, err := connect(b.ctx, resolved) + if err != nil { + return fmt.Errorf("mcp server %q: %w", s.Name, err) + } + if ts == nil { + return fmt.Errorf("mcp server %q: connector returned no toolset", s.Name) + } + b.servers[s.Name] = ts + } + return nil +} + +// toolset returns a declared server, or a registered toolset of that name. +func (b *builder) toolset(name string) (tool.Toolset, bool) { + if ts, ok := b.servers[name]; ok { + return ts, true + } + ts, ok := b.reg.toolsets[name] + return ts, ok +} + +// runnableTool is the interface the ADK workflow engine uses to call a tool +// outside a model turn; MCP and function tools implement it. +type runnableTool interface { + Run(ctx agent.Context, args any) (map[string]any, error) +} + +// mcpToolNode calls one tool of a toolset. The toolset lists its tools only +// when the node first runs; a successful lookup is cached, a failed one is +// retried by the next run so a server that was briefly down recovers. +func (b *builder) mcpToolNode(n *NodeSpec, in, out *jsonschema.Schema, cfg workflow.NodeConfig) (workflow.Node, error) { + ts, ok := b.toolset(n.Server) + if !ok { + return nil, fmt.Errorf("server %q is not declared or registered", n.Server) + } + var ( + mu sync.Mutex + cached runnableTool + ) + lookup := func(ctx agent.Context) (runnableTool, error) { + mu.Lock() + defer mu.Unlock() + if cached != nil { + return cached, nil + } + tools, err := ts.Tools(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list tools of %q: %w", n.Server, err) + } + for _, t := range tools { + if t.Name() != n.Tool { + continue + } + r, ok := t.(runnableTool) + if !ok { + return nil, fmt.Errorf("tool %q of %q (%T) cannot run outside a model turn", n.Tool, n.Server, t) + } + cached = r + return r, nil + } + names := make([]string, 0, len(tools)) + for _, t := range tools { + names = append(names, t.Name()) + } + slices.Sort(names) + return nil, fmt.Errorf("server %q has no tool %q (has %v)", n.Server, n.Tool, names) + } + body := func(ctx agent.Context, input any) (any, error) { + t, err := lookup(ctx) + if err != nil { + return nil, err + } + args, err := toolArgs(input) + if err != nil { + return nil, fmt.Errorf("tool %q of %q: %w", n.Tool, n.Server, err) + } + res, err := t.Run(ctx, args) + if err != nil { + return nil, fmt.Errorf("tool %q of %q: %w", n.Tool, n.Server, err) + } + return res, nil + } + return workflow.NewFunctionNodeWithSchema[any, any](n.ID, body, in, out, cfg) +} + +// toolArgs turns a predecessor's output into tool arguments the way the ADK +// tool node does: a map as is, a JSON object string decoded, nothing as {}. +func toolArgs(input any) (map[string]any, error) { + switch v := input.(type) { + case nil: + return map[string]any{}, nil + case map[string]any: + return v, nil + case string: + var m map[string]any + if err := json.Unmarshal([]byte(v), &m); err != nil || m == nil { + return nil, errors.New("input must be an object or a JSON object string") + } + return m, nil + } + data, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("input is not JSON: %w", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil || m == nil { + return nil, fmt.Errorf("input must be an object, got %T", input) + } + return m, nil +} + +// Close releases the connections of the document's MCP servers. +func (s *System) Close() error { + var errs []error + for _, name := range slices.Sorted(maps.Keys(s.servers)) { + if c, ok := s.servers[name].(io.Closer); ok { + if err := c.Close(); err != nil { + errs = append(errs, fmt.Errorf("mcp server %q: %w", name, err)) + } + } + } + return errors.Join(errs...) +} diff --git a/go/adk/pkg/declarative/mcp_test.go b/go/adk/pkg/declarative/mcp_test.go new file mode 100644 index 0000000000..1dd6c2cc31 --- /dev/null +++ b/go/adk/pkg/declarative/mcp_test.go @@ -0,0 +1,402 @@ +package declarative + +import ( + "context" + "encoding/json" + "iter" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "sync/atomic" + "testing" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" + "google.golang.org/adk/v2/agent" + adkmodel "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/session" + "google.golang.org/genai" +) + +const head = "apiVersion: adk.declarative/v0.2\nkind: AgentSystem\n" + +const crmKey = "secret-key" + +// crmServer is a remote MCP server reached over streamable HTTP. It rejects +// requests without the X-Api-Key header and counts tool calls. +type crmServer struct { + URL string + calls atomic.Int32 +} + +func startCRM(t testing.TB) *crmServer { + t.Helper() + crm := &crmServer{} + server := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "crm", Version: "v1"}, nil) + type lookupIn struct { + CustomerID string `json:"customer_id"` + } + type customer struct { + ID string `json:"id"` + Plan string `json:"plan"` + Balance int `json:"balance"` + } + mcpsdk.AddTool(server, &mcpsdk.Tool{Name: "lookup_customer", Description: "Looks up a customer by id."}, + func(_ context.Context, _ *mcpsdk.CallToolRequest, in lookupIn) (*mcpsdk.CallToolResult, customer, error) { + crm.calls.Add(1) + balance := 120 + if in.CustomerID == "c-13" { + balance = 50 + } + return nil, customer{ID: in.CustomerID, Plan: "pro", Balance: balance}, nil + }) + mcpsdk.AddTool(server, &mcpsdk.Tool{Name: "delete_customer", Description: "Deletes a customer."}, + func(_ context.Context, _ *mcpsdk.CallToolRequest, in lookupIn) (*mcpsdk.CallToolResult, any, error) { + crm.calls.Add(1) + return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: "deleted " + in.CustomerID}}}, nil, nil + }) + mcp := mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { return server }, nil) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Api-Key") != crmKey { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + mcp.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + crm.URL = srv.URL + return crm +} + +const crmServers = ` +mcp_servers: + - name: crm + url_from: { env: CRM_MCP_URL } + headers: + - { name: X-Api-Key, value_from: { env: CRM_API_KEY } } + tools: [lookup_customer, delete_customer] +` + +func buildDoc(t *testing.T, reg *Registry, doc string) *System { + t.Helper() + spec, err := reg.Parse([]byte(doc)) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, reg) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, sys.Close()) }) + return sys +} + +func replyText(events []*session.Event) string { + var sb strings.Builder + for _, ev := range events { + if ev.Content != nil && !ev.Partial { + for _, p := range ev.Content.Parts { + sb.WriteString(p.Text) + } + } + } + return sb.String() +} + +// TestRemoteMCPToolNode runs Declarative + CEL + remote MCP end to end: CEL +// extracts the arguments, a tool node calls the MCP server, CEL renders the +// structured result with context variables. +func TestRemoteMCPToolNode(t *testing.T) { + crm := startCRM(t) + t.Setenv("CRM_MCP_URL", crm.URL) + t.Setenv("CRM_API_KEY", crmKey) + sys := buildDoc(t, NewRegistry(), head+crmServers+` +agents: + - name: billing + agent_class: Workflow + nodes: + - id: args + type: cel + output: '{"customer_id": regex.extract(user.content, r"c-[0-9]+").orValue("unknown")}' + - { id: lookup, type: tool, server: crm, tool: lookup_customer, timeout: 10s } + - id: reply + type: cel + output: | + '%s: %s is on %s, balance %d (%s)'.format([invocation.agent_name, input.output.id, + input.output.plan, input.output.balance, toJson(input.output.plan)]) + state_delta: + plan: input.output.plan + edges: + - { from: START, to: args } + - { from: args, to: lookup } + - { from: lookup, to: reply } +`) + svc, id := newSession(t) + events := run(t, sys.Root(), genai.NewContentFromText("why was c-42 charged?", genai.RoleUser), svc, id) + require.Contains(t, replyText(events), `billing: c-42 is on pro, balance 120 ("pro")`) + require.EqualValues(t, 1, crm.calls.Load()) + got, err := svc.Get(t.Context(), &session.GetRequest{AppName: "test", UserID: "user", SessionID: id}) + require.NoError(t, err) + plan, err := got.Session.State().Get("plan") + require.NoError(t, err) + require.Equal(t, "pro", plan) +} + +func TestRemoteMCPUnreachableFailsTheRunNotTheBuild(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + srv.Close() + sys := buildDoc(t, NewRegistry(), head+` +mcp_servers: + - { name: crm, url: "`+srv.URL+`" } +agents: + - name: wf + agent_class: Workflow + nodes: + - { id: lookup, type: tool, server: crm, tool: lookup_customer } + - { id: done, type: cel, output: "'ok'" } + edges: + - { from: START, to: lookup } + - { from: lookup, to: done } +`) + svc, id := newSession(t) + _, err := tryRun(t, &System{root: sys.Root()}, svc, id, "hi") + require.ErrorContains(t, err, `failed to list tools of "crm"`) +} + +// toolCaller calls tool with the customer id of the user message, then +// answers with the tool response it receives. +type toolCaller struct{ tool string } + +func (m toolCaller) Name() string { return "tool_caller" } + +func (m toolCaller) GenerateContent(_ context.Context, req *adkmodel.LLMRequest, _ bool) iter.Seq2[*adkmodel.LLMResponse, error] { + return func(yield func(*adkmodel.LLMResponse, error) bool) { + last := req.Contents[len(req.Contents)-1] + for _, p := range last.Parts { + if p.FunctionResponse != nil { + data, _ := json.Marshal(p.FunctionResponse.Response) + yield(&adkmodel.LLMResponse{Content: genai.NewContentFromText("tool said "+string(data), genai.RoleModel), TurnComplete: true}, nil) + return + } + } + id := regexp.MustCompile(`c-[0-9]+`).FindString(lastUserText(req)) + yield(&adkmodel.LLMResponse{ + Content: &genai.Content{Role: genai.RoleModel, Parts: []*genai.Part{{FunctionCall: &genai.FunctionCall{ + ID: "call-1", Name: m.tool, Args: map[string]any{"customer_id": id}, + }}}}, + TurnComplete: true, + }, nil) + } +} + +// TestHooksGuardModelAndRemoteMCPTools covers accept and reject on every +// callback point of an LLM agent whose tools come from a remote MCP server. +func TestHooksGuardModelAndRemoteMCPTools(t *testing.T) { + crm := startCRM(t) + t.Setenv("CRM_MCP_URL", crm.URL) + t.Setenv("CRM_API_KEY", crmKey) + t.Setenv("ASSISTANT_PROMPT", "You are the CRM assistant.") + + newReg := func(tool string) *Registry { + reg := NewRegistry() + require.NoError(t, reg.RegisterModel("caller", toolCaller{tool: tool})) + require.NoError(t, reg.RegisterHook("vip_only", func(_ agent.Context, ev *HookEvent) (Decision, error) { + if ev.Args["customer_id"] == "c-1" { + return Decision{Verdict: Accept}, nil + } + return Decision{Verdict: Continue}, nil + })) + return reg + } + const agentDoc = ` +agents: + - name: assistant + agent_class: LlmAgent + model: caller + instruction_from: { env: ASSISTANT_PROMPT } + tools: [crm] + callbacks: + before_model: + - when: request.text.matches(r'(?i)password') + action: reject + message: I cannot help with credentials. + - when: "!request.system_instruction.contains('CRM assistant')" + action: reject + message: missing prompt + before_tool: + - function: vip_only + - when: tool.name == 'delete_customer' + action: reject + message: deleting customers is not allowed + - when: tool.args.customer_id == '' + action: reject + message: a customer id is required + after_tool: + - when: "tool.result.?output.?balance.orValue(0) > 100 && state[?'role'].orValue('') != 'admin'" + action: reject + message: balance hidden + after_model: + - when: response.text.contains('c-13') + action: reject + message: redacted +` + for _, tc := range []struct { + name, tool, input string + want string + calls int32 + }{ + {"before_model rejects without calling the model", "lookup_customer", "reset my password for c-42", "I cannot help with credentials.", 0}, + {"before_tool rejects a tool by name", "delete_customer", "remove c-42", "deleting customers is not allowed", 0}, + {"before_tool rejects missing arguments", "lookup_customer", "who am I", "a customer id is required", 0}, + {"a function hook accepts and stops the chain", "delete_customer", "remove c-1", `deleted c-1`, 1}, + {"after_tool rejects the remote result", "lookup_customer", "lookup c-42", "balance hidden", 1}, + {"after_model rejects the reply", "lookup_customer", "c-13 please", "redacted", 1}, + } { + t.Run(tc.name, func(t *testing.T) { + before := crm.calls.Load() + sys := buildDoc(t, newReg(tc.tool), head+crmServers+agentDoc) + svc, id := newSession(t) + reply := replyText(run(t, sys.Root(), genai.NewContentFromText(tc.input, genai.RoleUser), svc, id)) + require.Contains(t, reply, tc.want) + require.Equal(t, tc.calls, crm.calls.Load()-before, "remote tool calls") + }) + } +} + +func TestPromptSources(t *testing.T) { + const doc = head + ` +agents: + - name: a + agent_class: LlmAgent + model: stub + instruction_from: %s +` + reg := NewRegistry() + _, err := reg.Parse([]byte(strings.Replace(doc, "%s", "{ config_map: { name: prompts, key: a.md } }", 1))) + require.ErrorContains(t, err, "must be resolved by the kagent controller") + _, err = reg.Parse([]byte(strings.Replace(doc, "%s", "{ env: A, secret: { name: s, key: k } }", 1))) + require.ErrorContains(t, err, "exactly one of config_map, secret, env") + + spec, err := decode([]byte(strings.Replace(doc, "%s", "{ config_map: { name: prompts, key: a.md } }", 1))) + require.NoError(t, err) + require.NoError(t, Validate(spec), "document-only validation accepts Kubernetes references") + var paths []string + require.NoError(t, ResolvePrompts(spec, func(path string, src TextSource) (string, *TextSource, error) { + paths = append(paths, path) + require.Equal(t, "prompts", src.ConfigMap.Name) + return "Be brief.", nil, nil + })) + require.Equal(t, []string{"agents[0].instruction_from"}, paths) + require.Equal(t, "Be brief.", spec.Agents[0].Instruction) + require.Nil(t, spec.Agents[0].InstructionFrom) + require.NoError(t, reg.Validate(spec)) + + spec, err = reg.Parse([]byte(strings.Replace(doc, "%s", "{ env: MISSING_PROMPT_VAR }", 1))) + require.NoError(t, err) + _, err = Build(t.Context(), spec, reg) + require.ErrorContains(t, err, `prompt environment variable "MISSING_PROMPT_VAR" is not set`) +} + +func TestMCPSourceValidation(t *testing.T) { + for _, tc := range []struct { + name, source, want string + }{ + {"URL ConfigMap", "url_from: { config_map: { name: endpoints, key: crm } }", "mcp_servers[0].url_from: must use env"}, + {"header ConfigMap", "url: http://crm, headers: [{ name: Authorization, value_from: { config_map: { name: credentials, key: token } } }]", "mcp_servers[0].headers[0].value_from: must use env"}, + {"header Secret", "url: http://crm, headers: [{ name: Authorization, value_from: { secret: { name: credentials, key: token } } }]", "mcp_servers[0].headers[0].value_from: must use env"}, + {"invalid env name", "url: http://crm, headers: [{ name: Authorization, value_from: { env: BAD-NAME } }]", "mcp_servers[0].headers[0].value_from.env"}, + } { + t.Run(tc.name, func(t *testing.T) { + doc := head + "mcp_servers: [{ name: crm, " + tc.source + " }]\nagents: [{ name: a, agent_class: LlmAgent, model: stub, instruction: hi }]\n" + _, err := Parse([]byte(doc)) + require.ErrorContains(t, err, tc.want) + }) + } +} + +func TestMCPAndHookValidation(t *testing.T) { + reg := NewRegistry() + for _, tc := range []struct{ body, want string }{ + {` +mcp_servers: [{ name: crm, url: "ftp://x" }] +agents: [{ name: a, agent_class: LlmAgent, model: stub, instruction: hi, tools: [crm] }]`, "must be an absolute http or https URL"}, + {` +mcp_servers: [{ name: crm, url: "http://x", url_from: { env: X } }] +agents: [{ name: a, agent_class: LlmAgent, model: stub, instruction: hi }]`, "exactly one of url, url_from"}, + {` +mcp_servers: [{ name: crm, url: "http://x", headers: [{ name: "bad header", value: v }] }] +agents: [{ name: a, agent_class: LlmAgent, model: stub, instruction: hi }]`, "is not an HTTP header name"}, + {` +mcp_servers: [{ name: crm, url: "http://x", tools: [lookup_customer] }] +agents: + - name: w + agent_class: Workflow + nodes: [{ id: t, type: tool, server: crm, tool: delete_customer }, { id: d, type: cel, output: "'x'" }] + edges: [{ from: START, to: t }, { from: t, to: d }]`, `tool "delete_customer" is not in the tools of mcp server "crm"`}, + {` +agents: + - name: w + agent_class: Workflow + nodes: [{ id: t, type: tool, server: ghost, tool: x }, { id: d, type: cel, output: "'x'" }] + edges: [{ from: START, to: t }, { from: t, to: d }]`, `server "ghost" is not declared in mcp_servers`}, + {` +agents: + - name: a + agent_class: LlmAgent + model: stub + instruction: hi + callbacks: { before_tool: [{ action: reject }] }`, "a reject action requires a message"}, + {` +agents: + - name: a + agent_class: LlmAgent + model: stub + instruction: hi + callbacks: { before_model: [{ when: "tool.name == 'x'", action: accept }] }`, "undeclared reference to 'tool'"}, + {` +agents: + - name: a + agent_class: LlmAgent + model: stub + instruction: hi + callbacks: { after_tool: [{ function: ghost }] }`, `hook "ghost" is not registered`}, + {` +agents: + - name: w + agent_class: Workflow + nodes: [{ id: c, type: cel, route: "1 + 1" }] + edges: [{ from: START, to: c }]`, "want string or list(string)"}, + } { + _, err := reg.Parse([]byte(head + tc.body)) + require.ErrorContains(t, err, tc.want, tc.body) + } +} + +func TestCELContextAndLibrary(t *testing.T) { + sys := buildDoc(t, NewRegistry(), head+` +agents: + - name: ctx_wf + agent_class: Workflow + nodes: + - id: seed + type: cel + state_delta: + tags: "['b', 'a', 'b']" + raw: '''{"n": 2, "items": [1, 2, 3]}''' + - id: show + type: cel + output: | + cel.bind(doc, json(state.raw), [ + session.app_name, session.user_id, user.id, invocation.agent_name, + string(size(session.id) > 0), string(size(invocation.id) > 0), + string(math.greatest(doc.items)), string(doc.n * 2), + sets.contains(state.tags, ['a']) ? 'has-a' : 'no-a', + state.tags.map(t, string(t)).distinct().sort().join('+'), + string(state[?'missing'].orValue('none')), + toJson({'k': [1, 'x']}), + ].join(' ')) + edges: + - { from: START, to: seed } + - { from: seed, to: show } +`) + svc, id := newSession(t) + reply := replyText(run(t, sys.Root(), genai.NewContentFromText("hi", genai.RoleUser), svc, id)) + require.Contains(t, reply, `test user user ctx_wf true true 3 4 has-a a+b none {"k":[1,"x"]}`) +} diff --git a/go/adk/pkg/declarative/nodes.go b/go/adk/pkg/declarative/nodes.go new file mode 100644 index 0000000000..6f9709f791 --- /dev/null +++ b/go/adk/pkg/declarative/nodes.go @@ -0,0 +1,333 @@ +package declarative + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/session" + "google.golang.org/adk/v2/workflow" + "google.golang.org/genai" +) + +// node dispatches on the node type. Registered types are consulted after the +// built-ins, which they cannot shadow. render is set for terminal nodes whose +// output must be readable as message text. +func (b *builder) node(bc *BuildContext, sc *scope, n *NodeSpec, render bool) (workflow.Node, error) { + cfg, err := nodeConfig(n) + if err != nil { + return nil, err + } + in, out, err := nodeSchemas(n) + if err != nil { + return nil, err + } + switch n.Type { + case NodeFunction: + fn, err := b.function(n.Function) + if err != nil { + return nil, err + } + return workflow.NewFunctionNodeWithSchema[any, any](n.ID, functionBody(fn, render), in, out, cfg) + case NodeJoin: + return joinNode(n.ID, in) + case NodeTool: + if n.Server != "" { + return b.mcpToolNode(n, in, out, cfg) + } + return b.toolNode(n.ID, n.Tool, cfg) + case NodeAgent: + return b.agentNode(n.Agent, in, cfg) + case NodeLLM: + return b.llmNode(n, in, cfg) + case NodeWorkflow: + return b.workflowNode(n, render) + case NodeParallelWorker: + return b.parallelWorker(n, cfg) + case NodeDynamic: + return b.dynamicNode(sc, n, cfg, render, in, out) + case NodeHITL: + return hitlNode(n, cfg, render), nil + case NodeCEL: + return celNode(n, in, out, cfg, render) + } + custom, ok := b.reg.nodeTypes[n.Type] + if !ok { + return nil, fmt.Errorf("node type %q is not registered", n.Type) + } + return custom.build(bc, n) +} + +func (b *builder) function(name string) (Func, error) { + fn, ok := b.reg.funcs[name] + if !ok { + return nil, fmt.Errorf("function %q is not registered", name) + } + return fn, nil +} + +// functionBody adapts a Func to a plain function node, as a native +// workflow.NewFunctionNode would be written. A plain node emits data only, so +// intermediate steps stay out of the chat; a result that carries routes, or +// that ends the run, is returned as an event the engine yields unchanged. +func functionBody(fn Func, render bool) func(agent.Context, any) (any, error) { + return func(ctx agent.Context, in any) (any, error) { + res, err := fn(ctx, in) + if err != nil { + return nil, err + } + if len(res.Routes) == 0 && (!render || res.Output == nil) { + return res.Output, nil + } + return resultEvent(ctx, res.Output, res.Routes, render), nil + } +} + +// resultEvent builds one event carrying a node's output and routes. A2A +// clients display only Event.Content while successor nodes read Event.Output, +// so a terminal result is also rendered as message text: strings as they are, +// structured values as JSON. +func resultEvent(ctx agent.Context, out any, routes []string, render bool) *session.Event { + ev := session.NewEvent(ctx, ctx.InvocationID()) + ev.Routes = routes + ev.Output = out + if render && out != nil { + ev.Content = &genai.Content{Parts: []*genai.Part{{Text: outputText(out)}}} + } + return ev +} + +// finish hands the result of an emitting node (dynamic, hitl) to the engine. +// Plain results return normally and the engine emits them; a result that +// must be rendered is emitted here as one event and the node returns nil so +// the engine adds no second one. +func finish(ctx agent.Context, emit func(*session.Event) error, out any, render bool) (any, error) { + if _, isText := out.(string); !render || out == nil || isText { + return out, nil + } + return nil, emit(resultEvent(ctx, out, nil, true)) +} + +// outputText renders a structured output for display. +func outputText(out any) string { + if s, ok := out.(string); ok { + return s + } + if data, err := json.Marshal(out); err == nil { + return string(data) + } + return fmt.Sprint(out) +} + +func (b *builder) toolNode(id, toolName string, cfg workflow.NodeConfig) (workflow.Node, error) { + t, ok := b.reg.tools[toolName] + if !ok { + return nil, fmt.Errorf("tool %q is not registered", toolName) + } + return workflow.NewNamedToolNode(id, t, cfg) +} + +// agentNode wraps a document agent. ADK names an agent node after its agent, +// so the document id of such a node is not visible to the engine. +func (b *builder) agentNode(agentName string, in *jsonschema.Schema, cfg workflow.NodeConfig) (workflow.Node, error) { + a, err := b.agent(agentName) + if err != nil { + return nil, err + } + return workflow.NewAgentNodeWithSchemas(a, in, nil, cfg) +} + +// nodeSchemas parses the node's contracts. Validate already resolved them. +// An inline llm node's output_schema is the model's reply schema, handled when +// the agent is built, so it is not a node contract here. +func nodeSchemas(n *NodeSpec) (in, out *jsonschema.Schema, err error) { + if len(n.InputSchema) > 0 { + if in, err = parseSchema(n.InputSchema); err != nil { + return nil, nil, fmt.Errorf("input_schema: %w", err) + } + } + if n.Type != NodeLLM && len(n.OutputSchema) > 0 { + if out, err = parseSchema(n.OutputSchema); err != nil { + return nil, nil, fmt.Errorf("output_schema: %w", err) + } + } + return in, out, nil +} + +// joinNode applies the schema to each predecessor's output, as the engine does. +func joinNode(id string, in *jsonschema.Schema) (workflow.Node, error) { + if in == nil { + return workflow.NewJoinNode(id), nil + } + resolved, err := in.Resolve(nil) + if err != nil { + return nil, fmt.Errorf("input_schema: %w", err) + } + return workflow.NewJoinNodeWithSchema(id, resolved), nil +} + +// llmNode builds an inline llm node as an anonymous single-turn agent named +// after the node id. +func (b *builder) llmNode(n *NodeSpec, in *jsonschema.Schema, cfg workflow.NodeConfig) (workflow.Node, error) { + mode := n.Mode + if mode == "" { + mode = ModeSingleTurn + } + a, err := b.newLLM(n.ID, n.Description, &n.LLMSpec, mode, nil, nil, false, false) + if err != nil { + return nil, err + } + return workflow.NewAgentNodeWithSchemas(a, in, nil, cfg) +} + +func (b *builder) workflowNode(n *NodeSpec, render bool) (workflow.Node, error) { + nodes, err := b.nodes(n.Nodes, n.Edges, render) + if err != nil { + return nil, err + } + return workflow.NewWorkflowNode(n.ID, toEdges(nodes, n.Edges, "")) +} + +// parallelWorker fans the input list out over one wrapped node. The wrapped +// node carries no retry policy: ADK applies the worker's own policy per item. +func (b *builder) parallelWorker(n *NodeSpec, cfg workflow.NodeConfig) (workflow.Node, error) { + var wrapped workflow.Node + var err error + switch { + case n.Function != "": + var fn Func + if fn, err = b.function(n.Function); err == nil { + wrapped = workflow.NewFunctionNode(n.ID+"_item", plainFunc(fn), workflow.NodeConfig{}) + } + case n.Tool != "": + wrapped, err = b.toolNode(n.ID+"_item", n.Tool, workflow.NodeConfig{}) + default: + wrapped, err = b.agentNode(n.Agent, nil, workflow.NodeConfig{}) + } + if err != nil { + return nil, err + } + return workflow.NewParallelWorker(n.ID, wrapped, n.MaxConcurrency, cfg) +} + +// plainFunc adapts a Func whose routes are ignored: a worker item has no +// outgoing edges to route over. +func plainFunc(fn Func) func(agent.Context, any) (any, error) { + return func(ctx agent.Context, in any) (any, error) { + res, err := fn(ctx, in) + return res.Output, err + } +} + +func (b *builder) dynamicNode(sc *scope, n *NodeSpec, cfg workflow.NodeConfig, render bool, in, out *jsonschema.Schema) (workflow.Node, error) { + fn, ok := b.reg.dynamics[n.Function] + if !ok { + return nil, fmt.Errorf("dynamic function %q is not registered", n.Function) + } + maxIters, childIDs, isolation := n.MaxIters, n.Children, n.Isolation + return workflow.NewDynamicNodeWithSchema[any, any](n.ID, func(ctx agent.Context, input any, emit func(*session.Event) error) (any, error) { + children := make(map[string]workflow.Node, len(childIDs)) + for _, id := range childIDs { + children[id] = sc.nodes[id] + } + result, err := fn(ctx, input, &DynamicEnv{MaxIters: maxIters, Emit: emit, ctx: ctx, children: children, isolation: isolation}) + if err != nil { + return nil, err + } + return finish(ctx, emit, result, render) + }, in, out, cfg) +} + +// hitlNode pauses the workflow until a human answers and forwards the answer. +// The node re-runs on resume, so the interrupt id must be stable within a run +// and unique across runs; the invocation id gives both. +func hitlNode(n *NodeSpec, cfg workflow.NodeConfig, render bool) workflow.Node { + rerun := true + cfg.RerunOnResume = &rerun + return workflow.NewEmittingFunctionNode(n.ID, func(ctx agent.Context, _ any, emit func(*session.Event) error) (any, error) { + reply, err := workflow.ResumeOrRequestInput(ctx, emit, session.RequestInput{ + InterruptID: n.ID + "-" + ctx.InvocationID(), + Message: n.Message, + }) + if err != nil { + return nil, err + } + return finish(ctx, emit, reply, render) + }, cfg) +} + +// nodeConfig translates the common retry and timeout fields. Validate has +// already checked the syntax of every duration. +func nodeConfig(n *NodeSpec) (workflow.NodeConfig, error) { + var cfg workflow.NodeConfig + if n.Timeout != "" { + d, err := time.ParseDuration(n.Timeout) + if err != nil { + return cfg, fmt.Errorf("failed to parse timeout: %w", err) + } + cfg.Timeout = d + } + if n.Retry != nil { + rc := workflow.DefaultRetryConfig() + { + r := n.Retry + if r.MaxAttempts != 0 { + rc.MaxAttempts = r.MaxAttempts + } + for _, f := range []struct { + in string + out *time.Duration + }{{r.InitialDelay, &rc.InitialDelay}, {r.MaxDelay, &rc.MaxDelay}} { + if f.in == "" { + continue + } + d, err := time.ParseDuration(f.in) + if err != nil { + return cfg, fmt.Errorf("failed to parse retry delay: %w", err) + } + *f.out = d + } + if r.BackoffFactor != nil { + rc.BackoffFactor = *r.BackoffFactor + } + if r.Jitter != nil { + rc.Jitter = *r.Jitter + } + if r.When != "" { + when, err := compileCEL(scopeRetry, r.When, resultBool) + if err != nil { + return cfg, err + } + rc.ShouldRetry = retryPredicate(when) + } + } + cfg.RetryConfig = rc + } + return cfg, nil +} + +// retryPredicate adapts retry.when to ADK's ShouldRetry. Like ADK's default, +// an input validation error is never retried because the same input fails +// again. An expression that fails to evaluate does not retry: the original +// error surfaces instead. +func retryPredicate(when *expression) func(error) bool { + return func(err error) bool { + if err == nil || errors.Is(err, workflow.ErrInputValidation) { + return false + } + retry, evalErr := when.evalBool(map[string]any{celError: errorVars(err)}) + return evalErr == nil && retry + } +} + +func errorVars(err error) map[string]any { + return map[string]any{ + "message": err.Error(), + "timeout": errors.Is(err, context.DeadlineExceeded), + "canceled": errors.Is(err, context.Canceled), + } +} diff --git a/go/adk/pkg/declarative/prompts.go b/go/adk/pkg/declarative/prompts.go new file mode 100644 index 0000000000..3f39dc2805 --- /dev/null +++ b/go/adk/pkg/declarative/prompts.go @@ -0,0 +1,89 @@ +package declarative + +import ( + "fmt" + "os" +) + +// PromptResolver resolves one prompt source of a document. It returns either +// the prompt text, which is inlined, or a replacement source that the runtime +// resolves later, for example an Env source backed by a Secret. +type PromptResolver func(path string, src TextSource) (text string, replacement *TextSource, err error) + +// ResolvePrompts replaces every instruction_from and global_instruction_from +// of spec using resolve. The kagent controller uses it to turn ConfigMap and +// Secret references into text or Secret-backed environment variables before +// it delivers the document. Errors carry the field path of the source. +func ResolvePrompts(spec *Spec, resolve PromptResolver) error { + if spec == nil { + return nil + } + for i := range spec.Agents { + a := &spec.Agents[i] + path := fmt.Sprintf("agents[%d]", i) + if err := resolveLLMPrompts(path, &a.LLMSpec, resolve); err != nil { + return err + } + if err := resolveNodePrompts(path, a.Nodes, resolve); err != nil { + return err + } + } + return nil +} + +func resolveNodePrompts(path string, nodes []NodeSpec, resolve PromptResolver) error { + for i := range nodes { + n := &nodes[i] + p := fmt.Sprintf("%s.nodes[%d]", path, i) + if err := resolveLLMPrompts(p, &n.LLMSpec, resolve); err != nil { + return err + } + if err := resolveNodePrompts(p, n.Nodes, resolve); err != nil { + return err + } + } + return nil +} + +func resolveLLMPrompts(path string, l *LLMSpec, resolve PromptResolver) error { + for _, f := range []struct { + name string + text *string + src **TextSource + }{ + {"instruction_from", &l.Instruction, &l.InstructionFrom}, + {"global_instruction_from", &l.GlobalInstruction, &l.GlobalInstructionFrom}, + } { + if *f.src == nil { + continue + } + p := path + "." + f.name + text, replacement, err := resolve(p, **f.src) + if err != nil { + return fmt.Errorf("%s: %w", p, err) + } + if replacement != nil { + *f.src = replacement + continue + } + *f.text, *f.src = text, nil + } + return nil +} + +// sourcedText returns the effective text of an inline or sourced prompt. The +// runtime only resolves Env sources; Kubernetes references must have been +// resolved by the controller, which Registry.Validate enforces. +func sourcedText(inline string, src *TextSource) (string, error) { + if src == nil { + return inline, nil + } + if src.Env == "" { + return "", fmt.Errorf("prompt source must be resolved by the kagent controller before the runtime builds the document") + } + text, ok := os.LookupEnv(src.Env) //nolint:forbidigo // Controller-generated Secret-backed prompt variables, not user configuration. + if !ok { + return "", fmt.Errorf("prompt environment variable %q is not set", src.Env) + } + return text, nil +} diff --git a/go/adk/pkg/declarative/registry.go b/go/adk/pkg/declarative/registry.go new file mode 100644 index 0000000000..9079c8d739 --- /dev/null +++ b/go/adk/pkg/declarative/registry.go @@ -0,0 +1,316 @@ +package declarative + +import ( + "context" + "errors" + "fmt" + "regexp" + "sync/atomic" + + "google.golang.org/adk/v2/agent" + adkmodel "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/session" + "google.golang.org/adk/v2/tool" + "google.golang.org/adk/v2/tool/loadmemorytool" + "google.golang.org/adk/v2/tool/preloadmemorytool" + "google.golang.org/adk/v2/workflow" +) + +// StubModel is the name of the built-in offline model. +const StubModel = "stub" + +// Result is what a Func hands back to the workflow engine. +type Result struct { + // Output is passed to the successor nodes as their input. + Output any + // Routes selects the outgoing edges whose route matches. An empty slice + // takes every unconditional edge. + Routes []string +} + +// Output returns a Result that only carries a value. +func Output(v any) Result { return Result{Output: v} } + +// Route returns a Result that carries a value and selects routes. +func Route(v any, routes ...string) Result { return Result{Output: v, Routes: routes} } + +// Func is the behavior behind a function node. Keep it free of I/O that the +// caller cannot retry: a node with a retry policy may run it again. +type Func func(ctx agent.Context, input any) (Result, error) + +// ErrIterationBudget is returned by DynamicEnv.Run once max_iters child runs +// were spent. +var ErrIterationBudget = errors.New("dynamic node exhausted max_iters") + +// DynamicFunc is the orchestrator behind a dynamic node. It decides at run +// time which children to run and in which order. +type DynamicFunc func(ctx agent.Context, input any, env *DynamicEnv) (any, error) + +// DynamicEnv is the capability set handed to a DynamicFunc. +type DynamicEnv struct { + // MaxIters is the node's max_iters; zero means unlimited. + MaxIters int + // Emit publishes an intermediate event such as a state delta. + Emit func(*session.Event) error + + ctx agent.Context + children map[string]workflow.Node + isolation Isolation + runs atomic.Int64 +} + +// Run executes child as part of this node and returns its output, isolated as +// the node's isolation says. Each call spends one iteration of the max_iters +// budget. +func (e *DynamicEnv) Run(child workflow.Node, input any) (any, error) { + // A DynamicFunc may run children concurrently, so the budget is claimed + // atomically and released when it is already spent. + if n := e.runs.Add(1); e.MaxIters > 0 && n > int64(e.MaxIters) { + e.runs.Add(-1) + return nil, ErrIterationBudget + } + return workflow.RunNode[any](e.ctx, child, input, e.runOptions()...) +} + +// runOptions translates the node's isolation into engine options. Shared adds +// none: the child runs on the dynamic node's own branch. +func (e *DynamicEnv) runOptions() []workflow.RunNodeOption { + switch e.isolation { + case IsolationBranch: + return []workflow.RunNodeOption{workflow.WithUseSubBranch()} + case IsolationScope: + return []workflow.RunNodeOption{workflow.WithUseSubBranch(), workflow.WithIsolationScopeFromNodePath()} + } + return nil +} + +// RunChild executes the child node declared under id in the node's children +// list. It spends one iteration like Run. +func (e *DynamicEnv) RunChild(id string, input any) (any, error) { + child, ok := e.children[id] + if !ok { + return nil, fmt.Errorf("dynamic node has no child %q", id) + } + return e.Run(child, input) +} + +// Runs reports how many iterations were spent so far. +func (e *DynamicEnv) Runs() int { return int(e.runs.Load()) } + +// ModelResolver resolves model names that are not registered by value, for +// example by delegating to the provider factories of the kagent runtime. +type ModelResolver func(ctx context.Context, name string) (adkmodel.LLM, error) + +// BuildContext is what a NodeBuilder may use to construct its node. +type BuildContext struct { + Context context.Context + Registry *Registry + // Agent returns the built agent of the document, building it first when + // needed. It is the only way to obtain a sibling agent. + Agent func(name string) (agent.Agent, error) +} + +// NodeBuilder builds the workflow node for one NodeSpec of a registered type. +type NodeBuilder func(bc *BuildContext, spec *NodeSpec) (workflow.Node, error) + +type nodeType struct { + rule NodeRule + build NodeBuilder +} + +// Registry maps the names a document uses to Go values. It is populated +// before Build and read-only afterwards; it is not safe for concurrent +// registration. +type Registry struct { + funcs map[string]Func + dynamics map[string]DynamicFunc + tools map[string]tool.Tool + toolsets map[string]tool.Toolset + models map[string]adkmodel.LLM + resolver ModelResolver + nodeTypes map[NodeType]nodeType + hooks map[string]Hook + // mcpConnector creates the toolsets of declared MCP servers; nil uses + // ConnectMCP. + mcpConnector MCPConnector +} + +// NewRegistry returns a Registry preloaded with the offline stub model and +// the ADK memory tools (preload_memory, load_memory). +func NewRegistry() *Registry { + r := &Registry{ + funcs: map[string]Func{}, + dynamics: map[string]DynamicFunc{}, + tools: map[string]tool.Tool{}, + toolsets: map[string]tool.Toolset{}, + models: map[string]adkmodel.LLM{}, + nodeTypes: map[NodeType]nodeType{}, + hooks: map[string]Hook{}, + } + r.models[StubModel] = NewStubModel(StubModel) + for _, t := range []tool.Tool{preloadmemorytool.New(), loadmemorytool.New()} { + r.tools[t.Name()] = t + } + return r +} + +// register adds v under name after checking the name's shape, uniqueness, and +// that no registration kept in another table already uses it. +func register[V any](kind, name string, valid *regexp.Regexp, m map[string]V, v V, taken ...func(string) bool) error { + if !valid.MatchString(name) { + return fmt.Errorf("failed to register %s %q: name must match %s", kind, name, valid) + } + if _, dup := m[name]; dup { + return fmt.Errorf("failed to register %s %q: already registered", kind, name) + } + for _, isTaken := range taken { + if isTaken(name) { + return fmt.Errorf("failed to register %s %q: name is used by another registration", kind, name) + } + } + m[name] = v + return nil +} + +// RegisterFunction makes fn available to function and parallel_worker nodes. +func (r *Registry) RegisterFunction(name string, fn Func) error { + if fn == nil { + return fmt.Errorf("failed to register function %q: nil func", name) + } + return register("function", name, identifier, r.funcs, fn) +} + +// RegisterDynamic makes fn available to dynamic nodes. +func (r *Registry) RegisterDynamic(name string, fn DynamicFunc) error { + if fn == nil { + return fmt.Errorf("failed to register dynamic function %q: nil func", name) + } + return register("dynamic function", name, identifier, r.dynamics, fn) +} + +// RegisterHook makes h available to the function field of callback rules. +func (r *Registry) RegisterHook(name string, h Hook) error { + if h == nil { + return fmt.Errorf("failed to register hook %q: nil hook", name) + } + return register("hook", name, identifier, r.hooks, h) +} + +// RegisterTool makes t available to tool nodes and to the tools list of +// LLM agents under t.Name(), the name the model sees. +func (r *Registry) RegisterTool(t tool.Tool) error { + if t == nil { + return errors.New("failed to register tool: nil tool") + } + return register("tool", t.Name(), toolName, r.tools, t, func(n string) bool { _, ok := r.toolsets[n]; return ok }) +} + +// RegisterToolset makes ts, for example an MCP toolset, available to the +// tools list of LLM agents under ts.Name(). +func (r *Registry) RegisterToolset(ts tool.Toolset) error { + if ts == nil { + return errors.New("failed to register toolset: nil toolset") + } + return register("toolset", ts.Name(), toolName, r.toolsets, ts, func(n string) bool { _, ok := r.tools[n]; return ok }) +} + +// RegisterModel makes m available under name. +func (r *Registry) RegisterModel(name string, m adkmodel.LLM) error { + if m == nil { + return fmt.Errorf("failed to register model %q: nil model", name) + } + if !modelName.MatchString(name) { + return fmt.Errorf("failed to register model %q: name must match %s", name, modelName) + } + if _, dup := r.models[name]; dup { + return fmt.Errorf("failed to register model %q: already registered", name) + } + r.models[name] = m + return nil +} + +// SetModelResolver installs the fallback used for model names that were not +// registered by value. Registered models always win. +func (r *Registry) SetModelResolver(resolve ModelResolver) { r.resolver = resolve } + +// RegisterNodeType adds a node type to the vocabulary of documents. rule +// declares which NodeSpec fields it takes; build constructs the node. Built-in +// types cannot be replaced. +func (r *Registry) RegisterNodeType(t NodeType, rule NodeRule, build NodeBuilder) error { + if _, builtin := builtinNodeRules[t]; builtin { + return fmt.Errorf("failed to register node type %q: built-in types cannot be replaced", t) + } + if !identifier.MatchString(string(t)) { + return fmt.Errorf("failed to register node type %q: name must match %s", t, identifier) + } + if build == nil { + return fmt.Errorf("failed to register node type %q: nil builder", t) + } + if _, dup := r.nodeTypes[t]; dup { + return fmt.Errorf("failed to register node type %q: already registered", t) + } + r.nodeTypes[t] = nodeType{rule: rule, build: build} + return nil +} + +// Validate is the package-level Validate extended with the vocabulary of r: +// its node types, and a check that every function, tool and model a document +// names is registered. All issues are reported together with their field +// paths. +func (r *Registry) Validate(spec *Spec) error { + return validate(spec, r.rules(), r) +} + +// Parse decodes a document and validates it against r. +func (r *Registry) Parse(data []byte) (*Spec, error) { + spec, err := decode(data) + if err != nil { + return nil, err + } + if err := r.Validate(spec); err != nil { + return nil, err + } + return spec, nil +} + +func (r *Registry) rules() map[NodeType]NodeRule { + rules := make(map[NodeType]NodeRule, len(r.nodeTypes)) + for t, nt := range r.nodeTypes { + rules[t] = nt.rule + } + return rules +} + +func (r *Registry) hasFunction(name string) bool { _, ok := r.funcs[name]; return ok } +func (r *Registry) hasDynamic(name string) bool { _, ok := r.dynamics[name]; return ok } +func (r *Registry) hasTool(name string) bool { _, ok := r.tools[name]; return ok } + +func (r *Registry) hasToolset(name string) bool { _, ok := r.toolsets[name]; return ok } + +func (r *Registry) hasToolOrToolset(name string) bool { + _, ok := r.toolsets[name] + return ok || r.hasTool(name) +} + +// hasModel reports whether name can be resolved. With a resolver installed any +// name may resolve, and the resolver's own error surfaces from Build. +func (r *Registry) hasModel(name string) bool { + _, ok := r.models[name] + return ok || r.resolver != nil +} + +func (r *Registry) resolveModel(ctx context.Context, name string) (adkmodel.LLM, error) { + if m, ok := r.models[name]; ok { + return m, nil + } + if r.resolver != nil { + m, err := r.resolver(ctx, name) + if err != nil { + return nil, fmt.Errorf("failed to resolve model %q: %w", name, err) + } + return m, nil + } + return nil, fmt.Errorf("model %q is not registered", name) +} + +// resolveTools expands tool names into tools and toolsets. diff --git a/go/adk/pkg/declarative/registry_test.go b/go/adk/pkg/declarative/registry_test.go new file mode 100644 index 0000000000..98f3f867f8 --- /dev/null +++ b/go/adk/pkg/declarative/registry_test.go @@ -0,0 +1,188 @@ +package declarative + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/adk/v2/agent" + adkmodel "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/tool" + "google.golang.org/adk/v2/tool/functiontool" + "google.golang.org/adk/v2/workflow" +) + +func noopFunc(agent.Context, any) (Result, error) { return Result{}, nil } + +func TestRegistryRejectsBadRegistrations(t *testing.T) { + reg := NewRegistry() + require.NoError(t, reg.RegisterFunction("f", noopFunc)) + require.ErrorContains(t, reg.RegisterFunction("f", noopFunc), "already registered") + require.ErrorContains(t, reg.RegisterFunction("bad name", noopFunc), "must match") + require.ErrorContains(t, reg.RegisterFunction("nilfn", nil), "nil func") + + require.ErrorContains(t, reg.RegisterDynamic("d", nil), "nil func") + require.ErrorContains(t, reg.RegisterModel("stub", NewStubModel("stub")), "already registered") + require.ErrorContains(t, reg.RegisterModel("m", nil), "nil model") + require.ErrorContains(t, reg.RegisterModel("not valid!", NewStubModel("x")), "must match") + require.NoError(t, reg.RegisterModel("openai/gpt-4o:latest", NewStubModel("gpt"))) + + require.ErrorContains(t, reg.RegisterTool(nil), "nil tool") + require.ErrorContains(t, reg.RegisterToolset(nil), "nil toolset") + require.ErrorContains(t, reg.RegisterTool(reg.tools["load_memory"]), "already registered") +} + +func TestRegistryToolAndToolsetNamesAreDisjoint(t *testing.T) { + reg := NewRegistry() + ts := namedToolset("shared") + require.NoError(t, reg.RegisterToolset(ts)) + clash, err := functiontool.New(functiontool.Config{Name: "shared", Description: "d"}, + func(agent.Context, struct{}) (string, error) { return "", nil }) + require.NoError(t, err) + require.ErrorContains(t, reg.RegisterTool(clash), "used by another registration") +} + +type namedToolset string + +func (n namedToolset) Name() string { return string(n) } +func (n namedToolset) Tools(agent.ReadonlyContext) ([]tool.Tool, error) { return nil, nil } + +func TestRegistryBuiltinTools(t *testing.T) { + reg := NewRegistry() + tools, toolsets, err := (&builder{reg: reg}).resolveTools([]string{"preload_memory", "load_memory"}) + require.NoError(t, err) + require.Len(t, tools, 2) + require.Empty(t, toolsets) + _, _, err = (&builder{reg: reg}).resolveTools([]string{"ghost"}) + require.ErrorContains(t, err, `tool "ghost" is not registered`) +} + +func TestRegistryToolsetsResolve(t *testing.T) { + reg := NewRegistry() + require.NoError(t, reg.RegisterToolset(namedToolset("mcp_server"))) + tools, toolsets, err := (&builder{reg: reg}).resolveTools([]string{"mcp_server", "load_memory"}) + require.NoError(t, err) + require.Len(t, tools, 1) + require.Len(t, toolsets, 1) +} + +func TestRegisterNodeTypeRules(t *testing.T) { + reg := NewRegistry() + build := func(*BuildContext, *NodeSpec) (workflow.Node, error) { return nil, nil } + require.ErrorContains(t, reg.RegisterNodeType(NodeJoin, NodeRule{}, build), "built-in types cannot be replaced") + require.ErrorContains(t, reg.RegisterNodeType("bad-name", NodeRule{}, build), "must match") + require.ErrorContains(t, reg.RegisterNodeType("custom", NodeRule{}, nil), "nil builder") + require.NoError(t, reg.RegisterNodeType("custom", NodeRule{}, build)) + require.ErrorContains(t, reg.RegisterNodeType("custom", NodeRule{}, build), "already registered") +} + +func TestDynamicEnvBudget(t *testing.T) { + child := workflow.NewFunctionNode("c", func(agent.Context, any) (string, error) { return "ok", nil }, workflow.NodeConfig{}) + bounded := &DynamicEnv{MaxIters: 1} + bounded.runs.Store(1) + _, err := bounded.Run(child, nil) + require.ErrorIs(t, err, ErrIterationBudget) + require.Equal(t, 1, bounded.Runs(), "a refused run is not counted") +} + +func TestDynamicEnvRunChildRejectsUnknownIDs(t *testing.T) { + env := &DynamicEnv{} + _, err := env.RunChild("ghost", nil) + require.ErrorContains(t, err, `no child "ghost"`) + require.Zero(t, env.Runs()) +} + +func TestRegistryValidateReportsUnresolvedReferencesTogether(t *testing.T) { + reg := NewRegistry() + require.NoError(t, reg.RegisterFunction("known", noopFunc)) + _, err := reg.Parse([]byte(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - { name: a, agent_class: LlmAgent, model: gpt-nope, instruction: hi, tools: [load_memory, ghost_tool] } + - name: wf + agent_class: Workflow + nodes: + - { id: f, type: function, function: nope } + - { id: k, type: function, function: known } + - { id: t, type: tool, tool: ghost_tool } + - { id: d, type: dynamic, function: nope_dyn } + - { id: p, type: parallel_worker, function: nope_too } + - { id: l, type: llm, model: gpt-nope, instruction: hi, tools: [ghost_tool] } + - { id: end, type: function, function: known } + edges: + - { from: START, to: f } + - { from: f, to: k } + - { from: k, to: t } + - { from: t, to: d } + - { from: d, to: p } + - { from: p, to: l } + - { from: l, to: end } +`)) + require.True(t, IsValidation(err)) + for _, want := range []string{ + `agents[0].model: model "gpt-nope" is not registered`, + `agents[0].tools[1]: tool "ghost_tool" is not registered`, + `agents[1].nodes[0].function: function "nope" is not registered`, + `agents[1].nodes[2].tool: tool "ghost_tool" is not registered`, + `agents[1].nodes[3].function: dynamic function "nope_dyn" is not registered`, + `agents[1].nodes[4].function: function "nope_too" is not registered`, + `agents[1].nodes[5].model: model "gpt-nope" is not registered`, + `agents[1].nodes[5].tools[0]: tool "ghost_tool" is not registered`, + } { + require.Contains(t, err.Error(), want) + } + require.NotContains(t, err.Error(), "load_memory", "built-in tools resolve") + require.NotContains(t, err.Error(), `function "known"`) +} + +func TestToolsetsResolveInLLMToolListsButNotAsToolNodes(t *testing.T) { + reg := NewRegistry() + require.NoError(t, reg.RegisterToolset(namedToolset("mcp-server"))) + _, err := reg.Parse([]byte(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - { name: a, agent_class: LlmAgent, model: stub, instruction: hi, tools: [mcp-server] } + - name: wf + agent_class: Workflow + nodes: [{ id: t, type: tool, tool: mcp-server }, { id: f, type: function, function: f }] + edges: [{ from: START, to: t }, { from: t, to: f }] +`)) + require.ErrorContains(t, err, `agents[1].nodes[0].tool: tool "mcp-server" is not registered`) + require.NotContains(t, err.Error(), "agents[0]") +} + +func TestModelResolverSatisfiesReferenceCheck(t *testing.T) { + reg := NewRegistry() + reg.SetModelResolver(func(context.Context, string) (adkmodel.LLM, error) { return NewStubModel("x"), nil }) + _, err := reg.Parse([]byte(` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - { name: a, agent_class: LlmAgent, model: any-model, instruction: hi } +`)) + require.NoError(t, err) +} + +func TestResultHelpers(t *testing.T) { + require.Equal(t, Result{Output: 1}, Output(1)) + require.Equal(t, Result{Output: 1, Routes: []string{"a", "b"}}, Route(1, "a", "b")) +} + +func TestDynamicEnvBudgetHoldsUnderConcurrentRuns(t *testing.T) { + child := workflow.NewFunctionNode("c", func(agent.Context, any) (string, error) { return "ok", nil }, workflow.NodeConfig{}) + env := &DynamicEnv{MaxIters: 2} + env.runs.Store(2) + var wg sync.WaitGroup + for range 16 { + wg.Go(func() { + _, err := env.Run(child, nil) + assert.ErrorIs(t, err, ErrIterationBudget) + }) + } + wg.Wait() + require.Equal(t, 2, env.Runs(), "refused runs release their claim") +} diff --git a/go/adk/pkg/declarative/schema_test.go b/go/adk/pkg/declarative/schema_test.go new file mode 100644 index 0000000000..7835a14999 --- /dev/null +++ b/go/adk/pkg/declarative/schema_test.go @@ -0,0 +1,129 @@ +package declarative + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/adk/v2/agent" +) + +const personSchema = `{ type: object, properties: { name: { type: string } }, required: [name], additionalProperties: false }` + +func schemaRegistry(t *testing.T) *Registry { + t.Helper() + reg := testRegistry(t) + for name, fn := range map[string]Func{ + "make_person": func(_ agent.Context, in any) (Result, error) { + return Output(map[string]any{"name": fmt.Sprint(in)}), nil + }, + "make_wrong": func(_ agent.Context, in any) (Result, error) { + return Output(map[string]any{"title": fmt.Sprint(in)}), nil + }, + "greet": func(_ agent.Context, in any) (Result, error) { + person, _ := in.(map[string]any) + return Output("hello " + fmt.Sprint(person["name"])), nil + }, + } { + require.NoError(t, reg.RegisterFunction(name, fn)) + } + return reg +} + +func runSchemaDoc(t *testing.T, reg *Registry, nodes, edges string) error { + t.Helper() + spec, err := reg.Parse([]byte(docHead + " - name: wf\n agent_class: Workflow\n nodes:\n" + nodes + " edges: " + edges + "\n")) + require.NoError(t, err) + sys, err := Build(t.Context(), spec, reg) + if err != nil { + return err + } + svc, id := newSession(t) + _, err = tryRun(t, sys, svc, id, "Ada") + return err +} + +func TestNodeOutputSchemaValidatesWhatAFunctionReturns(t *testing.T) { + reg := schemaRegistry(t) + nodes := func(fn string) string { + return " - { id: make, type: function, function: " + fn + ", output_schema: " + personSchema + " }\n" + + " - { id: greet, type: function, function: greet }\n" + } + edges := "[{ from: START, to: make }, { from: make, to: greet }]" + require.NoError(t, runSchemaDoc(t, reg, nodes("make_person"), edges)) + err := runSchemaDoc(t, reg, nodes("make_wrong"), edges) + require.Error(t, err, "a result that breaks the contract fails the run") + require.ErrorContains(t, err, "make") +} + +func TestNodeInputSchemaValidatesWhatANodeReceives(t *testing.T) { + reg := schemaRegistry(t) + nodes := func(fn string) string { + return " - { id: make, type: function, function: " + fn + " }\n" + + " - { id: greet, type: function, function: greet, input_schema: " + personSchema + " }\n" + } + edges := "[{ from: START, to: make }, { from: make, to: greet }]" + require.NoError(t, runSchemaDoc(t, reg, nodes("make_person"), edges)) + require.Error(t, runSchemaDoc(t, reg, nodes("make_wrong"), edges)) +} + +func TestJoinInputSchemaAppliesToEachPredecessor(t *testing.T) { + reg := schemaRegistry(t) + nodes := func(second string) string { + return " - { id: one, type: function, function: make_person }\n" + + " - { id: two, type: function, function: " + second + " }\n" + + " - { id: gather, type: join, input_schema: " + personSchema + " }\n" + + " - { id: end, type: function, function: summarize_join }\n" + } + edges := "[{ from: START, to: one }, { from: START, to: two }, { from: one, to: gather }, { from: two, to: gather }, { from: gather, to: end }]" + require.NoError(t, runSchemaDoc(t, reg, nodes("make_person"), edges)) + err := runSchemaDoc(t, reg, nodes("make_wrong"), edges) + require.Error(t, err) + require.ErrorContains(t, err, "predecessor") +} + +func TestIncompatibleSchemasAreCaughtBeforeAnyRun(t *testing.T) { + reg := schemaRegistry(t) + err := runSchemaDoc(t, reg, + " - { id: make, type: function, function: make_person, output_schema: { type: string } }\n"+ + " - { id: greet, type: function, function: greet, input_schema: { type: integer } }\n", + "[{ from: START, to: make }, { from: make, to: greet }]") + require.ErrorContains(t, err, `agent "wf"`, "the engine compares the schemas on every edge when it builds the graph") + require.False(t, IsValidation(err)) +} + +func TestDynamicNodeOutputSchema(t *testing.T) { + reg := schemaRegistry(t) + require.NoError(t, reg.RegisterDynamic("person_maker", func(_ agent.Context, in any, _ *DynamicEnv) (any, error) { + return map[string]any{"name": fmt.Sprint(in)}, nil + })) + require.NoError(t, runSchemaDoc(t, reg, + " - { id: make, type: dynamic, function: person_maker, output_schema: "+personSchema+" }\n"+ + " - { id: greet, type: function, function: greet }\n", + "[{ from: START, to: make }, { from: make, to: greet }]")) +} + +func TestSchemaFieldsAreValidated(t *testing.T) { + tests := []struct { + name string + doc string + want string + }{ + {"malformed input_schema", wf("[{ id: f, type: function, function: f, input_schema: { type: 12 } }]", "[{ from: START, to: f }]"), + "nodes[0].input_schema: not a JSON Schema"}, + {"malformed output_schema", wf("[{ id: f, type: function, function: f, output_schema: { type: 12 } }]", "[{ from: START, to: f }]"), + "nodes[0].output_schema: not a JSON Schema"}, + {"unresolvable reference", wf("[{ id: f, type: function, function: f, input_schema: { $ref: '#/$defs/missing' } }]", "[{ from: START, to: f }]"), + "cannot resolve the schema"}, + {"input_schema is not allowed on hitl", wf("[{ id: h, type: hitl, message: m, input_schema: { type: string } }, { id: f, type: function, function: f }]", "[{ from: START, to: h }, { from: h, to: f }]"), + "nodes[0].input_schema: not allowed on node type hitl"}, + {"output_schema is not allowed on join", wf("[{ id: a, type: function, function: f }, { id: j, type: join, output_schema: { type: object } }, { id: f, type: function, function: f }]", "[{ from: START, to: a }, { from: a, to: j }, { from: j, to: f }]"), + "nodes[1].output_schema: not allowed on node type join"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse([]byte(tt.doc)) + require.ErrorContains(t, err, tt.want) + }) + } +} diff --git a/go/adk/pkg/declarative/spec.go b/go/adk/pkg/declarative/spec.go new file mode 100644 index 0000000000..2c835e08eb --- /dev/null +++ b/go/adk/pkg/declarative/spec.go @@ -0,0 +1,333 @@ +package declarative + +import "encoding/json" + +// Document identity. +const ( + APIVersion = "adk.declarative/v0.2" + KindAgentSystem = "AgentSystem" +) + +// AgentClass selects how an agent is built. +type AgentClass string + +const ( + // ClassWorkflow is a graph of nodes and edges executed by the ADK + // workflow engine. + ClassWorkflow AgentClass = "Workflow" + // ClassLlmAgent is a model-backed agent. + ClassLlmAgent AgentClass = "LlmAgent" + // ClassSequential runs its sub-agents one after another. + ClassSequential AgentClass = "SequentialAgent" + // ClassParallel runs its sub-agents concurrently in isolated branches. + ClassParallel AgentClass = "ParallelAgent" + // ClassLoop runs its sub-agents repeatedly until max_iterations or until a + // sub-agent escalates. + ClassLoop AgentClass = "LoopAgent" +) + +// NodeType selects how a workflow node is built. +type NodeType string + +const ( + NodeFunction NodeType = "function" + NodeJoin NodeType = "join" + NodeTool NodeType = "tool" + NodeAgent NodeType = "agent" + NodeLLM NodeType = "llm" + NodeWorkflow NodeType = "workflow" + NodeParallelWorker NodeType = "parallel_worker" + NodeDynamic NodeType = "dynamic" + NodeHITL NodeType = "hitl" + // NodeCEL computes its output, routes and state changes with CEL + // expressions instead of registered Go code. + NodeCEL NodeType = "cel" +) + +// Reserved edge endpoints and routes. +const ( + // Start is the edge source that seeds a workflow with the user input. + Start = "START" + // RouteDefault marks the edge taken when no other route of the source + // node matched. + RouteDefault = "DEFAULT" +) + +// Mode is the delegation mode of an LlmAgent. +type Mode string + +const ( + ModeChat Mode = "chat" + ModeTask Mode = "task" + ModeSingleTurn Mode = "single_turn" +) + +// Isolation says how much of the session a child run can see. Each level +// hides more than the one before it. +type Isolation string + +const ( + // IsolationShared runs the child on its parent's branch: it sees the + // whole conversation, including what earlier children said. This is the + // ADK default for children of a dynamic node. + IsolationShared Isolation = "shared" + // IsolationBranch gives every child run its own branch: it sees its + // ancestors' history but not its siblings'. Static fan-out, parallel + // workers and ParallelAgent always run this way. + IsolationBranch Isolation = "branch" + // IsolationScope also tags the child's events with a scope unique to the + // run, so it sees only its own events. + IsolationScope Isolation = "scope" +) + +// IncludeContents controls how much conversation history a model sees. +type IncludeContents string + +const ( + // IncludeContentsDefault sends the history that the ADK selects for the + // agent's placement. + IncludeContentsDefault IncludeContents = "default" + // IncludeContentsNone sends only the current turn. + IncludeContentsNone IncludeContents = "none" +) + +// Spec is an AgentSystem document. The first agent is the root. +type Spec struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Agents []AgentSpec `json:"agents"` + // MCPServers declares remote MCP servers. An LLM's tools list may name + // a server to offer its tools to the model, and a tool node may call one + // of its tools with server and tool. + MCPServers []MCPServerSpec `json:"mcp_servers,omitempty"` +} + +// MCPServerSpec is a remote MCP server reached over streamable HTTP. +type MCPServerSpec struct { + // Name is how agents and nodes refer to the server. + Name string `json:"name"` + // URL is the server endpoint. URLFrom reads it from the environment + // instead; exactly one is set. + URL string `json:"url,omitempty"` + URLFrom *TextSource `json:"url_from,omitempty"` + // Headers are sent with every request, for example credentials. + Headers []HeaderSpec `json:"headers,omitempty"` + // Tools, when set, is the allowlist of tool names the server exposes to + // the document. Unset exposes every tool the server lists. + Tools []string `json:"tools,omitempty"` +} + +// HeaderSpec is one HTTP header of an MCP server. Exactly one of Value and +// ValueFrom is set; ValueFrom reads from the runtime environment. +type HeaderSpec struct { + Name string `json:"name"` + Value string `json:"value,omitempty"` + ValueFrom *TextSource `json:"value_from,omitempty"` +} + +// LLMSpec is the model configuration shared by LlmAgent agents and inline llm +// nodes. It is embedded, so its fields sit directly on the owner in YAML. +type LLMSpec struct { + // Model names a registered model, or one the registry's resolver knows. + Model string `json:"model,omitempty"` + Mode Mode `json:"mode,omitempty"` + // Instruction is the system prompt of the agent. + Instruction string `json:"instruction,omitempty"` + // InstructionFrom reads the system prompt from a ConfigMap, a Secret or + // an environment variable instead. It excludes Instruction. + InstructionFrom *TextSource `json:"instruction_from,omitempty"` + // GlobalInstruction applies to the whole agent tree below the agent. + GlobalInstruction string `json:"global_instruction,omitempty"` + // GlobalInstructionFrom is the TextSource form of GlobalInstruction. + GlobalInstructionFrom *TextSource `json:"global_instruction_from,omitempty"` + // Tools names registered tools and toolsets. + Tools []string `json:"tools,omitempty"` + // OutputKey stores the final reply in session state under this key. + OutputKey string `json:"output_key,omitempty"` + // OutputSchema is a JSON Schema the reply must satisfy. + OutputSchema json.RawMessage `json:"output_schema,omitempty"` + IncludeContents IncludeContents `json:"include_contents,omitempty"` + // Callbacks accept or reject model calls and tool calls. + Callbacks *CallbacksSpec `json:"callbacks,omitempty"` +} + +// TextSource names where sourced text comes from. Exactly one field is set. +// +// ConfigMap and Secret references require an embedding resolver before the +// runtime starts. The kagent controller inlines ConfigMap prompt sources. +// Env is resolved by the runtime when the agent is built; MCP URL and header +// sources use Env only. +type TextSource struct { + ConfigMap *KeyRef `json:"config_map,omitempty"` + Secret *KeyRef `json:"secret,omitempty"` + Env string `json:"env,omitempty"` +} + +// KeyRef selects one key of a same-namespace ConfigMap or Secret. +type KeyRef struct { + Name string `json:"name"` + Key string `json:"key"` +} + +// HookAction is the declarative decision of a hook rule. +type HookAction string + +const ( + // HookAccept lets the call run unchanged and skips the remaining rules. + HookAccept HookAction = "accept" + // HookReject blocks the call: a model call is answered with Message, a + // tool call returns {"error": Message} to the model. + HookReject HookAction = "reject" +) + +// HookSpec is one rule of a callback point. Exactly one of Action and +// Function is set. Rules run in order until one accepts or rejects. +type HookSpec struct { + // When is a CEL condition; the rule applies only when it is true. Empty + // always applies. + When string `json:"when,omitempty"` + // Action decides declaratively. + Action HookAction `json:"action,omitempty"` + // Message explains a rejection. It is required with action reject. + Message string `json:"message,omitempty"` + // Function names a registered Hook that decides in Go. + Function string `json:"function,omitempty"` +} + +// CallbacksSpec lists the hook rules of each ADK callback point. +type CallbacksSpec struct { + BeforeModel []HookSpec `json:"before_model,omitempty"` + AfterModel []HookSpec `json:"after_model,omitempty"` + BeforeTool []HookSpec `json:"before_tool,omitempty"` + AfterTool []HookSpec `json:"after_tool,omitempty"` +} + +// AgentSpec declares one named agent. Which fields apply depends on +// AgentClass; see agentRules. +type AgentSpec struct { + Name string `json:"name"` + AgentClass AgentClass `json:"agent_class"` + Description string `json:"description,omitempty"` + // SubAgents names other agents of the document. For a Workflow they are + // made available to the engine; for an LlmAgent they are transfer + // targets; for Sequential, Parallel and Loop agents they are the agents + // being orchestrated. + SubAgents []string `json:"sub_agents,omitempty"` + + // Workflow fields. + + // Entry is shorthand for a START edge to the named node. It may repeat + // an explicit START edge. + Entry string `json:"entry,omitempty"` + Nodes []NodeSpec `json:"nodes,omitempty"` + Edges []EdgeSpec `json:"edges,omitempty"` + // MaxConcurrency bounds how many nodes of the whole graph run at once, + // across fan-out branches; further ready nodes wait. Zero is unlimited. + // Nested workflow nodes and the items of a parallel_worker run inside + // their parent node and are not counted separately. + MaxConcurrency int `json:"max_concurrency,omitempty"` + + // LlmAgent fields. + LLMSpec + DisallowTransferToParent bool `json:"disallow_transfer_to_parent,omitempty"` + DisallowTransferToPeers bool `json:"disallow_transfer_to_peers,omitempty"` + + // AgentTools names agents that this LlmAgent calls as tools. Every call + // runs in a new session seeded with a copy of the caller's state, so the + // agent never sees the caller's history and parallel calls cannot + // interfere. An agent is either a sub_agent (shared session, transfer) or + // an agent tool (separate session), not both. + AgentTools []string `json:"agent_tools,omitempty"` + + // MaxIterations bounds a LoopAgent. Zero loops until a sub-agent + // escalates. + MaxIterations uint `json:"max_iterations,omitempty"` +} + +// NodeSpec declares one workflow node. Which fields apply depends on Type; +// see builtinNodeRules. +type NodeSpec struct { + ID string `json:"id"` + Type NodeType `json:"type"` + Description string `json:"description,omitempty"` + + // Retry and Timeout apply to every node type that runs user code. + Retry *RetrySpec `json:"retry,omitempty"` + Timeout string `json:"timeout,omitempty"` + + // Function, Tool and Agent name the registered behavior a node runs. + Function string `json:"function,omitempty"` + Tool string `json:"tool,omitempty"` + Agent string `json:"agent,omitempty"` + // Server makes a tool node call Tool on a declared MCP server, or on a + // registered toolset, instead of a registered tool. + Server string `json:"server,omitempty"` + + // MaxConcurrency bounds a parallel_worker. Zero is unlimited. + MaxConcurrency int `json:"max_concurrency,omitempty"` + // MaxIters bounds the child runs of a dynamic node. + MaxIters int `json:"max_iters,omitempty"` + // Children names nodes of the same graph that a dynamic node may run by + // id. A child has no edges: only its dynamic parent schedules it. + Children []string `json:"children,omitempty"` + // Isolation sets how much of the session each child run of a dynamic + // node sees. Unset is shared. + Isolation Isolation `json:"isolation,omitempty"` + // InputSchema is a JSON Schema that the node's input must satisfy. On a + // join it applies to each predecessor's output separately. + InputSchema json.RawMessage `json:"input_schema,omitempty"` + // Message is the prompt a hitl node shows the human. + Message string `json:"message,omitempty"` + + // CEL node fields. Vars are named values the other expressions read as + // vars.. Output defaults to the node input. Route evaluates to a + // string or a list of strings; Routes is the rule-table alternative. + // StateDelta writes each key's value to session state. + Vars []CELVar `json:"vars,omitempty"` + Output string `json:"output,omitempty"` + Route string `json:"route,omitempty"` + Routes []CELRoute `json:"routes,omitempty"` + StateDelta map[string]string `json:"state_delta,omitempty"` + + // Inline llm node fields. OutputSchema also types the output of function + // and dynamic nodes: the JSON Schema their result must satisfy. + LLMSpec + + // Nested workflow fields. A nested workflow has its own START. + Nodes []NodeSpec `json:"nodes,omitempty"` + Edges []EdgeSpec `json:"edges,omitempty"` +} + +// CELVar is a named value of a cel node. Vars are evaluated in order, so an +// expression may read the vars declared before it. +type CELVar struct { + Name string `json:"name"` + Expr string `json:"expr"` +} + +// CELRoute is one rule of a cel node's route table. The first rule whose +// When is true selects its Route; an empty When always matches. When no rule +// matches, the DEFAULT edge is taken. +type CELRoute struct { + When string `json:"when,omitempty"` + Route string `json:"route"` +} + +// EdgeSpec connects two nodes. An empty Route is unconditional. +type EdgeSpec struct { + From string `json:"from"` + To string `json:"to"` + Route string `json:"route,omitempty"` +} + +// RetrySpec mirrors the ADK retry policy. Unset fields take the ADK defaults. +type RetrySpec struct { + MaxAttempts int `json:"max_attempts,omitempty"` + InitialDelay string `json:"initial_delay,omitempty"` + MaxDelay string `json:"max_delay,omitempty"` + BackoffFactor *float64 `json:"backoff_factor,omitempty"` + Jitter *float64 `json:"jitter,omitempty"` + // When is a CEL predicate over `error` ({message, timeout, canceled}) + // that decides whether a failure is retried. Empty retries every error. + When string `json:"when,omitempty"` +} diff --git a/go/adk/pkg/declarative/stub.go b/go/adk/pkg/declarative/stub.go new file mode 100644 index 0000000000..7dbe707a32 --- /dev/null +++ b/go/adk/pkg/declarative/stub.go @@ -0,0 +1,52 @@ +package declarative + +import ( + "context" + "iter" + "slices" + "strings" + + adkmodel "google.golang.org/adk/v2/model" + "google.golang.org/genai" +) + +// stubModel answers every request with a deterministic echo of the last +// user text. It lets documents run in tests and examples without credentials. +type stubModel struct{ name string } + +// NewStubModel returns an offline model that replies "[] ". It never calls tools. +func NewStubModel(name string) adkmodel.LLM { return stubModel{name: name} } + +func (m stubModel) Name() string { return m.name } + +func (m stubModel) GenerateContent(_ context.Context, req *adkmodel.LLMRequest, _ bool) iter.Seq2[*adkmodel.LLMResponse, error] { + return func(yield func(*adkmodel.LLMResponse, error) bool) { + reply := "[" + m.name + "] " + lastUserText(req) + yield(&adkmodel.LLMResponse{ + Content: genai.NewContentFromText(reply, genai.RoleModel), + TurnComplete: true, + }, nil) + } +} + +func lastUserText(req *adkmodel.LLMRequest) string { + if req == nil { + return "" + } + for _, c := range slices.Backward(req.Contents) { + if c == nil || c.Role != genai.RoleUser { + continue + } + var sb strings.Builder + for _, p := range c.Parts { + if p != nil { + sb.WriteString(p.Text) + } + } + if sb.Len() > 0 { + return sb.String() + } + } + return "" +} diff --git a/go/adk/pkg/declarative/testdata/composites.yaml b/go/adk/pkg/declarative/testdata/composites.yaml new file mode 100644 index 0000000000..eedaa8aa8d --- /dev/null +++ b/go/adk/pkg/declarative/testdata/composites.yaml @@ -0,0 +1,46 @@ +# The classic ADK workflow agents: sequential, parallel and loop orchestration of +# sub-agents. Each worker is a stub-model LlmAgent that echoes its input. +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: pipeline + agent_class: SequentialAgent + description: Drafts, then reviews + sub_agents: [draft, review] + + - name: draft + agent_class: LlmAgent + model: stub + instruction: Write a draft. + output_key: draft_text + + - name: review + agent_class: LlmAgent + model: stub + instruction: Review the draft. + + - name: panel + agent_class: ParallelAgent + description: Two perspectives at once + sub_agents: [optimist, pessimist] + + - name: optimist + agent_class: LlmAgent + model: stub + instruction: Argue for it. + + - name: pessimist + agent_class: LlmAgent + model: stub + instruction: Argue against it. + + - name: refiner + agent_class: LoopAgent + description: Refines twice + max_iterations: 2 + sub_agents: [polish] + + - name: polish + agent_class: LlmAgent + model: stub + instruction: Improve the text. diff --git a/go/adk/pkg/declarative/testdata/showcase.yaml b/go/adk/pkg/declarative/testdata/showcase.yaml new file mode 100644 index 0000000000..f931bb69fe --- /dev/null +++ b/go/adk/pkg/declarative/testdata/showcase.yaml @@ -0,0 +1,138 @@ +# All-options showcase: a root Workflow plus LlmAgent specialists covering every +# built-in node type, StringRoute/DEFAULT routing, fan-out/join, nesting, +# parallel workers, dynamic orchestration, and human-in-the-loop. +# +# ADK graph constraints this document respects: +# - A mid-graph agent or llm node runs single_turn; chat is only valid from START +# and task agents cannot be graph nodes. +# - Mutually exclusive routes must not fan in to a non-join node, so each route +# ends in its own terminal. +# - A join is for fan-out that always runs every branch. +# - An agent has one parent: it appears in at most one sub_agents list. +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: multi_agent_root + agent_class: Workflow + description: Multi-agent orchestrator with fan-out/join, string routes and every node type + entry: branch_a + # Graph-wide cap: at most two nodes run at once across the fan-out. + max_concurrency: 2 + sub_agents: [specialist_helper] + nodes: + # Fan-out branches and their join. + - { id: branch_a, type: function, function: branch_a } + - { id: branch_b, type: function, function: branch_b } + - { id: branch_c, type: function, function: branch_c } + - { id: gather, type: join } + + # Route dispatcher. + - { id: classify, type: function, function: route_by_text } + + # BUG: parallel_worker -> nested workflow -> terminal. + - { id: map_items, type: parallel_worker, function: prefix, max_concurrency: 4 } + - id: nested + type: workflow + nodes: + - { id: n_plan, type: function, function: plan } + - { id: n_exec, type: function, function: execute } + edges: + - { from: START, to: n_plan } + - { from: n_plan, to: n_exec } + - { id: finish_bug, type: function, function: summarize_join } + + # BILLING: tool node -> terminal. + - { id: lookup, type: tool, tool: lookup_customer } + - { id: finish_billing, type: function, function: summarize_join } + + # SUPPORT: agent node -> terminal. + - { id: call_specialist, type: agent, agent: specialist_helper } + - { id: finish_support, type: function, function: summarize_join } + + # OTHER: inline llm -> dynamic -> hitl -> terminal. + - id: inline_llm + type: llm + model: stub + description: Inline LLM node for OTHER tickets + instruction: Summarize the ticket in one short sentence. + tools: [lookup_customer] + output_key: ticket_summary + - { id: refine, type: dynamic, function: critique, max_iters: 2, children: [critic] } + # Owned by `refine`: no edges, scheduled only by the dynamic function. + - { id: critic, type: function, function: critic_step } + - { id: approve, type: hitl, message: "Confirm final reply before send (yes/no)" } + - { id: finish_other, type: function, function: summarize_join } + + # Fallback terminal. + - id: finish_default + type: function + function: prefix + timeout: 30s + retry: { max_attempts: 2, initial_delay: 10ms, max_delay: 50ms, backoff_factor: 2, jitter: 0 } + + edges: + - { from: START, to: branch_a } + - { from: START, to: branch_b } + - { from: START, to: branch_c } + - { from: branch_a, to: gather } + - { from: branch_b, to: gather } + - { from: branch_c, to: gather } + - { from: gather, to: classify } + - { from: classify, to: map_items, route: BUG } + - { from: classify, to: lookup, route: BILLING } + - { from: classify, to: call_specialist, route: SUPPORT } + - { from: classify, to: inline_llm, route: OTHER } + - { from: classify, to: finish_default, route: DEFAULT } + - { from: map_items, to: nested } + - { from: nested, to: finish_bug } + - { from: lookup, to: finish_billing } + - { from: call_specialist, to: finish_support } + - { from: inline_llm, to: refine } + - { from: refine, to: approve } + - { from: approve, to: finish_other } + + - name: specialist_helper + agent_class: LlmAgent + model: stub + mode: single_turn + description: Support specialist with memory tools and a sub-agent + instruction: | + Handle SUPPORT tickets. Use preload_memory / load_memory when useful. + Delegate coding details to code_expert. + tools: [preload_memory, load_memory, lookup_customer] + output_key: specialist_reply + sub_agents: [code_expert] + + - name: code_expert + agent_class: LlmAgent + model: stub + mode: single_turn + description: Coding helper sub-agent + instruction: Answer coding questions briefly with a short example. + global_instruction: Be concise. + include_contents: none + disallow_transfer_to_peers: true + + # A chat coordinator is not a graph node; it documents the chat + sub_agents + # pattern and is addressable through System.Agent. + - name: learning_coordinator + agent_class: LlmAgent + model: stub + mode: chat + description: Chat coordinator that delegates to a docs expert + instruction: Delegate documentation questions to docs_expert and answer the rest yourself. + tools: [lookup_customer] + output_key: coordinator_reply + sub_agents: [docs_expert] + + - name: docs_expert + agent_class: LlmAgent + model: stub + mode: single_turn + description: Documentation helper + instruction: Answer documentation questions briefly. + output_schema: + type: object + properties: + answer: { type: string } + required: [answer] diff --git a/go/adk/pkg/declarative/validate.go b/go/adk/pkg/declarative/validate.go new file mode 100644 index 0000000000..4c42df802b --- /dev/null +++ b/go/adk/pkg/declarative/validate.go @@ -0,0 +1,1060 @@ +package declarative + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "net/url" + "regexp" + "slices" + "strings" + "time" + + "github.com/google/jsonschema-go/jsonschema" + + adkoutputschema "github.com/kagent-dev/kagent/go/adk/pkg/outputschema" +) + +// maxNesting bounds nested workflow nodes so that a document cannot drive the +// recursive builder arbitrarily deep. +const maxNesting = 8 + +// Issue is one validation failure located by a field path such as +// agents[0].nodes[2].function. +type Issue struct { + Path string + Message string +} + +func (i Issue) String() string { return i.Path + ": " + i.Message } + +// ValidationError aggregates every issue found in a document so an author +// fixes them in one pass. +type ValidationError struct { + Issues []Issue +} + +func (e *ValidationError) Error() string { + lines := make([]string, len(e.Issues)) + for i, issue := range e.Issues { + lines[i] = issue.String() + } + return fmt.Sprintf("invalid %s document:\n %s", KindAgentSystem, strings.Join(lines, "\n ")) +} + +// IsValidation reports whether err is, or wraps, a *ValidationError. +func IsValidation(err error) bool { + var ve *ValidationError + return errors.As(err, &ve) +} + +// identifier is the shape of agent names and node ids. It matches the names +// ADK accepts for agents and keeps ids usable in event paths. +var identifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// modelName additionally allows the separators provider model ids use. +var modelName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]*$`) + +// toolName allows the characters that MCP and function tool names use. +var toolName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.:-]*$`) + +// Validate checks everything that can be decided from the document alone: +// field shapes, per-class and per-type field rules, and name references +// between agents and nodes. Whether the Go values a document names exist is +// checked by Registry.Validate; graph semantics (reachability, cycles, fan-in) +// are checked by Build, where the ADK workflow engine owns them. +func Validate(spec *Spec) error { + return validate(spec, nil, nil) +} + +// ValidateWithRules is Validate for documents that use node types added with +// Registry.RegisterNodeType. extra rules take precedence over the built-ins. +func ValidateWithRules(spec *Spec, extra map[NodeType]NodeRule) error { + return validate(spec, extra, nil) +} + +func validate(spec *Spec, extra map[NodeType]NodeRule, refs *Registry) error { + rules := maps.Clone(builtinNodeRules) + maps.Copy(rules, extra) + v := &validator{rules: rules, refs: refs} + v.spec(spec) + if len(v.issues) == 0 { + return nil + } + return &ValidationError{Issues: v.issues} +} + +type validator struct { + // agents indexes the document's agents by name. + agents map[string]*AgentSpec + rules map[NodeType]NodeRule + // refs, when set, resolves the names a document uses to registrations. + refs *Registry + // servers indexes the document's MCP servers by name. + servers map[string]*MCPServerSpec + issues []Issue +} + +func (v *validator) addf(path, format string, args ...any) { + v.issues = append(v.issues, Issue{Path: path, Message: fmt.Sprintf(format, args...)}) +} + +// checkFields reports required fields that are unset and set fields that the +// owner accepts neither as required nor as optional. +func (v *validator) checkFields(path, owner string, present map[string]bool, required, optional []string) { + for _, field := range required { + if !present[field] { + v.addf(path+"."+field, "required for %s", owner) + } + } + for _, field := range slices.Sorted(maps.Keys(present)) { + if present[field] && !slices.Contains(required, field) && !slices.Contains(optional, field) { + v.addf(path+"."+field, "not allowed on %s", owner) + } + } +} + +func (v *validator) spec(spec *Spec) { + if spec == nil { + v.addf("$", "document is required") + return + } + if spec.APIVersion != APIVersion { + v.addf("apiVersion", "must be %q, got %q", APIVersion, spec.APIVersion) + } + if spec.Kind != KindAgentSystem { + v.addf("kind", "must be %q, got %q", KindAgentSystem, spec.Kind) + } + v.mcpServers(spec.MCPServers) + if len(spec.Agents) == 0 { + v.addf("agents", "at least one agent is required") + return + } + + classes := make(map[string]AgentClass, len(spec.Agents)) + v.agents = make(map[string]*AgentSpec, len(spec.Agents)) + for i, a := range spec.Agents { + path := fmt.Sprintf("agents[%d]", i) + switch { + case !identifier.MatchString(a.Name): + v.addf(path+".name", "%q must match %s", a.Name, identifier) + case a.Name == "user": + v.addf(path+".name", `"user" is reserved for end-user input`) + default: + if _, dup := classes[a.Name]; dup { + v.addf(path+".name", "duplicate agent name %q", a.Name) + } + classes[a.Name] = a.AgentClass + v.agents[a.Name] = &spec.Agents[i] + } + } + for i := range spec.Agents { + v.agent(fmt.Sprintf("agents[%d]", i), &spec.Agents[i], classes) + } + v.agentTree(spec) + v.agentReferences(spec) +} + +// agentRule lists the AgentSpec fields a class requires and accepts beyond +// name and agent_class. +type agentRule struct { + required, optional []string +} + +var llmAgentFields = []string{"mode", "global_instruction", "instruction_from", "global_instruction_from", "callbacks", "tools", "output_key", "output_schema", "include_contents"} + +var agentRules = map[AgentClass]agentRule{ + ClassWorkflow: {required: []string{"nodes"}, optional: []string{"description", "sub_agents", "entry", "edges", "max_concurrency"}}, + ClassLlmAgent: { + required: []string{"model", "instruction"}, + optional: append([]string{"description", "sub_agents", "agent_tools", "disallow_transfer_to_parent", "disallow_transfer_to_peers"}, llmAgentFields...), + }, + ClassSequential: {required: []string{"sub_agents"}, optional: []string{"description"}}, + ClassParallel: {required: []string{"sub_agents"}, optional: []string{"description"}}, + ClassLoop: {required: []string{"sub_agents"}, optional: []string{"description", "max_iterations"}}, +} + +func presentAgentFields(a *AgentSpec) map[string]bool { + present := llmPresent(&a.LLMSpec) + maps.Copy(present, map[string]bool{ + "description": a.Description != "", "sub_agents": len(a.SubAgents) > 0, + "entry": a.Entry != "", "nodes": len(a.Nodes) > 0, "edges": len(a.Edges) > 0, + "agent_tools": len(a.AgentTools) > 0, + "disallow_transfer_to_parent": a.DisallowTransferToParent, + "disallow_transfer_to_peers": a.DisallowTransferToPeers, + "max_iterations": a.MaxIterations != 0, + "max_concurrency": a.MaxConcurrency != 0, + }) + return present +} + +func llmPresent(l *LLMSpec) map[string]bool { + return map[string]bool{ + "model": l.Model != "", "mode": l.Mode != "", + // A sourced instruction satisfies the instruction requirement. + "instruction": l.Instruction != "" || l.InstructionFrom != nil, + "instruction_from": l.InstructionFrom != nil, + "global_instruction": l.GlobalInstruction != "", "global_instruction_from": l.GlobalInstructionFrom != nil, + "callbacks": l.Callbacks != nil, "tools": len(l.Tools) > 0, + "output_key": l.OutputKey != "", "output_schema": len(l.OutputSchema) > 0, + "include_contents": l.IncludeContents != "", + } +} + +func (v *validator) agent(path string, a *AgentSpec, classes map[string]AgentClass) { + for i, name := range a.SubAgents { + p := fmt.Sprintf("%s.sub_agents[%d]", path, i) + if _, ok := classes[name]; !ok { + v.addf(p, "unknown agent %q", name) + } else if name == a.Name { + v.addf(p, "an agent cannot be its own sub-agent") + } + } + + seenTools := map[string]bool{} + for i, name := range a.AgentTools { + p := fmt.Sprintf("%s.agent_tools[%d]", path, i) + switch { + case classes[name] == "": + v.addf(p, "unknown agent %q", name) + case name == a.Name: + v.addf(p, "an agent cannot be its own tool") + case seenTools[name]: + v.addf(p, "duplicate agent tool %q", name) + } + seenTools[name] = true + } + if a.AgentClass == ClassParallel { + v.concurrentOutputKeys(path+".sub_agents", a.SubAgents, "sub-agents of a ParallelAgent") + } + + rule, ok := agentRules[a.AgentClass] + if !ok { + v.addf(path+".agent_class", "must be one of %s, got %q", strings.Join(agentClassNames(), ", "), a.AgentClass) + return + } + v.checkFields(path, "agent_class "+string(a.AgentClass), presentAgentFields(a), rule.required, rule.optional) + + switch a.AgentClass { + case ClassWorkflow: + if a.MaxConcurrency < 0 { + v.addf(path+".max_concurrency", "must not be negative") + } + v.graph(path, a.Nodes, a.Edges, a.Entry, classes, 0, true) + case ClassLlmAgent: + v.llm(path, &a.LLMSpec) + } +} + +func agentClassNames() []string { + names := make([]string, 0, len(agentRules)) + for c := range agentRules { + names = append(names, string(c)) + } + slices.Sort(names) + return names +} + +// llm validates the fields shared by LlmAgent agents and llm nodes. +func (v *validator) llm(path string, l *LLMSpec) { + switch l.Mode { + case "", ModeChat, ModeTask, ModeSingleTurn: + default: + v.addf(path+".mode", "must be one of chat, task, single_turn, got %q", l.Mode) + } + switch l.IncludeContents { + case "", IncludeContentsDefault, IncludeContentsNone: + default: + v.addf(path+".include_contents", "must be default or none, got %q", l.IncludeContents) + } + if len(l.OutputSchema) > 0 { + if _, err := adkoutputschema.ToGenAISchema(l.OutputSchema); err != nil { + v.addf(path+".output_schema", "%v", err) + } + } + if l.Model != "" { + switch { + case !modelName.MatchString(l.Model): + v.addf(path+".model", "%q must match %s", l.Model, modelName) + case v.refs != nil && !v.refs.hasModel(l.Model): + v.addf(path+".model", "model %q is not registered", l.Model) + } + } + if l.Instruction != "" && l.InstructionFrom != nil { + v.addf(path+".instruction_from", "instruction and instruction_from are mutually exclusive") + } + if l.GlobalInstruction != "" && l.GlobalInstructionFrom != nil { + v.addf(path+".global_instruction_from", "global_instruction and global_instruction_from are mutually exclusive") + } + v.textSource(path+".instruction_from", l.InstructionFrom) + v.textSource(path+".global_instruction_from", l.GlobalInstructionFrom) + v.callbacks(path+".callbacks", l.Callbacks) + seen := map[string]bool{} + for i, t := range l.Tools { + p := fmt.Sprintf("%s.tools[%d]", path, i) + switch { + case seen[t]: + v.addf(p, "duplicate tool %q", t) + case v.servers[t] != nil: + case v.refs != nil && !v.refs.hasToolOrToolset(t): + v.addf(p, "tool %q is not registered or declared in mcp_servers", t) + } + seen[t] = true + } +} + +// NodeRule lists the NodeSpec fields a node type requires and accepts beyond +// the common ones (id, type, description). Field names are the JSON names. +type NodeRule struct { + Required []string + Optional []string +} + +var builtinNodeRules = map[NodeType]NodeRule{ + NodeFunction: {Required: []string{"function"}, Optional: []string{"input_schema", "output_schema", "retry", "timeout"}}, + NodeJoin: {Optional: []string{"input_schema"}}, + NodeTool: {Required: []string{"tool"}, Optional: []string{"server", "retry", "timeout"}}, + NodeAgent: {Required: []string{"agent"}, Optional: []string{"input_schema", "retry", "timeout"}}, + NodeLLM: { + Required: []string{"model", "instruction"}, + Optional: []string{"mode", "global_instruction", "instruction_from", "global_instruction_from", "callbacks", "tools", "output_key", "input_schema", "output_schema", "include_contents", "retry", "timeout"}, + }, + NodeWorkflow: {Required: []string{"nodes", "edges"}}, + NodeParallelWorker: {Optional: []string{"function", "tool", "agent", "max_concurrency", "retry", "timeout"}}, + NodeDynamic: {Required: []string{"function"}, Optional: []string{"max_iters", "children", "isolation", "input_schema", "output_schema", "retry", "timeout"}}, + NodeHITL: {Required: []string{"message"}}, + NodeCEL: {Optional: []string{"vars", "output", "route", "routes", "state_delta", "input_schema", "output_schema", "retry", "timeout"}}, +} + +// structuredTerminals are node types whose output is structured data that no +// A2A client displays; a branch must not end on one. +var structuredTerminals = []NodeType{NodeTool, NodeJoin, NodeParallelWorker} + +// presentFields names the type-specific fields a node sets. +func presentFields(n *NodeSpec) map[string]bool { + present := llmPresent(&n.LLMSpec) + maps.Copy(present, map[string]bool{ + "function": n.Function != "", "tool": n.Tool != "", "agent": n.Agent != "", "server": n.Server != "", + "max_concurrency": n.MaxConcurrency != 0, "max_iters": n.MaxIters != 0, + "children": len(n.Children) > 0, "isolation": n.Isolation != "", "message": n.Message != "", + "input_schema": len(n.InputSchema) > 0, + "nodes": len(n.Nodes) > 0, "edges": len(n.Edges) > 0, + "retry": n.Retry != nil, "timeout": n.Timeout != "", + "vars": len(n.Vars) > 0, "output": n.Output != "", "route": n.Route != "", + "routes": len(n.Routes) > 0, "state_delta": len(n.StateDelta) > 0, + }) + return present +} + +// graph validates one node/edge scope: the root of a Workflow agent or the +// body of a nested workflow node. terminal reports whether the scope's own +// terminal nodes end the run, which holds for the root scope and for nested +// scopes whose workflow node is itself terminal. +func (v *validator) graph(path string, nodes []NodeSpec, edges []EdgeSpec, entry string, classes map[string]AgentClass, depth int, terminal bool) { + if depth > maxNesting { + v.addf(path, "workflow nodes are nested deeper than %d levels", maxNesting) + return + } + + ids := make(map[string]bool, len(nodes)) + for i := range nodes { + n := &nodes[i] + p := fmt.Sprintf("%s.nodes[%d]", path, i) + switch { + case !identifier.MatchString(n.ID): + v.addf(p+".id", "%q must match %s", n.ID, identifier) + case n.ID == Start: + v.addf(p+".id", "%q is reserved", Start) + case ids[n.ID]: + v.addf(p+".id", "duplicate node id %q", n.ID) + default: + ids[n.ID] = true + } + } + + outgoing := make(map[string]bool, len(edges)) + for _, e := range edges { + outgoing[e.From] = true + } + children := v.children(path, nodes, ids) + + agentTargets := make(map[string]string) + for i := range nodes { + n := &nodes[i] + p := fmt.Sprintf("%s.nodes[%d]", path, i) + v.node(p, n, classes, depth, terminal && !outgoing[n.ID] && !children[n.ID]) + if n.Agent != "" && (n.Type == NodeAgent || n.Type == NodeParallelWorker) { + if prev, dup := agentTargets[n.Agent]; dup { + v.addf(p+".agent", "agent %q is already used by node %q in this graph; ADK names an agent node after its agent", n.Agent, prev) + } + agentTargets[n.Agent] = n.ID + } + } + for _, n := range nodes { + if n.Type == NodeAgent && ids[n.Agent] { + v.addf(path+".nodes", "agent %q collides with a node id in the same graph", n.Agent) + } + } + + v.edges(path, nodes, edges, entry, ids, children) +} + +// children validates the children lists of dynamic nodes and returns the set +// of ids that some dynamic node owns. +func (v *validator) children(path string, nodes []NodeSpec, ids map[string]bool) map[string]bool { + owned := map[string]bool{} + for i := range nodes { + n := &nodes[i] + for j, child := range n.Children { + p := fmt.Sprintf("%s.nodes[%d].children[%d]", path, i, j) + switch { + case !ids[child]: + v.addf(p, "unknown node %q", child) + case child == n.ID: + v.addf(p, "a node cannot be its own child") + default: + owned[child] = true + } + } + } + return owned +} + +func (v *validator) edges(path string, nodes []NodeSpec, edges []EdgeSpec, entry string, ids, children map[string]bool) { + if entry != "" && !ids[entry] { + v.addf(path+".entry", "unknown node %q", entry) + } + hasStart := entry != "" + connected := map[string]bool{entry: true} + for child := range children { + connected[child] = true + } + seen := map[EdgeSpec]bool{} + defaults := map[string]bool{} + for i, e := range edges { + p := fmt.Sprintf("%s.edges[%d]", path, i) + if e.From != Start && !ids[e.From] { + v.addf(p+".from", "unknown node %q", e.From) + } + switch { + case e.To == Start: + v.addf(p+".to", "%q cannot be an edge target", Start) + case e.To == "END" && !ids[e.To]: + v.addf(p+".to", "there is no END node; a node without outgoing edges ends its branch") + case !ids[e.To]: + v.addf(p+".to", "unknown node %q", e.To) + } + for _, end := range []string{e.From, e.To} { + if children[end] { + v.addf(p, "node %q is a dynamic child and cannot have edges", end) + } + } + connected[e.From], connected[e.To] = true, true + if e.From == Start { + hasStart = true + if e.Route != "" { + v.addf(p+".route", "%q does not emit routes", Start) + } + } + if seen[e] { + v.addf(p, "duplicate edge") + } + seen[e] = true + if e.Route == RouteDefault { + if defaults[e.From] { + v.addf(p+".route", "node %q has more than one %s edge", e.From, RouteDefault) + } + defaults[e.From] = true + } + } + v.fanOutOutputKeys(path, nodes, edges) + for i, n := range nodes { + if ids[n.ID] && !connected[n.ID] { + v.addf(fmt.Sprintf("%s.nodes[%d]", path, i), "node %q is not connected by any edge", n.ID) + } + } + if !hasStart && len(nodes) > 0 { + v.addf(path+".edges", "no edge from %s and no entry: the graph has no starting node", Start) + } +} + +func (v *validator) node(path string, n *NodeSpec, classes map[string]AgentClass, depth int, terminal bool) { + rule, ok := v.rules[n.Type] + if !ok { + types := make([]string, 0, len(v.rules)) + for t := range v.rules { + types = append(types, string(t)) + } + slices.Sort(types) + v.addf(path+".type", "unknown node type %q (known: %s)", n.Type, strings.Join(types, ", ")) + return + } + v.checkFields(path, "node type "+string(n.Type), presentFields(n), rule.Required, rule.Optional) + + v.nodeSchemas(path, n) + + if terminal && slices.Contains(structuredTerminals, n.Type) { + v.addf(path, "terminal %s node emits structured output that A2A clients do not display; end the branch with a function node", n.Type) + } + + switch n.Type { + case NodeFunction: + v.function(path, n.Function) + case NodeTool: + if n.Server != "" { + v.mcpToolNode(path, n) + } else { + v.toolNode(path, n.Tool) + } + case NodeParallelWorker: + v.parallelWorker(path, n, classes) + case NodeDynamic: + if n.MaxIters < 0 { + v.addf(path+".max_iters", "must not be negative") + } + switch n.Isolation { + case "", IsolationShared, IsolationBranch, IsolationScope: + default: + v.addf(path+".isolation", "must be one of shared, branch, scope, got %q", n.Isolation) + } + if n.Function != "" && v.refs != nil && !v.refs.hasDynamic(n.Function) { + v.addf(path+".function", "dynamic function %q is not registered", n.Function) + } + case NodeLLM: + v.llm(path, &n.LLMSpec) + if n.Mode != "" && n.Mode != ModeSingleTurn { + v.addf(path+".mode", "an inline llm node runs mid-graph and must be single_turn, got %q", n.Mode) + } + case NodeAgent: + v.agentTarget(path+".agent", n.Agent, classes) + case NodeWorkflow: + v.graph(path, n.Nodes, n.Edges, "", classes, depth+1, terminal) + case NodeCEL: + v.celNode(path, n) + } + + if n.Timeout != "" { + if d, err := time.ParseDuration(n.Timeout); err != nil || d <= 0 { + v.addf(path+".timeout", "must be a positive duration such as 30s, got %q", n.Timeout) + } + } + if n.Retry != nil { + v.retry(path+".retry", n.Retry) + } +} + +// nodeSchemas checks that a node's contracts are JSON Schemas the engine can +// use. An inline llm node's output_schema is the model's reply schema and is +// checked with the other model options. +func (v *validator) nodeSchemas(path string, n *NodeSpec) { + if len(n.InputSchema) > 0 { + if _, err := parseSchema(n.InputSchema); err != nil { + v.addf(path+".input_schema", "%v", err) + } + } + if n.Type != NodeLLM && len(n.OutputSchema) > 0 { + if _, err := parseSchema(n.OutputSchema); err != nil { + v.addf(path+".output_schema", "%v", err) + } + } +} + +// parseSchema decodes and resolves a JSON Schema, so a malformed or +// unresolvable one fails validation instead of the first run. +func parseSchema(raw json.RawMessage) (*jsonschema.Schema, error) { + var schema jsonschema.Schema + if err := json.Unmarshal(raw, &schema); err != nil { + return nil, fmt.Errorf("not a JSON Schema: %w", err) + } + if _, err := schema.Resolve(nil); err != nil { + return nil, fmt.Errorf("cannot resolve the schema: %w", err) + } + return &schema, nil +} + +func (v *validator) function(path, name string) { + if name != "" && v.refs != nil && !v.refs.hasFunction(name) { + v.addf(path+".function", "function %q is not registered", name) + } +} + +func (v *validator) toolNode(path, name string) { + switch { + case name == "": + case !toolName.MatchString(name): + v.addf(path+".tool", "%q must match %s", name, toolName) + case v.refs != nil && !v.refs.hasTool(name): + v.addf(path+".tool", "tool %q is not registered", name) + } +} + +func (v *validator) agentTarget(path, name string, classes map[string]AgentClass) { + if name == "" { + return + } + class, known := classes[name] + switch { + case !known: + v.addf(path, "unknown agent %q", name) + case class == ClassWorkflow: + v.addf(path, "agent %q is a Workflow; use a workflow node to nest graphs", name) + } +} + +func (v *validator) parallelWorker(path string, n *NodeSpec, classes map[string]AgentClass) { + targets := 0 + for _, set := range []bool{n.Function != "", n.Tool != "", n.Agent != ""} { + if set { + targets++ + } + } + if targets != 1 { + v.addf(path, "parallel_worker needs exactly one of function, tool or agent, got %d", targets) + } + if n.MaxConcurrency < 0 { + v.addf(path+".max_concurrency", "must not be negative") + } + v.function(path, n.Function) + v.toolNode(path, n.Tool) + v.agentTarget(path+".agent", n.Agent, classes) + if target := v.agents[n.Agent]; target != nil && target.OutputKey != "" { + v.addf(path+".agent", "every concurrent item writes the state key %q of agent %q; drop its output_key and read the worker's output", target.OutputKey, n.Agent) + } +} + +func (v *validator) retry(path string, r *RetrySpec) { + if r.MaxAttempts < 0 { + v.addf(path+".max_attempts", "must not be negative") + } + for field, value := range map[string]string{"initial_delay": r.InitialDelay, "max_delay": r.MaxDelay} { + if value == "" { + continue + } + if d, err := time.ParseDuration(value); err != nil || d < 0 { + v.addf(path+"."+field, "must be a non-negative duration such as 1s, got %q", value) + } + } + if r.BackoffFactor != nil && *r.BackoffFactor <= 0 { + v.addf(path+".backoff_factor", "must be positive") + } + if r.Jitter != nil && (*r.Jitter < 0 || *r.Jitter > 1) { + v.addf(path+".jitter", "must be within [0, 1]") + } + if r.When != "" { + if _, err := compileCEL(scopeRetry, r.When, resultBool); err != nil { + v.addf(path+".when", "%v", err) + } + } +} + +// agentTree checks that sub_agents form a forest: ADK names must be unique +// within an agent tree and a sub-agent has one parent. +func (v *validator) agentTree(spec *Spec) { + parent := map[string]string{} + for i, a := range spec.Agents { + for j, sub := range a.SubAgents { + p := fmt.Sprintf("agents[%d].sub_agents[%d]", i, j) + if prev, taken := parent[sub]; taken { + v.addf(p, "agent %q is already a sub-agent of %q; an agent has one parent", sub, prev) + continue + } + parent[sub] = a.Name + } + } + for i, a := range spec.Agents { + for j, tool := range a.AgentTools { + if owner, isSub := parent[tool]; isSub { + v.addf(fmt.Sprintf("agents[%d].agent_tools[%d]", i, j), "agent %q is a sub-agent of %q; an agent is either a sub-agent (shared session) or an agent tool (its own session)", tool, owner) + } + } + } + for i, a := range spec.Agents { + seen := map[string]bool{a.Name: true} + for cur, ok := parent[a.Name]; ok; cur, ok = parent[cur] { + if seen[cur] { + v.addf(fmt.Sprintf("agents[%d].sub_agents", i), "agent %q is part of a sub-agent cycle", a.Name) + break + } + seen[cur] = true + } + } +} + +// concurrentOutputKeys reports agents that run side by side and write the same +// session state key. Branches isolate history, not state: a shared key is +// written by whichever branch finishes last. +func (v *validator) concurrentOutputKeys(path string, agentNames []string, what string) { + owner := map[string]string{} + for i, name := range agentNames { + target := v.agents[name] + if target == nil || target.OutputKey == "" { + continue + } + if prev, taken := owner[target.OutputKey]; taken { + v.addf(fmt.Sprintf("%s[%d]", path, i), "%s %q and %q both write the state key %q; give each its own output_key", what, prev, name, target.OutputKey) + continue + } + owner[target.OutputKey] = name + } +} + +// fanOutOutputKeys applies the same rule to the unconditional successors of +// one node, which the engine starts concurrently. +func (v *validator) fanOutOutputKeys(path string, nodes []NodeSpec, edges []EdgeSpec) { + byID := make(map[string]*NodeSpec, len(nodes)) + for i := range nodes { + byID[nodes[i].ID] = &nodes[i] + } + successors := map[string][]string{} + for _, e := range edges { + if e.Route == "" { + successors[e.From] = append(successors[e.From], e.To) + } + } + for _, from := range slices.Sorted(maps.Keys(successors)) { + targets := successors[from] + if len(targets) < 2 { + continue + } + owner := map[string]string{} + for _, id := range targets { + n := byID[id] + if n == nil { + continue + } + key := n.OutputKey + if n.Type == NodeAgent && v.agents[n.Agent] != nil { + key = v.agents[n.Agent].OutputKey + } + if key == "" { + continue + } + if prev, taken := owner[key]; taken { + v.addf(path+".edges", "nodes %q and %q run concurrently after %q and both write the state key %q; give each its own output_key", prev, id, from, key) + continue + } + owner[key] = id + } + } +} + +// agentReferences rejects cycles through agent references. sub_agents cycles +// have their own message; this reports the ones that also pass through an +// agent tool or an agent node, which would recurse without end when built. +func (v *validator) agentReferences(spec *Spec) { + type edge struct{ to, kind string } + uses := map[string][]edge{} + for _, a := range spec.Agents { + for _, s := range a.SubAgents { + uses[a.Name] = append(uses[a.Name], edge{s, "sub_agents"}) + } + for _, t := range a.AgentTools { + uses[a.Name] = append(uses[a.Name], edge{t, "agent_tools"}) + } + for _, target := range nodeAgents(a.Nodes) { + uses[a.Name] = append(uses[a.Name], edge{target, "an agent node"}) + } + } + + const ( + unvisited = iota + onStack + done + ) + state := map[string]int{} + reported := map[string]bool{} + var stack []string + var kinds []string + var visit func(name string) + visit = func(name string) { + state[name] = onStack + stack = append(stack, name) + for _, e := range uses[name] { + switch state[e.to] { + case unvisited: + kinds = append(kinds, e.kind) + visit(e.to) + kinds = kinds[:len(kinds)-1] + case onStack: + start := slices.Index(stack, e.to) + cycleKinds := append(slices.Clone(kinds[start:]), e.kind) + members := append(slices.Clone(stack[start:]), e.to) + if slices.ContainsFunc(cycleKinds, func(k string) bool { return k != "sub_agents" }) && !reported[e.to] { + for _, m := range members { + reported[m] = true + } + v.addf("agents", "agents reference themselves: %s", strings.Join(members, " → ")) + } + } + } + stack = stack[:len(stack)-1] + state[name] = done + } + for _, a := range spec.Agents { + if state[a.Name] == unvisited { + visit(a.Name) + } + } +} + +// nodeAgents lists the agents that nodes run, through nested workflows too. +func nodeAgents(nodes []NodeSpec) []string { + var out []string + for i := range nodes { + n := &nodes[i] + if n.Agent != "" { + out = append(out, n.Agent) + } + out = append(out, nodeAgents(n.Nodes)...) + } + return out +} + +var ( + dnsLabel = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) + dataKey = regexp.MustCompile(`^[-._a-zA-Z0-9]+$`) + envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) +) + +// textSource checks a prompt source. The runtime only reads env sources: +// config_map and secret are Kubernetes references that the kagent controller +// resolves before delivery, so a registry-backed validation rejects them. +func (v *validator) textSource(path string, src *TextSource) { + if src == nil { + return + } + set := 0 + for _, ref := range []struct { + name string + key *KeyRef + }{{"config_map", src.ConfigMap}, {"secret", src.Secret}} { + if ref.key == nil { + continue + } + set++ + p := path + "." + ref.name + if len(ref.key.Name) > 253 || !dnsLabel.MatchString(ref.key.Name) { + v.addf(p+".name", "%q must be a DNS-1123 subdomain", ref.key.Name) + } + if len(ref.key.Key) > 253 || !dataKey.MatchString(ref.key.Key) { + v.addf(p+".key", "%q must match %s", ref.key.Key, dataKey) + } + if v.refs != nil { + v.addf(p, "must be resolved by the kagent controller; the runtime reads only env sources") + } + } + if src.Env != "" { + set++ + if !envName.MatchString(src.Env) { + v.addf(path+".env", "%q must match %s", src.Env, envName) + } + } + if set != 1 { + v.addf(path, "must set exactly one of config_map, secret, env") + } +} + +func (v *validator) mcpEnvSource(path string, src *TextSource) { + if src == nil { + return + } + if src.Env == "" || src.ConfigMap != nil || src.Secret != nil { + v.addf(path, "must use env; config_map and secret are not supported for MCP sources") + return + } + if !envName.MatchString(src.Env) { + v.addf(path+".env", "%q must match %s", src.Env, envName) + } +} + +func (v *validator) callbacks(path string, cb *CallbacksSpec) { + if cb == nil { + return + } + for _, point := range []struct { + scope celScope + rules []HookSpec + }{ + {scopeBeforeModel, cb.BeforeModel}, {scopeAfterModel, cb.AfterModel}, + {scopeBeforeTool, cb.BeforeTool}, {scopeAfterTool, cb.AfterTool}, + } { + for i, h := range point.rules { + v.hook(fmt.Sprintf("%s.%s[%d]", path, point.scope, i), point.scope, &h) + } + } +} + +func (v *validator) hook(path string, scope celScope, h *HookSpec) { + switch { + case h.Action != "" && h.Function != "": + v.addf(path, "action and function are mutually exclusive") + case h.Action == "" && h.Function == "": + v.addf(path, "must set one of action or function") + } + switch h.Action { + case "", HookAccept: + if h.Message != "" { + v.addf(path+".message", "only a reject action carries a message") + } + case HookReject: + if h.Message == "" { + v.addf(path+".message", "a reject action requires a message") + } + default: + v.addf(path+".action", "must be accept or reject, got %q", h.Action) + } + if h.Function != "" { + switch { + case !identifier.MatchString(h.Function): + v.addf(path+".function", "%q must match %s", h.Function, identifier) + case v.refs != nil && v.refs.hooks[h.Function] == nil: + v.addf(path+".function", "hook %q is not registered", h.Function) + } + } + if h.When != "" { + if _, err := compileCEL(scope, h.When, resultBool); err != nil { + v.addf(path+".when", "%v", err) + } + } +} + +func (v *validator) celNode(path string, n *NodeSpec) { + if n.Output == "" && n.Route == "" && len(n.Routes) == 0 && len(n.StateDelta) == 0 { + v.addf(path, "a cel node must set at least one of output, route, routes, state_delta") + } + names := map[string]bool{} + for i, cv := range n.Vars { + p := fmt.Sprintf("%s.vars[%d]", path, i) + // A var reads only the vars declared before it. + v.celExpr(p+".expr", cv.Expr, resultAny, names) + switch { + case !celIdent.MatchString(cv.Name): + v.addf(p+".name", "must be an identifier, got %q", cv.Name) + case names[cv.Name]: + v.addf(p+".name", "duplicate var %q", cv.Name) + } + names[cv.Name] = true + } + if n.Route != "" && len(n.Routes) > 0 { + v.addf(path, "route and routes are mutually exclusive") + } + for i, r := range n.Routes { + p := fmt.Sprintf("%s.routes[%d]", path, i) + if r.Route == "" { + v.addf(p+".route", "required") + } + if r.When == "" { + if i != len(n.Routes)-1 { + v.addf(p+".when", "only the last rule may omit when; later rules are unreachable") + } + continue + } + v.celExpr(p+".when", r.When, resultBool, names) + } + if n.Output != "" { + v.celExpr(path+".output", n.Output, resultAny, names) + } + if n.Route != "" { + v.celExpr(path+".route", n.Route, resultRoutes, names) + } + for _, k := range slices.Sorted(maps.Keys(n.StateDelta)) { + p := path + ".state_delta." + k + if k == "" { + v.addf(p, "state keys must not be empty") + continue + } + v.celExpr(p, n.StateDelta[k], resultAny, names) + } +} + +// celExpr compiles a cel node expression and checks that every vars. +// it reads is in known. +func (v *validator) celExpr(path, src string, want celResult, known map[string]bool) { + e, err := compileCEL(scopeNode, src, want) + if err != nil { + v.addf(path, "%v", err) + return + } + for _, name := range e.varRefs { + if !known[name] { + v.addf(path, "vars.%s is not declared before this expression", name) + } + } +} + +var celIdent = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +var headerName = regexp.MustCompile("^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") + +func (v *validator) mcpServers(servers []MCPServerSpec) { + v.servers = make(map[string]*MCPServerSpec, len(servers)) + for i := range servers { + s := &servers[i] + path := fmt.Sprintf("mcp_servers[%d]", i) + switch { + case !identifier.MatchString(s.Name): + v.addf(path+".name", "%q must match %s", s.Name, identifier) + case v.servers[s.Name] != nil: + v.addf(path+".name", "duplicate mcp server %q", s.Name) + case v.refs != nil && v.refs.hasToolOrToolset(s.Name): + v.addf(path+".name", "%q is already a registered tool or toolset", s.Name) + default: + v.servers[s.Name] = s + } + switch { + case (s.URL == "") == (s.URLFrom == nil): + v.addf(path, "must set exactly one of url, url_from") + case s.URL != "": + if u, err := url.Parse(s.URL); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + v.addf(path+".url", "%q must be an absolute http or https URL", s.URL) + } + } + v.mcpEnvSource(path+".url_from", s.URLFrom) + seen := map[string]bool{} + for j, h := range s.Headers { + p := fmt.Sprintf("%s.headers[%d]", path, j) + key := strings.ToLower(h.Name) + switch { + case !headerName.MatchString(h.Name): + v.addf(p+".name", "%q is not an HTTP header name", h.Name) + case seen[key]: + v.addf(p+".name", "duplicate header %q", h.Name) + } + seen[key] = true + if (h.Value == "") == (h.ValueFrom == nil) { + v.addf(p, "must set exactly one of value, value_from") + } + v.mcpEnvSource(p+".value_from", h.ValueFrom) + } + tools := map[string]bool{} + for j, t := range s.Tools { + p := fmt.Sprintf("%s.tools[%d]", path, j) + switch { + case !toolName.MatchString(t): + v.addf(p, "%q must match %s", t, toolName) + case tools[t]: + v.addf(p, "duplicate tool %q", t) + } + tools[t] = true + } + } +} + +// mcpToolNode checks a tool node that calls a tool of a server. Whether the +// server offers the tool is only known when it runs, unless the server +// declares an allowlist. +func (v *validator) mcpToolNode(path string, n *NodeSpec) { + if n.Tool != "" && !toolName.MatchString(n.Tool) { + v.addf(path+".tool", "%q must match %s", n.Tool, toolName) + } + if s := v.servers[n.Server]; s != nil { + if len(s.Tools) > 0 && !slices.Contains(s.Tools, n.Tool) { + v.addf(path+".tool", "tool %q is not in the tools of mcp server %q", n.Tool, n.Server) + } + return + } + if v.refs == nil || !v.refs.hasToolset(n.Server) { + v.addf(path+".server", "server %q is not declared in mcp_servers", n.Server) + } +} diff --git a/go/adk/pkg/declarative/validate_test.go b/go/adk/pkg/declarative/validate_test.go new file mode 100644 index 0000000000..6cfbca579b --- /dev/null +++ b/go/adk/pkg/declarative/validate_test.go @@ -0,0 +1,296 @@ +package declarative + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const docHeader = "apiVersion: adk.declarative/v0.2\nkind: AgentSystem\nagents:\n" + +// wf wraps nodes and edges in a minimal Workflow agent. +func wf(nodes, edges string) string { + return docHeader + " - name: wf\n agent_class: Workflow\n nodes: " + nodes + "\n edges: " + edges + "\n" +} + +func TestShowcaseIsValid(t *testing.T) { + data, err := os.ReadFile("testdata/showcase.yaml") + require.NoError(t, err) + spec, err := Parse(data) + require.NoError(t, err) + require.Len(t, spec.Agents, 5) +} + +func TestValidateReportsFieldPaths(t *testing.T) { + startTo := func(id string) string { return "[{ from: START, to: " + id + " }]" } + tests := []struct { + name string + doc string + want []string + }{ + {"wrong apiVersion and kind", "apiVersion: v1\nkind: Pod\nagents:\n - { name: a, agent_class: LlmAgent, model: m, instruction: i }\n", + []string{`apiVersion: must be "adk.declarative/v0.2"`, `kind: must be "AgentSystem"`}}, + {"no agents", "apiVersion: adk.declarative/v0.2\nkind: AgentSystem\nagents: []\n", + []string{"agents: at least one agent is required"}}, + {"bad and reserved agent names", docHeader + " - { name: 'a b', agent_class: LlmAgent, model: m, instruction: i }\n - { name: user, agent_class: LlmAgent, model: m, instruction: i }\n", + []string{"agents[0].name:", `agents[1].name: "user" is reserved`}}, + {"duplicate agent", docHeader + " - { name: a, agent_class: LlmAgent, model: m, instruction: i }\n - { name: a, agent_class: LlmAgent, model: m, instruction: i }\n", + []string{`agents[1].name: duplicate agent name "a"`}}, + {"unknown agent class", docHeader + " - { name: a, agent_class: Sequential }\n", + []string{"agents[0].agent_class: must be"}}, + {"llm fields on a workflow", docHeader + " - { name: a, agent_class: Workflow, model: m, instruction: i, nodes: [{ id: n, type: join }], edges: [{ from: START, to: n }] }\n", + []string{"agents[0].instruction: not allowed on agent_class Workflow", "agents[0].model: not allowed on agent_class Workflow"}}, + {"workflow fields on an llm agent", docHeader + " - { name: a, agent_class: LlmAgent, model: m, instruction: i, entry: x }\n", + []string{"agents[0].entry: not allowed on agent_class LlmAgent"}}, + {"llm agent requires model and instruction", docHeader + " - { name: a, agent_class: LlmAgent }\n", + []string{"agents[0].model: required", "agents[0].instruction: required"}}, + {"bad mode and duplicate tool", docHeader + " - { name: a, agent_class: LlmAgent, model: m, instruction: i, mode: weird, tools: [t, t] }\n", + []string{"agents[0].mode: must be one of", "agents[0].tools[1]: duplicate tool"}}, + {"unknown and self sub-agent", docHeader + " - { name: a, agent_class: LlmAgent, model: m, instruction: i, sub_agents: [a, ghost] }\n", + []string{"sub_agents[0]: an agent cannot be its own sub-agent", `sub_agents[1]: unknown agent "ghost"`}}, + {"sub-agent with two parents", docHeader + + " - { name: a, agent_class: LlmAgent, model: m, instruction: i, sub_agents: [c] }\n" + + " - { name: b, agent_class: LlmAgent, model: m, instruction: i, sub_agents: [c] }\n" + + " - { name: c, agent_class: LlmAgent, model: m, instruction: i }\n", + []string{`agents[1].sub_agents[0]: agent "c" is already a sub-agent of "a"`}}, + {"sub-agent cycle", docHeader + + " - { name: a, agent_class: LlmAgent, model: m, instruction: i, sub_agents: [b] }\n" + + " - { name: b, agent_class: LlmAgent, model: m, instruction: i, sub_agents: [a] }\n", + []string{"is part of a sub-agent cycle"}}, + {"workflow without nodes", docHeader + " - { name: a, agent_class: Workflow }\n", + []string{"agents[0].nodes: required for agent_class Workflow"}}, + {"bad node ids", wf("[{ id: START, type: join }, { id: 'a-b', type: join }, { id: x, type: join }, { id: x, type: join }]", "[{ from: START, to: x }]"), + []string{`nodes[0].id: "START" is reserved`, "nodes[1].id:", `nodes[3].id: duplicate node id "x"`}}, + {"unknown node type", wf("[{ id: n, type: teleport }]", startTo("n")), + []string{`nodes[0].type: unknown node type "teleport" (known: `}}, + {"function node requires function", wf("[{ id: n, type: function }]", startTo("n")), + []string{"nodes[0].function: required for node type function"}}, + {"field not allowed on node type", wf("[{ id: n, type: join, function: f }]", startTo("n")), + []string{"nodes[0].function: not allowed on node type join"}}, + {"retry not allowed on hitl", wf("[{ id: n, type: hitl, message: m, retry: { max_attempts: 2 } }]", startTo("n")), + []string{"nodes[0].retry: not allowed on node type hitl"}}, + {"parallel worker needs exactly one target", wf("[{ id: n, type: parallel_worker, function: f, tool: t }, { id: m, type: parallel_worker }]", "[{ from: START, to: n }, { from: n, to: m }]"), + []string{"nodes[0]: parallel_worker needs exactly one of function, tool or agent, got 2", "nodes[1]: parallel_worker needs exactly one of function, tool or agent, got 0"}}, + {"negative bounds", wf("[{ id: n, type: parallel_worker, function: f, max_concurrency: -1 }, { id: d, type: dynamic, function: f, max_iters: -1 }]", "[{ from: START, to: n }, { from: n, to: d }]"), + []string{"nodes[0].max_concurrency: must not be negative", "nodes[1].max_iters: must not be negative"}}, + {"inline llm must be single_turn", wf("[{ id: n, type: llm, model: m, instruction: i, mode: chat }]", startTo("n")), + []string{`nodes[0].mode: an inline llm node runs mid-graph and must be single_turn, got "chat"`}}, + {"agent node targets", docHeader + + " - { name: llm, agent_class: LlmAgent, model: m, instruction: i }\n" + + " - name: wf\n agent_class: Workflow\n nodes: [{ id: a, type: agent, agent: ghost }, { id: b, type: agent, agent: wf }, { id: c, type: agent, agent: llm }, { id: d, type: agent, agent: llm }]\n" + + " edges: [{ from: START, to: a }, { from: a, to: b }, { from: b, to: c }, { from: c, to: d }]\n", + []string{`nodes[0].agent: unknown agent "ghost"`, `nodes[1].agent: agent "wf" is a Workflow`, `nodes[3].agent: agent "llm" is already used by node "c"`}}, + {"agent name collides with node id", docHeader + + " - { name: llm, agent_class: LlmAgent, model: m, instruction: i }\n" + + " - name: wf\n agent_class: Workflow\n nodes: [{ id: llm, type: join }, { id: a, type: agent, agent: llm }]\n edges: [{ from: START, to: llm }, { from: llm, to: a }]\n", + []string{`agent "llm" collides with a node id`}}, + {"bad durations", wf("[{ id: n, type: function, function: f, timeout: soon, retry: { initial_delay: later, max_delay: -1s } }]", startTo("n")), + []string{"nodes[0].timeout: must be a positive duration", "retry.initial_delay: must be a non-negative duration", "retry.max_delay: must be a non-negative duration"}}, + {"bad retry numbers", wf("[{ id: n, type: function, function: f, retry: { max_attempts: -1, backoff_factor: 0, jitter: 2 } }]", startTo("n")), + []string{"retry.max_attempts: must not be negative", "retry.backoff_factor: must be positive", "retry.jitter: must be within [0, 1]"}}, + {"edge endpoints", wf("[{ id: n, type: join }]", "[{ from: ghost, to: n }, { from: START, to: START }, { from: n, to: ghost }]"), + []string{`edges[0].from: unknown node "ghost"`, `edges[1].to: "START" cannot be an edge target`, `edges[2].to: unknown node "ghost"`}}, + {"START cannot route", wf("[{ id: n, type: join }]", "[{ from: START, to: n, route: X }]"), + []string{`edges[0].route: "START" does not emit routes`}}, + {"duplicate edge and default", wf("[{ id: a, type: join }, { id: b, type: join }, { id: c, type: join }]", + "[{ from: START, to: a }, { from: START, to: a }, { from: a, to: b, route: DEFAULT }, { from: a, to: c, route: DEFAULT }]"), + []string{"edges[1]: duplicate edge", `edges[3].route: node "a" has more than one DEFAULT edge`}}, + {"graph without a start", wf("[{ id: a, type: join }, { id: b, type: join }]", "[{ from: a, to: b }]"), + []string{"no edge from START and no entry"}}, + {"node outside every edge", wf("[{ id: a, type: join }, { id: b, type: join }]", "[{ from: START, to: a }]"), + []string{`nodes[1]: node "b" is not connected by any edge`}}, + {"unknown entry", wf("[{ id: a, type: join }]", "[{ from: START, to: a }]") + " entry: ghost\n", + []string{`entry: unknown node "ghost"`}}, + {"composite agents require sub_agents", docHeader + " - { name: s, agent_class: SequentialAgent }\n - { name: p, agent_class: ParallelAgent }\n - { name: l, agent_class: LoopAgent }\n", + []string{"agents[0].sub_agents: required for agent_class SequentialAgent", "agents[1].sub_agents: required for agent_class ParallelAgent", "agents[2].sub_agents: required for agent_class LoopAgent"}}, + {"composite agents reject foreign fields", docHeader + + " - { name: w, agent_class: LlmAgent, model: m, instruction: i }\n" + + " - { name: s, agent_class: SequentialAgent, sub_agents: [w], instruction: i, max_iterations: 2 }\n", + []string{"agents[1].instruction: not allowed on agent_class SequentialAgent", "agents[1].max_iterations: not allowed on agent_class SequentialAgent"}}, + {"max_iterations only belongs to LoopAgent", docHeader + + " - { name: w, agent_class: LlmAgent, model: m, instruction: i, max_iterations: 3 }\n", + []string{"agents[0].max_iterations: not allowed on agent_class LlmAgent"}}, + {"negative workflow max_concurrency", wf("[{ id: a, type: function, function: f }]", "[{ from: START, to: a }]") + " max_concurrency: -1\n", + []string{"agents[0].max_concurrency: must not be negative"}}, + {"max_concurrency only belongs to Workflow", docHeader + + " - { name: w, agent_class: LlmAgent, model: m, instruction: i, max_concurrency: 2 }\n", + []string{"agents[0].max_concurrency: not allowed on agent_class LlmAgent"}}, + {"unknown agent class lists the known ones", docHeader + " - { name: a, agent_class: Graph }\n", + []string{"LlmAgent, LoopAgent, ParallelAgent, SequentialAgent, Workflow"}}, + {"bad include_contents", docHeader + " - { name: a, agent_class: LlmAgent, model: m, instruction: i, include_contents: all }\n", + []string{"agents[0].include_contents: must be default or none"}}, + {"invalid output_schema", docHeader + " - { name: a, agent_class: LlmAgent, model: m, instruction: i, output_schema: { type: 12 } }\n", + []string{"agents[0].output_schema:"}}, + {"model name shape", docHeader + " - { name: a, agent_class: LlmAgent, model: 'bad model!', instruction: i }\n", + []string{"agents[0].model:"}}, + {"terminal structured nodes are rejected", wf("[{ id: t, type: tool, tool: x }, { id: j, type: join }, { id: p, type: parallel_worker, function: f }]", + "[{ from: START, to: t }, { from: START, to: j }, { from: START, to: p }]"), + []string{"nodes[0]: terminal tool node emits structured output", "nodes[1]: terminal join node", "nodes[2]: terminal parallel_worker node"}}, + {"terminal check reaches into a terminal nested workflow", wf("[{ id: outer, type: workflow, nodes: [{ id: t, type: tool, tool: x }], edges: [{ from: START, to: t }] }]", startTo("outer")), + []string{"agents[0].nodes[0].nodes[0]: terminal tool node"}}, + {"bad tool node name", wf("[{ id: t, type: tool, tool: 'bad name' }, { id: f, type: function, function: g }]", "[{ from: START, to: t }, { from: t, to: f }]"), + []string{"nodes[0].tool:"}}, + {"dynamic children must exist and not be self", wf("[{ id: d, type: dynamic, function: f, children: [ghost, d] }]", startTo("d")), + []string{`nodes[0].children[0]: unknown node "ghost"`, "nodes[0].children[1]: a node cannot be its own child"}}, + {"dynamic children cannot have edges", wf("[{ id: d, type: dynamic, function: f, children: [c] }, { id: c, type: function, function: f }, { id: e, type: function, function: f }]", + "[{ from: START, to: d }, { from: START, to: c }, { from: c, to: e }]"), + []string{`node "c" is a dynamic child and cannot have edges`}}, + {"children only belong to dynamic nodes", wf("[{ id: f, type: function, function: f, children: [f] }]", startTo("f")), + []string{"nodes[0].children: not allowed on node type function"}}, + {"agent tool references", docHeader + + " - { name: a, agent_class: LlmAgent, model: m, instruction: i, agent_tools: [a, ghost, b, b] }\n" + + " - { name: b, agent_class: LlmAgent, model: m, instruction: i }\n", + []string{"agent_tools[0]: an agent cannot be its own tool", `agent_tools[1]: unknown agent "ghost"`, `agent_tools[3]: duplicate agent tool "b"`}}, + {"agent tools belong to LlmAgents", docHeader + + " - { name: w, agent_class: LlmAgent, model: m, instruction: i }\n" + + " - { name: s, agent_class: SequentialAgent, sub_agents: [w], agent_tools: [w] }\n", + []string{"agents[1].agent_tools: not allowed on agent_class SequentialAgent"}}, + {"an agent is a sub-agent or a tool, not both", docHeader + + " - { name: parent, agent_class: LlmAgent, model: m, instruction: i, sub_agents: [child] }\n" + + " - { name: caller, agent_class: LlmAgent, model: m, instruction: i, agent_tools: [child] }\n" + + " - { name: child, agent_class: LlmAgent, model: m, instruction: i }\n", + []string{`agents[1].agent_tools[0]: agent "child" is a sub-agent of "parent"`}}, + {"agent tool cycle", docHeader + + " - { name: a, agent_class: LlmAgent, model: m, instruction: i, agent_tools: [b] }\n" + + " - { name: b, agent_class: LlmAgent, model: m, instruction: i, agent_tools: [a] }\n", + []string{"agents reference themselves: a → b → a"}}, + {"cycle through an agent node", docHeader + + " - { name: caller, agent_class: LlmAgent, model: m, instruction: i, agent_tools: [flow] }\n" + + " - name: flow\n agent_class: Workflow\n nodes: [{ id: ask, type: agent, agent: caller }]\n edges: [{ from: START, to: ask }]\n", + []string{"agents reference themselves: caller → flow → caller"}}, + {"bad dynamic isolation", wf("[{ id: d, type: dynamic, function: f, isolation: private }]", startTo("d")), + []string{`nodes[0].isolation: must be one of shared, branch, scope, got "private"`}}, + {"isolation only belongs to dynamic nodes", wf("[{ id: f, type: function, function: f, isolation: branch }]", startTo("f")), + []string{"nodes[0].isolation: not allowed on node type function"}}, + {"ParallelAgent sub-agents share an output key", docHeader + + " - { name: p, agent_class: ParallelAgent, sub_agents: [a, b] }\n" + + " - { name: a, agent_class: LlmAgent, model: m, instruction: i, output_key: result }\n" + + " - { name: b, agent_class: LlmAgent, model: m, instruction: i, output_key: result }\n", + []string{`agents[0].sub_agents[1]: sub-agents of a ParallelAgent "a" and "b" both write the state key "result"`}}, + {"fan-out branches share an output key", docHeader + + " - name: wf\n agent_class: Workflow\n" + + " nodes:\n" + + " - { id: left, type: llm, model: m, instruction: i, output_key: out }\n" + + " - { id: right, type: agent, agent: worker }\n" + + " - { id: g, type: join }\n" + + " - { id: e, type: function, function: f }\n" + + " edges: [{ from: START, to: left }, { from: START, to: right }, { from: left, to: g }, { from: right, to: g }, { from: g, to: e }]\n" + + " - { name: worker, agent_class: LlmAgent, model: m, mode: single_turn, instruction: i, output_key: out }\n", + []string{`nodes "left" and "right" run concurrently after "START" and both write the state key "out"`}}, + {"parallel worker iterations share an agent output key", docHeader + + " - name: wf\n agent_class: Workflow\n" + + " nodes: [{ id: each, type: parallel_worker, agent: worker }, { id: e, type: function, function: f }]\n" + + " edges: [{ from: START, to: each }, { from: each, to: e }]\n" + + " - { name: worker, agent_class: LlmAgent, model: m, mode: single_turn, instruction: i, output_key: out }\n", + []string{`nodes[0].agent: every concurrent item writes the state key "out" of agent "worker"`}}, + {"errors inside a nested workflow carry the full path", wf("[{ id: outer, type: workflow, nodes: [{ id: in, type: function }], edges: [{ from: START, to: in }] }]", startTo("outer")), + []string{"agents[0].nodes[0].nodes[0].function: required for node type function"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse([]byte(tt.doc)) + require.Error(t, err) + require.True(t, IsValidation(err)) + for _, want := range tt.want { + require.Contains(t, err.Error(), want) + } + }) + } +} + +func TestValidateAggregatesAllIssues(t *testing.T) { + _, err := Parse([]byte("apiVersion: nope\nkind: nope\nagents:\n - { name: a, agent_class: LlmAgent }\n")) + var ve *ValidationError + require.ErrorAs(t, err, &ve) + require.GreaterOrEqual(t, len(ve.Issues), 4) + require.True(t, strings.HasPrefix(err.Error(), "invalid AgentSystem document:")) +} + +func TestValidateNilSpec(t *testing.T) { + require.ErrorContains(t, Validate(nil), "document is required") +} + +func TestEntryMayRepeatAnExplicitStartEdge(t *testing.T) { + doc := wf("[{ id: a, type: function, function: f }]", "[{ from: START, to: a }]") + " entry: a\n" + _, err := Parse([]byte(doc)) + require.NoError(t, err) +} + +func TestParseRejectsUnknownFields(t *testing.T) { + tests := map[string]string{ + "agent": docHeader + " - { name: a, agent_class: LlmAgent, model: m, instruction: i, temprature: 1 }\n", + "node": wf("[{ id: n, type: join, colour: red }]", "[{ from: START, to: n }]"), + "edge": wf("[{ id: n, type: join }]", "[{ from: START, to: n, weight: 2 }]"), + "retry": wf("[{ id: n, type: function, function: f, retry: { attempts: 2 } }]", "[{ from: START, to: n }]"), + "syntax": "agents: [", + } + for name, doc := range tests { + t.Run(name, func(t *testing.T) { + _, err := Parse([]byte(doc)) + require.ErrorContains(t, err, "failed to decode AgentSystem document") + require.False(t, IsValidation(err)) + }) + } +} + +func TestNestingDepthIsBounded(t *testing.T) { + inner := "{ id: leaf, type: function, function: f }" + for range maxNesting + 2 { + inner = "{ id: w, type: workflow, nodes: [" + inner + "], edges: [{ from: START, to: " + firstID(inner) + " }] }" + } + _, err := Parse([]byte(wf("["+inner+"]", "[{ from: START, to: w }]"))) + require.ErrorContains(t, err, "nested deeper than 8 levels") +} + +func firstID(node string) string { + rest := node[strings.Index(node, "id: ")+len("id: "):] + return rest[:strings.IndexAny(rest, ",}")] +} + +func TestChildrenAreNotReportedAsUnconnected(t *testing.T) { + doc := wf("[{ id: d, type: dynamic, function: f, children: [c] }, { id: c, type: function, function: f }]", "[{ from: START, to: d }]") + _, err := Parse([]byte(doc)) + require.NoError(t, err) +} + +func TestNonTerminalStructuredNodesAreAllowed(t *testing.T) { + doc := wf("[{ id: t, type: tool, tool: x }, { id: f, type: function, function: g }]", "[{ from: START, to: t }, { from: t, to: f }]") + _, err := Parse([]byte(doc)) + require.NoError(t, err) +} + +func TestValidateWithRulesAcceptsExtraNodeTypes(t *testing.T) { + doc := wf("[{ id: s, type: shout, message: hi }]", "[{ from: START, to: s }]") + spec, err := decode([]byte(doc)) + require.NoError(t, err) + require.ErrorContains(t, Validate(spec), `unknown node type "shout"`) + require.NoError(t, ValidateWithRules(spec, map[NodeType]NodeRule{"shout": {Required: []string{"message"}}})) +} + +func TestSharedOutputKeysAreFineWhenNothingRunsConcurrently(t *testing.T) { + tests := map[string]string{ + "SequentialAgent": docHeader + + " - { name: s, agent_class: SequentialAgent, sub_agents: [a, b] }\n" + + " - { name: a, agent_class: LlmAgent, model: m, instruction: i, output_key: out }\n" + + " - { name: b, agent_class: LlmAgent, model: m, instruction: i, output_key: out }\n", + "routed branches": docHeader + + " - name: wf\n agent_class: Workflow\n" + + " nodes:\n" + + " - { id: r, type: function, function: f }\n" + + " - { id: x, type: llm, model: m, instruction: i, output_key: out }\n" + + " - { id: y, type: llm, model: m, instruction: i, output_key: out }\n" + + " edges: [{ from: START, to: r }, { from: r, to: x, route: A }, { from: r, to: y, route: B }]\n", + "distinct keys in a fan-out": docHeader + + " - { name: p, agent_class: ParallelAgent, sub_agents: [a, b] }\n" + + " - { name: a, agent_class: LlmAgent, model: m, instruction: i, output_key: left }\n" + + " - { name: b, agent_class: LlmAgent, model: m, instruction: i, output_key: right }\n", + } + for name, doc := range tests { + t.Run(name, func(t *testing.T) { + _, err := Parse([]byte(doc)) + require.NoError(t, err) + }) + } +} diff --git a/go/api/a2a/metadata.go b/go/api/a2a/metadata.go index 80617cdb01..3aa68f5e2b 100644 --- a/go/api/a2a/metadata.go +++ b/go/api/a2a/metadata.go @@ -14,6 +14,9 @@ const ( UsageMetadataKey = MetadataPrefix + "usage" // ThoughtMetadataKey marks a text part that summarizes the model's reasoning, not its answer. ThoughtMetadataKey = MetadataPrefix + "thought" + // AuthorMetadataKey names the agent that wrote an artifact or message. A + // workflow has one author per node, so the chat can attribute each reply. + AuthorMetadataKey = MetadataPrefix + "author" ) // IsThought reports whether a part summarizes the model's reasoning rather than answering. diff --git a/go/api/gen/kagent/config/v1/adk.pb.go b/go/api/gen/kagent/config/v1/adk.pb.go index 4453ce8783..e060c53654 100644 --- a/go/api/gen/kagent/config/v1/adk.pb.go +++ b/go/api/gen/kagent/config/v1/adk.pb.go @@ -23,6 +23,61 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// ADKAgentIsolation says how much of the session a subagent shares with its parent. +type ADKAgentIsolation int32 + +const ( + ADKAgentIsolation_ADK_AGENT_ISOLATION_UNSPECIFIED ADKAgentIsolation = 0 + // SHARED hands the conversation to the subagent in the parent's session: the + // parent transfers control and the subagent answers in the same history. + ADKAgentIsolation_ADK_AGENT_ISOLATION_SHARED ADKAgentIsolation = 1 + // SESSION calls the subagent as a tool in a new session seeded with a copy of + // the parent's state. It never sees the parent's history, parallel calls get + // one session each, and it cannot pause for human input. + ADKAgentIsolation_ADK_AGENT_ISOLATION_SESSION ADKAgentIsolation = 2 +) + +// Enum value maps for ADKAgentIsolation. +var ( + ADKAgentIsolation_name = map[int32]string{ + 0: "ADK_AGENT_ISOLATION_UNSPECIFIED", + 1: "ADK_AGENT_ISOLATION_SHARED", + 2: "ADK_AGENT_ISOLATION_SESSION", + } + ADKAgentIsolation_value = map[string]int32{ + "ADK_AGENT_ISOLATION_UNSPECIFIED": 0, + "ADK_AGENT_ISOLATION_SHARED": 1, + "ADK_AGENT_ISOLATION_SESSION": 2, + } +) + +func (x ADKAgentIsolation) Enum() *ADKAgentIsolation { + p := new(ADKAgentIsolation) + *p = x + return p +} + +func (x ADKAgentIsolation) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ADKAgentIsolation) Descriptor() protoreflect.EnumDescriptor { + return file_kagent_config_v1_adk_proto_enumTypes[0].Descriptor() +} + +func (ADKAgentIsolation) Type() protoreflect.EnumType { + return &file_kagent_config_v1_adk_proto_enumTypes[0] +} + +func (x ADKAgentIsolation) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ADKAgentIsolation.Descriptor instead. +func (ADKAgentIsolation) EnumDescriptor() ([]byte, []int) { + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{0} +} + // ADKConfig is the compiled configuration consumed by both ADK runtimes. // It is private runtime input, serialized with ProtoJSON in config.json. // Process settings are separate from the recursive agent definition. @@ -98,8 +153,14 @@ type ADKAgentConfig struct { SkillsDirectory string `protobuf:"bytes,16,opt,name=skills_directory,json=skillsDirectory,proto3" json:"skills_directory,omitempty"` SubAgents []*ADKAgentConfig `protobuf:"bytes,17,rep,name=sub_agents,json=subAgents,proto3" json:"sub_agents,omitempty"` Output *ADKOutputConfig `protobuf:"bytes,18,opt,name=output,proto3" json:"output,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // workflow carries a declarative AgentSystem document for BYO images. The + // managed runtimes reject it. + Workflow *ADKWorkflowConfig `protobuf:"bytes,19,opt,name=workflow,proto3" json:"workflow,omitempty"` + // isolation applies to a subagent and says how its parent runs it. Unspecified + // is shared. The root agent must leave it unspecified. + Isolation ADKAgentIsolation `protobuf:"varint,20,opt,name=isolation,proto3,enum=kagent.config.v1.ADKAgentIsolation" json:"isolation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ADKAgentConfig) Reset() { @@ -244,6 +305,20 @@ func (x *ADKAgentConfig) GetOutput() *ADKOutputConfig { return nil } +func (x *ADKAgentConfig) GetWorkflow() *ADKWorkflowConfig { + if x != nil { + return x.Workflow + } + return nil +} + +func (x *ADKAgentConfig) GetIsolation() ADKAgentIsolation { + if x != nil { + return x.Isolation + } + return ADKAgentIsolation_ADK_AGENT_ISOLATION_UNSPECIFIED +} + // ADKModelConfig shares transport settings across a single selected provider. type ADKModelConfig struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2380,6 +2455,61 @@ func (x *ADKOutputConfig) GetSha256() string { return "" } +// ADKWorkflowConfig is an adk.declarative AgentSystem document that the +// controller validated. The BYO image owns the Go behavior the document names. +type ADKWorkflowConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Canonical JSON bytes retain the exact document hashed by the compiler. + Document []byte `protobuf:"bytes,1,opt,name=document,proto3" json:"document,omitempty"` + Sha256 string `protobuf:"bytes,2,opt,name=sha256,proto3" json:"sha256,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ADKWorkflowConfig) Reset() { + *x = ADKWorkflowConfig{} + mi := &file_kagent_config_v1_adk_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ADKWorkflowConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ADKWorkflowConfig) ProtoMessage() {} + +func (x *ADKWorkflowConfig) ProtoReflect() protoreflect.Message { + mi := &file_kagent_config_v1_adk_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ADKWorkflowConfig.ProtoReflect.Descriptor instead. +func (*ADKWorkflowConfig) Descriptor() ([]byte, []int) { + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{28} +} + +func (x *ADKWorkflowConfig) GetDocument() []byte { + if x != nil { + return x.Document + } + return nil +} + +func (x *ADKWorkflowConfig) GetSha256() string { + if x != nil { + return x.Sha256 + } + return "" +} + type ADKAgentPlugins struct { state protoimpl.MessageState `protogen:"open.v1"` Skills []*ADKSkill `protobuf:"bytes,1,rep,name=skills,proto3" json:"skills,omitempty"` @@ -2390,7 +2520,7 @@ type ADKAgentPlugins struct { func (x *ADKAgentPlugins) Reset() { *x = ADKAgentPlugins{} - mi := &file_kagent_config_v1_adk_proto_msgTypes[28] + mi := &file_kagent_config_v1_adk_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2402,7 +2532,7 @@ func (x *ADKAgentPlugins) String() string { func (*ADKAgentPlugins) ProtoMessage() {} func (x *ADKAgentPlugins) ProtoReflect() protoreflect.Message { - mi := &file_kagent_config_v1_adk_proto_msgTypes[28] + mi := &file_kagent_config_v1_adk_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2415,7 +2545,7 @@ func (x *ADKAgentPlugins) ProtoReflect() protoreflect.Message { // Deprecated: Use ADKAgentPlugins.ProtoReflect.Descriptor instead. func (*ADKAgentPlugins) Descriptor() ([]byte, []int) { - return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{28} + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{29} } func (x *ADKAgentPlugins) GetSkills() []*ADKSkill { @@ -2442,7 +2572,7 @@ type ADKSkill struct { func (x *ADKSkill) Reset() { *x = ADKSkill{} - mi := &file_kagent_config_v1_adk_proto_msgTypes[29] + mi := &file_kagent_config_v1_adk_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2454,7 +2584,7 @@ func (x *ADKSkill) String() string { func (*ADKSkill) ProtoMessage() {} func (x *ADKSkill) ProtoReflect() protoreflect.Message { - mi := &file_kagent_config_v1_adk_proto_msgTypes[29] + mi := &file_kagent_config_v1_adk_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2467,7 +2597,7 @@ func (x *ADKSkill) ProtoReflect() protoreflect.Message { // Deprecated: Use ADKSkill.ProtoReflect.Descriptor instead. func (*ADKSkill) Descriptor() ([]byte, []int) { - return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{29} + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{30} } func (x *ADKSkill) GetName() string { @@ -2494,7 +2624,7 @@ type ADKPluginBundle struct { func (x *ADKPluginBundle) Reset() { *x = ADKPluginBundle{} - mi := &file_kagent_config_v1_adk_proto_msgTypes[30] + mi := &file_kagent_config_v1_adk_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2506,7 +2636,7 @@ func (x *ADKPluginBundle) String() string { func (*ADKPluginBundle) ProtoMessage() {} func (x *ADKPluginBundle) ProtoReflect() protoreflect.Message { - mi := &file_kagent_config_v1_adk_proto_msgTypes[30] + mi := &file_kagent_config_v1_adk_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2519,7 +2649,7 @@ func (x *ADKPluginBundle) ProtoReflect() protoreflect.Message { // Deprecated: Use ADKPluginBundle.ProtoReflect.Descriptor instead. func (*ADKPluginBundle) Descriptor() ([]byte, []int) { - return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{30} + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{31} } func (x *ADKPluginBundle) GetSource() *ADKPluginSource { @@ -2551,7 +2681,7 @@ type ADKPluginSource struct { func (x *ADKPluginSource) Reset() { *x = ADKPluginSource{} - mi := &file_kagent_config_v1_adk_proto_msgTypes[31] + mi := &file_kagent_config_v1_adk_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2563,7 +2693,7 @@ func (x *ADKPluginSource) String() string { func (*ADKPluginSource) ProtoMessage() {} func (x *ADKPluginSource) ProtoReflect() protoreflect.Message { - mi := &file_kagent_config_v1_adk_proto_msgTypes[31] + mi := &file_kagent_config_v1_adk_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2576,7 +2706,7 @@ func (x *ADKPluginSource) ProtoReflect() protoreflect.Message { // Deprecated: Use ADKPluginSource.ProtoReflect.Descriptor instead. func (*ADKPluginSource) Descriptor() ([]byte, []int) { - return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{31} + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{32} } func (x *ADKPluginSource) GetSource() isADKPluginSource_Source { @@ -2652,7 +2782,7 @@ type ADKPluginGitSource struct { func (x *ADKPluginGitSource) Reset() { *x = ADKPluginGitSource{} - mi := &file_kagent_config_v1_adk_proto_msgTypes[32] + mi := &file_kagent_config_v1_adk_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2664,7 +2794,7 @@ func (x *ADKPluginGitSource) String() string { func (*ADKPluginGitSource) ProtoMessage() {} func (x *ADKPluginGitSource) ProtoReflect() protoreflect.Message { - mi := &file_kagent_config_v1_adk_proto_msgTypes[32] + mi := &file_kagent_config_v1_adk_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2677,7 +2807,7 @@ func (x *ADKPluginGitSource) ProtoReflect() protoreflect.Message { // Deprecated: Use ADKPluginGitSource.ProtoReflect.Descriptor instead. func (*ADKPluginGitSource) Descriptor() ([]byte, []int) { - return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{32} + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{33} } func (x *ADKPluginGitSource) GetUrl() string { @@ -2707,7 +2837,7 @@ type ADKPluginS3Source struct { func (x *ADKPluginS3Source) Reset() { *x = ADKPluginS3Source{} - mi := &file_kagent_config_v1_adk_proto_msgTypes[33] + mi := &file_kagent_config_v1_adk_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2719,7 +2849,7 @@ func (x *ADKPluginS3Source) String() string { func (*ADKPluginS3Source) ProtoMessage() {} func (x *ADKPluginS3Source) ProtoReflect() protoreflect.Message { - mi := &file_kagent_config_v1_adk_proto_msgTypes[33] + mi := &file_kagent_config_v1_adk_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2732,7 +2862,7 @@ func (x *ADKPluginS3Source) ProtoReflect() protoreflect.Message { // Deprecated: Use ADKPluginS3Source.ProtoReflect.Descriptor instead. func (*ADKPluginS3Source) Descriptor() ([]byte, []int) { - return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{33} + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{34} } func (x *ADKPluginS3Source) GetEndpoint() string { @@ -2793,7 +2923,7 @@ type ADKRuntimeConfig struct { func (x *ADKRuntimeConfig) Reset() { *x = ADKRuntimeConfig{} - mi := &file_kagent_config_v1_adk_proto_msgTypes[34] + mi := &file_kagent_config_v1_adk_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2805,7 +2935,7 @@ func (x *ADKRuntimeConfig) String() string { func (*ADKRuntimeConfig) ProtoMessage() {} func (x *ADKRuntimeConfig) ProtoReflect() protoreflect.Message { - mi := &file_kagent_config_v1_adk_proto_msgTypes[34] + mi := &file_kagent_config_v1_adk_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2818,7 +2948,7 @@ func (x *ADKRuntimeConfig) ProtoReflect() protoreflect.Message { // Deprecated: Use ADKRuntimeConfig.ProtoReflect.Descriptor instead. func (*ADKRuntimeConfig) Descriptor() ([]byte, []int) { - return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{34} + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{35} } func (x *ADKRuntimeConfig) GetControlPlaneUrl() string { @@ -2905,7 +3035,7 @@ const file_kagent_config_v1_adk_proto_rawDesc = "" + "\x1akagent/config/v1/adk.proto\x12\x10kagent.config.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1dkagent/config/v1/config.proto\"\x81\x01\n" + "\tADKConfig\x12<\n" + "\aruntime\x18\x01 \x01(\v2\".kagent.config.v1.ADKRuntimeConfigR\aruntime\x126\n" + - "\x05agent\x18\x02 \x01(\v2 .kagent.config.v1.ADKAgentConfigR\x05agent\"\x8b\a\n" + + "\x05agent\x18\x02 \x01(\v2 .kagent.config.v1.ADKAgentConfigR\x05agent\"\x99\b\n" + "\x0eADKAgentConfig\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x126\n" + "\x05model\x18\x02 \x01(\v2 .kagent.config.v1.ADKModelConfigR\x05model\x12 \n" + @@ -2926,7 +3056,9 @@ const file_kagent_config_v1_adk_proto_rawDesc = "" + "\x10skills_directory\x18\x10 \x01(\tR\x0fskillsDirectory\x12?\n" + "\n" + "sub_agents\x18\x11 \x03(\v2 .kagent.config.v1.ADKAgentConfigR\tsubAgents\x129\n" + - "\x06output\x18\x12 \x01(\v2!.kagent.config.v1.ADKOutputConfigR\x06outputB\t\n" + + "\x06output\x18\x12 \x01(\v2!.kagent.config.v1.ADKOutputConfigR\x06output\x12?\n" + + "\bworkflow\x18\x13 \x01(\v2#.kagent.config.v1.ADKWorkflowConfigR\bworkflow\x12K\n" + + "\tisolation\x18\x14 \x01(\x0e2#.kagent.config.v1.ADKAgentIsolationB\b\xbaH\x05\x82\x01\x02\x10\x01R\tisolationB\t\n" + "\a_stream\"\xa1\t\n" + "\x0eADKModelConfig\x12\x14\n" + "\x05model\x18\x01 \x01(\tR\x05model\x12G\n" + @@ -3190,7 +3322,10 @@ const file_kagent_config_v1_adk_proto_rawDesc = "" + "\x0fADKOutputConfig\x12\x1f\n" + "\vjson_schema\x18\x01 \x01(\fR\n" + "jsonSchema\x12\x16\n" + - "\x06sha256\x18\x02 \x01(\tR\x06sha256\"\x82\x01\n" + + "\x06sha256\x18\x02 \x01(\tR\x06sha256\"k\n" + + "\x11ADKWorkflowConfig\x12'\n" + + "\bdocument\x18\x01 \x01(\fB\v\xbaH\bz\x06\x10\x01\x18\x80\x80\x04R\bdocument\x12-\n" + + "\x06sha256\x18\x02 \x01(\tB\x15\xbaH\x12r\x102\x0e^[0-9a-f]{64}$R\x06sha256\"\x82\x01\n" + "\x0fADKAgentPlugins\x122\n" + "\x06skills\x18\x01 \x03(\v2\x1a.kagent.config.v1.ADKSkillR\x06skills\x12;\n" + "\aplugins\x18\x02 \x03(\v2!.kagent.config.v1.ADKPluginBundleR\aplugins\"Y\n" + @@ -3232,7 +3367,11 @@ const file_kagent_config_v1_adk_proto_rawDesc = "" + "\"runtime.telemetry.service_identity\x12bruntime telemetry resource_attributes must not override compiled service.name or service.namespace\x1ac!('service.name' in this.resource_attributes) && !('service.namespace' in this.resource_attributes)R\ttelemetry\x12I\n" + "\rbuiltin_tools\x18\v \x01(\v2$.kagent.config.v1.BuiltinToolsConfigR\fbuiltinToolsB\a\n" + "\x05_portB\x15\n" + - "\x13_max_content_lengthBCZAgithub.com/kagent-dev/kagent/go/api/gen/kagent/config/v1;configv1b\x06proto3" + "\x13_max_content_length*y\n" + + "\x11ADKAgentIsolation\x12#\n" + + "\x1fADK_AGENT_ISOLATION_UNSPECIFIED\x10\x00\x12\x1e\n" + + "\x1aADK_AGENT_ISOLATION_SHARED\x10\x01\x12\x1f\n" + + "\x1bADK_AGENT_ISOLATION_SESSION\x10\x02BCZAgithub.com/kagent-dev/kagent/go/api/gen/kagent/config/v1;configv1b\x06proto3" var ( file_kagent_config_v1_adk_proto_rawDescOnce sync.Once @@ -3246,116 +3385,121 @@ func file_kagent_config_v1_adk_proto_rawDescGZIP() []byte { return file_kagent_config_v1_adk_proto_rawDescData } -var file_kagent_config_v1_adk_proto_msgTypes = make([]protoimpl.MessageInfo, 41) +var file_kagent_config_v1_adk_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_kagent_config_v1_adk_proto_msgTypes = make([]protoimpl.MessageInfo, 42) var file_kagent_config_v1_adk_proto_goTypes = []any{ - (*ADKConfig)(nil), // 0: kagent.config.v1.ADKConfig - (*ADKAgentConfig)(nil), // 1: kagent.config.v1.ADKAgentConfig - (*ADKModelConfig)(nil), // 2: kagent.config.v1.ADKModelConfig - (*ADKOpenAI)(nil), // 3: kagent.config.v1.ADKOpenAI - (*ADKTokenExchangeConfig)(nil), // 4: kagent.config.v1.ADKTokenExchangeConfig - (*ADKGDCHTokenExchangeConfig)(nil), // 5: kagent.config.v1.ADKGDCHTokenExchangeConfig - (*ADKAzureOpenAI)(nil), // 6: kagent.config.v1.ADKAzureOpenAI - (*ADKAnthropic)(nil), // 7: kagent.config.v1.ADKAnthropic - (*ADKMistral)(nil), // 8: kagent.config.v1.ADKMistral - (*ADKGeminiVertexAI)(nil), // 9: kagent.config.v1.ADKGeminiVertexAI - (*ADKGeminiAnthropic)(nil), // 10: kagent.config.v1.ADKGeminiAnthropic - (*ADKOllama)(nil), // 11: kagent.config.v1.ADKOllama - (*ADKGemini)(nil), // 12: kagent.config.v1.ADKGemini - (*ADKBedrock)(nil), // 13: kagent.config.v1.ADKBedrock - (*ADKBedrockGuardrail)(nil), // 14: kagent.config.v1.ADKBedrockGuardrail - (*ADKSAPAICore)(nil), // 15: kagent.config.v1.ADKSAPAICore - (*ADKFoundry)(nil), // 16: kagent.config.v1.ADKFoundry - (*ADKHTTPConnectionParams)(nil), // 17: kagent.config.v1.ADKHTTPConnectionParams - (*ADKSSEConnectionParams)(nil), // 18: kagent.config.v1.ADKSSEConnectionParams - (*ADKHTTPMCPServerConfig)(nil), // 19: kagent.config.v1.ADKHTTPMCPServerConfig - (*ADKSSEMCPServerConfig)(nil), // 20: kagent.config.v1.ADKSSEMCPServerConfig - (*ADKStdioMCPServerConfig)(nil), // 21: kagent.config.v1.ADKStdioMCPServerConfig - (*ADKRemoteAgentConfig)(nil), // 22: kagent.config.v1.ADKRemoteAgentConfig - (*ADKEmbeddingConfig)(nil), // 23: kagent.config.v1.ADKEmbeddingConfig - (*ADKMemoryConfig)(nil), // 24: kagent.config.v1.ADKMemoryConfig - (*ADKContextConfig)(nil), // 25: kagent.config.v1.ADKContextConfig - (*ADKCompressionConfig)(nil), // 26: kagent.config.v1.ADKCompressionConfig - (*ADKOutputConfig)(nil), // 27: kagent.config.v1.ADKOutputConfig - (*ADKAgentPlugins)(nil), // 28: kagent.config.v1.ADKAgentPlugins - (*ADKSkill)(nil), // 29: kagent.config.v1.ADKSkill - (*ADKPluginBundle)(nil), // 30: kagent.config.v1.ADKPluginBundle - (*ADKPluginSource)(nil), // 31: kagent.config.v1.ADKPluginSource - (*ADKPluginGitSource)(nil), // 32: kagent.config.v1.ADKPluginGitSource - (*ADKPluginS3Source)(nil), // 33: kagent.config.v1.ADKPluginS3Source - (*ADKRuntimeConfig)(nil), // 34: kagent.config.v1.ADKRuntimeConfig - nil, // 35: kagent.config.v1.ADKModelConfig.HeadersEntry - nil, // 36: kagent.config.v1.ADKOllama.OptionsEntry - nil, // 37: kagent.config.v1.ADKHTTPConnectionParams.HeadersEntry - nil, // 38: kagent.config.v1.ADKSSEConnectionParams.HeadersEntry - nil, // 39: kagent.config.v1.ADKStdioMCPServerConfig.EnvEntry - nil, // 40: kagent.config.v1.ADKRemoteAgentConfig.HeadersEntry - (*durationpb.Duration)(nil), // 41: google.protobuf.Duration - (*TokenPropagationConfig)(nil), // 42: kagent.config.v1.TokenPropagationConfig - (*TelemetryConfig)(nil), // 43: kagent.config.v1.TelemetryConfig - (*BuiltinToolsConfig)(nil), // 44: kagent.config.v1.BuiltinToolsConfig + (ADKAgentIsolation)(0), // 0: kagent.config.v1.ADKAgentIsolation + (*ADKConfig)(nil), // 1: kagent.config.v1.ADKConfig + (*ADKAgentConfig)(nil), // 2: kagent.config.v1.ADKAgentConfig + (*ADKModelConfig)(nil), // 3: kagent.config.v1.ADKModelConfig + (*ADKOpenAI)(nil), // 4: kagent.config.v1.ADKOpenAI + (*ADKTokenExchangeConfig)(nil), // 5: kagent.config.v1.ADKTokenExchangeConfig + (*ADKGDCHTokenExchangeConfig)(nil), // 6: kagent.config.v1.ADKGDCHTokenExchangeConfig + (*ADKAzureOpenAI)(nil), // 7: kagent.config.v1.ADKAzureOpenAI + (*ADKAnthropic)(nil), // 8: kagent.config.v1.ADKAnthropic + (*ADKMistral)(nil), // 9: kagent.config.v1.ADKMistral + (*ADKGeminiVertexAI)(nil), // 10: kagent.config.v1.ADKGeminiVertexAI + (*ADKGeminiAnthropic)(nil), // 11: kagent.config.v1.ADKGeminiAnthropic + (*ADKOllama)(nil), // 12: kagent.config.v1.ADKOllama + (*ADKGemini)(nil), // 13: kagent.config.v1.ADKGemini + (*ADKBedrock)(nil), // 14: kagent.config.v1.ADKBedrock + (*ADKBedrockGuardrail)(nil), // 15: kagent.config.v1.ADKBedrockGuardrail + (*ADKSAPAICore)(nil), // 16: kagent.config.v1.ADKSAPAICore + (*ADKFoundry)(nil), // 17: kagent.config.v1.ADKFoundry + (*ADKHTTPConnectionParams)(nil), // 18: kagent.config.v1.ADKHTTPConnectionParams + (*ADKSSEConnectionParams)(nil), // 19: kagent.config.v1.ADKSSEConnectionParams + (*ADKHTTPMCPServerConfig)(nil), // 20: kagent.config.v1.ADKHTTPMCPServerConfig + (*ADKSSEMCPServerConfig)(nil), // 21: kagent.config.v1.ADKSSEMCPServerConfig + (*ADKStdioMCPServerConfig)(nil), // 22: kagent.config.v1.ADKStdioMCPServerConfig + (*ADKRemoteAgentConfig)(nil), // 23: kagent.config.v1.ADKRemoteAgentConfig + (*ADKEmbeddingConfig)(nil), // 24: kagent.config.v1.ADKEmbeddingConfig + (*ADKMemoryConfig)(nil), // 25: kagent.config.v1.ADKMemoryConfig + (*ADKContextConfig)(nil), // 26: kagent.config.v1.ADKContextConfig + (*ADKCompressionConfig)(nil), // 27: kagent.config.v1.ADKCompressionConfig + (*ADKOutputConfig)(nil), // 28: kagent.config.v1.ADKOutputConfig + (*ADKWorkflowConfig)(nil), // 29: kagent.config.v1.ADKWorkflowConfig + (*ADKAgentPlugins)(nil), // 30: kagent.config.v1.ADKAgentPlugins + (*ADKSkill)(nil), // 31: kagent.config.v1.ADKSkill + (*ADKPluginBundle)(nil), // 32: kagent.config.v1.ADKPluginBundle + (*ADKPluginSource)(nil), // 33: kagent.config.v1.ADKPluginSource + (*ADKPluginGitSource)(nil), // 34: kagent.config.v1.ADKPluginGitSource + (*ADKPluginS3Source)(nil), // 35: kagent.config.v1.ADKPluginS3Source + (*ADKRuntimeConfig)(nil), // 36: kagent.config.v1.ADKRuntimeConfig + nil, // 37: kagent.config.v1.ADKModelConfig.HeadersEntry + nil, // 38: kagent.config.v1.ADKOllama.OptionsEntry + nil, // 39: kagent.config.v1.ADKHTTPConnectionParams.HeadersEntry + nil, // 40: kagent.config.v1.ADKSSEConnectionParams.HeadersEntry + nil, // 41: kagent.config.v1.ADKStdioMCPServerConfig.EnvEntry + nil, // 42: kagent.config.v1.ADKRemoteAgentConfig.HeadersEntry + (*durationpb.Duration)(nil), // 43: google.protobuf.Duration + (*TokenPropagationConfig)(nil), // 44: kagent.config.v1.TokenPropagationConfig + (*TelemetryConfig)(nil), // 45: kagent.config.v1.TelemetryConfig + (*BuiltinToolsConfig)(nil), // 46: kagent.config.v1.BuiltinToolsConfig } var file_kagent_config_v1_adk_proto_depIdxs = []int32{ - 34, // 0: kagent.config.v1.ADKConfig.runtime:type_name -> kagent.config.v1.ADKRuntimeConfig - 1, // 1: kagent.config.v1.ADKConfig.agent:type_name -> kagent.config.v1.ADKAgentConfig - 2, // 2: kagent.config.v1.ADKAgentConfig.model:type_name -> kagent.config.v1.ADKModelConfig - 19, // 3: kagent.config.v1.ADKAgentConfig.http_tools:type_name -> kagent.config.v1.ADKHTTPMCPServerConfig - 20, // 4: kagent.config.v1.ADKAgentConfig.sse_tools:type_name -> kagent.config.v1.ADKSSEMCPServerConfig - 21, // 5: kagent.config.v1.ADKAgentConfig.stdio_tools:type_name -> kagent.config.v1.ADKStdioMCPServerConfig - 22, // 6: kagent.config.v1.ADKAgentConfig.remote_agents:type_name -> kagent.config.v1.ADKRemoteAgentConfig - 24, // 7: kagent.config.v1.ADKAgentConfig.memory:type_name -> kagent.config.v1.ADKMemoryConfig - 28, // 8: kagent.config.v1.ADKAgentConfig.agent_plugins:type_name -> kagent.config.v1.ADKAgentPlugins - 25, // 9: kagent.config.v1.ADKAgentConfig.context_config:type_name -> kagent.config.v1.ADKContextConfig - 1, // 10: kagent.config.v1.ADKAgentConfig.sub_agents:type_name -> kagent.config.v1.ADKAgentConfig - 27, // 11: kagent.config.v1.ADKAgentConfig.output:type_name -> kagent.config.v1.ADKOutputConfig - 35, // 12: kagent.config.v1.ADKModelConfig.headers:type_name -> kagent.config.v1.ADKModelConfig.HeadersEntry - 3, // 13: kagent.config.v1.ADKModelConfig.openai:type_name -> kagent.config.v1.ADKOpenAI - 6, // 14: kagent.config.v1.ADKModelConfig.azure_openai:type_name -> kagent.config.v1.ADKAzureOpenAI - 7, // 15: kagent.config.v1.ADKModelConfig.anthropic:type_name -> kagent.config.v1.ADKAnthropic - 9, // 16: kagent.config.v1.ADKModelConfig.gemini_vertex_ai:type_name -> kagent.config.v1.ADKGeminiVertexAI - 10, // 17: kagent.config.v1.ADKModelConfig.gemini_anthropic:type_name -> kagent.config.v1.ADKGeminiAnthropic - 11, // 18: kagent.config.v1.ADKModelConfig.ollama:type_name -> kagent.config.v1.ADKOllama - 12, // 19: kagent.config.v1.ADKModelConfig.gemini:type_name -> kagent.config.v1.ADKGemini - 13, // 20: kagent.config.v1.ADKModelConfig.bedrock:type_name -> kagent.config.v1.ADKBedrock - 15, // 21: kagent.config.v1.ADKModelConfig.sap_ai_core:type_name -> kagent.config.v1.ADKSAPAICore - 16, // 22: kagent.config.v1.ADKModelConfig.foundry:type_name -> kagent.config.v1.ADKFoundry - 8, // 23: kagent.config.v1.ADKModelConfig.mistral:type_name -> kagent.config.v1.ADKMistral - 41, // 24: kagent.config.v1.ADKOpenAI.timeout:type_name -> google.protobuf.Duration - 4, // 25: kagent.config.v1.ADKOpenAI.token_exchange:type_name -> kagent.config.v1.ADKTokenExchangeConfig - 5, // 26: kagent.config.v1.ADKTokenExchangeConfig.gdch_service_account:type_name -> kagent.config.v1.ADKGDCHTokenExchangeConfig - 41, // 27: kagent.config.v1.ADKAnthropic.timeout:type_name -> google.protobuf.Duration - 41, // 28: kagent.config.v1.ADKMistral.timeout:type_name -> google.protobuf.Duration - 36, // 29: kagent.config.v1.ADKOllama.options:type_name -> kagent.config.v1.ADKOllama.OptionsEntry - 14, // 30: kagent.config.v1.ADKBedrock.guardrail:type_name -> kagent.config.v1.ADKBedrockGuardrail - 41, // 31: kagent.config.v1.ADKBedrock.read_timeout:type_name -> google.protobuf.Duration - 41, // 32: kagent.config.v1.ADKBedrock.connect_timeout:type_name -> google.protobuf.Duration - 37, // 33: kagent.config.v1.ADKHTTPConnectionParams.headers:type_name -> kagent.config.v1.ADKHTTPConnectionParams.HeadersEntry - 41, // 34: kagent.config.v1.ADKHTTPConnectionParams.timeout:type_name -> google.protobuf.Duration - 41, // 35: kagent.config.v1.ADKHTTPConnectionParams.sse_read_timeout:type_name -> google.protobuf.Duration - 38, // 36: kagent.config.v1.ADKSSEConnectionParams.headers:type_name -> kagent.config.v1.ADKSSEConnectionParams.HeadersEntry - 41, // 37: kagent.config.v1.ADKSSEConnectionParams.timeout:type_name -> google.protobuf.Duration - 41, // 38: kagent.config.v1.ADKSSEConnectionParams.sse_read_timeout:type_name -> google.protobuf.Duration - 17, // 39: kagent.config.v1.ADKHTTPMCPServerConfig.params:type_name -> kagent.config.v1.ADKHTTPConnectionParams - 18, // 40: kagent.config.v1.ADKSSEMCPServerConfig.params:type_name -> kagent.config.v1.ADKSSEConnectionParams - 39, // 41: kagent.config.v1.ADKStdioMCPServerConfig.env:type_name -> kagent.config.v1.ADKStdioMCPServerConfig.EnvEntry - 40, // 42: kagent.config.v1.ADKRemoteAgentConfig.headers:type_name -> kagent.config.v1.ADKRemoteAgentConfig.HeadersEntry - 41, // 43: kagent.config.v1.ADKRemoteAgentConfig.timeout:type_name -> google.protobuf.Duration - 23, // 44: kagent.config.v1.ADKMemoryConfig.embedding:type_name -> kagent.config.v1.ADKEmbeddingConfig - 26, // 45: kagent.config.v1.ADKContextConfig.compaction:type_name -> kagent.config.v1.ADKCompressionConfig - 2, // 46: kagent.config.v1.ADKCompressionConfig.summarizer_model:type_name -> kagent.config.v1.ADKModelConfig - 29, // 47: kagent.config.v1.ADKAgentPlugins.skills:type_name -> kagent.config.v1.ADKSkill - 30, // 48: kagent.config.v1.ADKAgentPlugins.plugins:type_name -> kagent.config.v1.ADKPluginBundle - 31, // 49: kagent.config.v1.ADKSkill.source:type_name -> kagent.config.v1.ADKPluginSource - 31, // 50: kagent.config.v1.ADKPluginBundle.source:type_name -> kagent.config.v1.ADKPluginSource - 32, // 51: kagent.config.v1.ADKPluginSource.git:type_name -> kagent.config.v1.ADKPluginGitSource - 33, // 52: kagent.config.v1.ADKPluginSource.s3:type_name -> kagent.config.v1.ADKPluginS3Source - 42, // 53: kagent.config.v1.ADKRuntimeConfig.token_propagation:type_name -> kagent.config.v1.TokenPropagationConfig - 43, // 54: kagent.config.v1.ADKRuntimeConfig.telemetry:type_name -> kagent.config.v1.TelemetryConfig - 44, // 55: kagent.config.v1.ADKRuntimeConfig.builtin_tools:type_name -> kagent.config.v1.BuiltinToolsConfig - 56, // [56:56] is the sub-list for method output_type - 56, // [56:56] is the sub-list for method input_type - 56, // [56:56] is the sub-list for extension type_name - 56, // [56:56] is the sub-list for extension extendee - 0, // [0:56] is the sub-list for field type_name + 36, // 0: kagent.config.v1.ADKConfig.runtime:type_name -> kagent.config.v1.ADKRuntimeConfig + 2, // 1: kagent.config.v1.ADKConfig.agent:type_name -> kagent.config.v1.ADKAgentConfig + 3, // 2: kagent.config.v1.ADKAgentConfig.model:type_name -> kagent.config.v1.ADKModelConfig + 20, // 3: kagent.config.v1.ADKAgentConfig.http_tools:type_name -> kagent.config.v1.ADKHTTPMCPServerConfig + 21, // 4: kagent.config.v1.ADKAgentConfig.sse_tools:type_name -> kagent.config.v1.ADKSSEMCPServerConfig + 22, // 5: kagent.config.v1.ADKAgentConfig.stdio_tools:type_name -> kagent.config.v1.ADKStdioMCPServerConfig + 23, // 6: kagent.config.v1.ADKAgentConfig.remote_agents:type_name -> kagent.config.v1.ADKRemoteAgentConfig + 25, // 7: kagent.config.v1.ADKAgentConfig.memory:type_name -> kagent.config.v1.ADKMemoryConfig + 30, // 8: kagent.config.v1.ADKAgentConfig.agent_plugins:type_name -> kagent.config.v1.ADKAgentPlugins + 26, // 9: kagent.config.v1.ADKAgentConfig.context_config:type_name -> kagent.config.v1.ADKContextConfig + 2, // 10: kagent.config.v1.ADKAgentConfig.sub_agents:type_name -> kagent.config.v1.ADKAgentConfig + 28, // 11: kagent.config.v1.ADKAgentConfig.output:type_name -> kagent.config.v1.ADKOutputConfig + 29, // 12: kagent.config.v1.ADKAgentConfig.workflow:type_name -> kagent.config.v1.ADKWorkflowConfig + 0, // 13: kagent.config.v1.ADKAgentConfig.isolation:type_name -> kagent.config.v1.ADKAgentIsolation + 37, // 14: kagent.config.v1.ADKModelConfig.headers:type_name -> kagent.config.v1.ADKModelConfig.HeadersEntry + 4, // 15: kagent.config.v1.ADKModelConfig.openai:type_name -> kagent.config.v1.ADKOpenAI + 7, // 16: kagent.config.v1.ADKModelConfig.azure_openai:type_name -> kagent.config.v1.ADKAzureOpenAI + 8, // 17: kagent.config.v1.ADKModelConfig.anthropic:type_name -> kagent.config.v1.ADKAnthropic + 10, // 18: kagent.config.v1.ADKModelConfig.gemini_vertex_ai:type_name -> kagent.config.v1.ADKGeminiVertexAI + 11, // 19: kagent.config.v1.ADKModelConfig.gemini_anthropic:type_name -> kagent.config.v1.ADKGeminiAnthropic + 12, // 20: kagent.config.v1.ADKModelConfig.ollama:type_name -> kagent.config.v1.ADKOllama + 13, // 21: kagent.config.v1.ADKModelConfig.gemini:type_name -> kagent.config.v1.ADKGemini + 14, // 22: kagent.config.v1.ADKModelConfig.bedrock:type_name -> kagent.config.v1.ADKBedrock + 16, // 23: kagent.config.v1.ADKModelConfig.sap_ai_core:type_name -> kagent.config.v1.ADKSAPAICore + 17, // 24: kagent.config.v1.ADKModelConfig.foundry:type_name -> kagent.config.v1.ADKFoundry + 9, // 25: kagent.config.v1.ADKModelConfig.mistral:type_name -> kagent.config.v1.ADKMistral + 43, // 26: kagent.config.v1.ADKOpenAI.timeout:type_name -> google.protobuf.Duration + 5, // 27: kagent.config.v1.ADKOpenAI.token_exchange:type_name -> kagent.config.v1.ADKTokenExchangeConfig + 6, // 28: kagent.config.v1.ADKTokenExchangeConfig.gdch_service_account:type_name -> kagent.config.v1.ADKGDCHTokenExchangeConfig + 43, // 29: kagent.config.v1.ADKAnthropic.timeout:type_name -> google.protobuf.Duration + 43, // 30: kagent.config.v1.ADKMistral.timeout:type_name -> google.protobuf.Duration + 38, // 31: kagent.config.v1.ADKOllama.options:type_name -> kagent.config.v1.ADKOllama.OptionsEntry + 15, // 32: kagent.config.v1.ADKBedrock.guardrail:type_name -> kagent.config.v1.ADKBedrockGuardrail + 43, // 33: kagent.config.v1.ADKBedrock.read_timeout:type_name -> google.protobuf.Duration + 43, // 34: kagent.config.v1.ADKBedrock.connect_timeout:type_name -> google.protobuf.Duration + 39, // 35: kagent.config.v1.ADKHTTPConnectionParams.headers:type_name -> kagent.config.v1.ADKHTTPConnectionParams.HeadersEntry + 43, // 36: kagent.config.v1.ADKHTTPConnectionParams.timeout:type_name -> google.protobuf.Duration + 43, // 37: kagent.config.v1.ADKHTTPConnectionParams.sse_read_timeout:type_name -> google.protobuf.Duration + 40, // 38: kagent.config.v1.ADKSSEConnectionParams.headers:type_name -> kagent.config.v1.ADKSSEConnectionParams.HeadersEntry + 43, // 39: kagent.config.v1.ADKSSEConnectionParams.timeout:type_name -> google.protobuf.Duration + 43, // 40: kagent.config.v1.ADKSSEConnectionParams.sse_read_timeout:type_name -> google.protobuf.Duration + 18, // 41: kagent.config.v1.ADKHTTPMCPServerConfig.params:type_name -> kagent.config.v1.ADKHTTPConnectionParams + 19, // 42: kagent.config.v1.ADKSSEMCPServerConfig.params:type_name -> kagent.config.v1.ADKSSEConnectionParams + 41, // 43: kagent.config.v1.ADKStdioMCPServerConfig.env:type_name -> kagent.config.v1.ADKStdioMCPServerConfig.EnvEntry + 42, // 44: kagent.config.v1.ADKRemoteAgentConfig.headers:type_name -> kagent.config.v1.ADKRemoteAgentConfig.HeadersEntry + 43, // 45: kagent.config.v1.ADKRemoteAgentConfig.timeout:type_name -> google.protobuf.Duration + 24, // 46: kagent.config.v1.ADKMemoryConfig.embedding:type_name -> kagent.config.v1.ADKEmbeddingConfig + 27, // 47: kagent.config.v1.ADKContextConfig.compaction:type_name -> kagent.config.v1.ADKCompressionConfig + 3, // 48: kagent.config.v1.ADKCompressionConfig.summarizer_model:type_name -> kagent.config.v1.ADKModelConfig + 31, // 49: kagent.config.v1.ADKAgentPlugins.skills:type_name -> kagent.config.v1.ADKSkill + 32, // 50: kagent.config.v1.ADKAgentPlugins.plugins:type_name -> kagent.config.v1.ADKPluginBundle + 33, // 51: kagent.config.v1.ADKSkill.source:type_name -> kagent.config.v1.ADKPluginSource + 33, // 52: kagent.config.v1.ADKPluginBundle.source:type_name -> kagent.config.v1.ADKPluginSource + 34, // 53: kagent.config.v1.ADKPluginSource.git:type_name -> kagent.config.v1.ADKPluginGitSource + 35, // 54: kagent.config.v1.ADKPluginSource.s3:type_name -> kagent.config.v1.ADKPluginS3Source + 44, // 55: kagent.config.v1.ADKRuntimeConfig.token_propagation:type_name -> kagent.config.v1.TokenPropagationConfig + 45, // 56: kagent.config.v1.ADKRuntimeConfig.telemetry:type_name -> kagent.config.v1.TelemetryConfig + 46, // 57: kagent.config.v1.ADKRuntimeConfig.builtin_tools:type_name -> kagent.config.v1.BuiltinToolsConfig + 58, // [58:58] is the sub-list for method output_type + 58, // [58:58] is the sub-list for method input_type + 58, // [58:58] is the sub-list for extension type_name + 58, // [58:58] is the sub-list for extension extendee + 0, // [0:58] is the sub-list for field type_name } func init() { file_kagent_config_v1_adk_proto_init() } @@ -3391,24 +3535,25 @@ func file_kagent_config_v1_adk_proto_init() { file_kagent_config_v1_adk_proto_msgTypes[18].OneofWrappers = []any{} file_kagent_config_v1_adk_proto_msgTypes[23].OneofWrappers = []any{} file_kagent_config_v1_adk_proto_msgTypes[26].OneofWrappers = []any{} - file_kagent_config_v1_adk_proto_msgTypes[31].OneofWrappers = []any{ + file_kagent_config_v1_adk_proto_msgTypes[32].OneofWrappers = []any{ (*ADKPluginSource_Git)(nil), (*ADKPluginSource_S3)(nil), (*ADKPluginSource_Directory)(nil), } - file_kagent_config_v1_adk_proto_msgTypes[34].OneofWrappers = []any{} + file_kagent_config_v1_adk_proto_msgTypes[35].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kagent_config_v1_adk_proto_rawDesc), len(file_kagent_config_v1_adk_proto_rawDesc)), - NumEnums: 0, - NumMessages: 41, + NumEnums: 1, + NumMessages: 42, NumExtensions: 0, NumServices: 0, }, GoTypes: file_kagent_config_v1_adk_proto_goTypes, DependencyIndexes: file_kagent_config_v1_adk_proto_depIdxs, + EnumInfos: file_kagent_config_v1_adk_proto_enumTypes, MessageInfos: file_kagent_config_v1_adk_proto_msgTypes, }.Build() File_kagent_config_v1_adk_proto = out.File diff --git a/go/api/v1alpha3/agenttemplate_types.go b/go/api/v1alpha3/agenttemplate_types.go index 491adcd860..e647a9ceef 100644 --- a/go/api/v1alpha3/agenttemplate_types.go +++ b/go/api/v1alpha3/agenttemplate_types.go @@ -67,6 +67,23 @@ type MCPToolBinding struct { RequireApproval bool `json:"requireApproval,omitempty"` } +// SubAgentIsolation says how much of the Session a subagent shares with its parent. +// +kubebuilder:validation:Enum=Shared;Isolated +type SubAgentIsolation string + +const ( + // SubAgentIsolationShared hands the conversation to the subagent in the + // parent's Session. The parent transfers control and the subagent answers in + // the same history. It is the default. + SubAgentIsolationShared SubAgentIsolation = "Shared" + // SubAgentIsolationIsolated calls the subagent as a tool in a new + // conversation seeded with a copy of the parent's state. The subagent never + // sees the parent's history, parallel calls cannot interfere, its state + // writes are not copied back, and it cannot pause for human input, so its + // MCP tools cannot require approval. + SubAgentIsolationIsolated SubAgentIsolation = "Isolated" +) + // SubAgentToolBinding exposes a same-namespace AgentTemplate as a logical tool. type SubAgentToolBinding struct { // +kubebuilder:validation:MinLength=1 @@ -80,6 +97,12 @@ type SubAgentToolBinding struct { // +kubebuilder:validation:XValidation:rule="has(self.name) && self.name != ''",message="templateRef.name must not be empty" // +required TemplateRef *corev1.LocalObjectReference `json:"templateRef"` + // Isolation selects how the parent runs the subagent. Omitting it is Shared. + // Isolated gives every call its own conversation, which a parent needs when + // it fans out to the same subagent in parallel. Only the kagent and BYO + // Harnesses support Isolated. + // +optional + Isolation SubAgentIsolation `json:"isolation,omitempty"` // Deferred until Dedicated subagents can create and invoke their own Session. // AgentRef *corev1.LocalObjectReference `json:"agentRef,omitempty"` @@ -173,6 +196,7 @@ type PluginBundle struct { // AgentTemplateSpec defines portable agent behavior. // +kubebuilder:validation:AtMostOneOf=systemPrompt;systemPromptFrom // +kubebuilder:validation:AtMostOneOf=outputSchema;outputSchemaFrom +// +kubebuilder:validation:XValidation:rule="!has(self.workflowFrom) || (!has(self.systemPrompt) && !has(self.systemPromptFrom))",message="workflowFrom cannot be combined with systemPrompt or systemPromptFrom" // +kubebuilder:validation:XValidation:rule="!has(self.outputSchemaStrict) || !self.outputSchemaStrict || has(self.outputSchema) || has(self.outputSchemaFrom)",message="outputSchemaStrict requires outputSchema or outputSchemaFrom" type AgentTemplateSpec struct { // ModelConfig is required by managed harnesses and optional for BYO harnesses. @@ -186,6 +210,13 @@ type AgentTemplateSpec struct { // SystemPromptFrom references prompt text in a same-namespace ConfigMap. // +optional SystemPromptFrom *ConfigMapKeyReference `json:"systemPromptFrom,omitempty"` + // WorkflowFrom references an adk.declarative/v0.2 AgentSystem document, as + // YAML or JSON, in a same-namespace ConfigMap key. The controller validates + // the document's structure and delivers it to the runtime; the BYO image + // resolves the functions, tools, and models it names. Only a root template + // on a BYO Harness may set it. + // +optional + WorkflowFrom *ConfigMapKeyReference `json:"workflowFrom,omitempty"` // OutputSchema constrains successful terminal output when this template is // compiled as the root agent. // +optional diff --git a/go/api/v1alpha3/configuration_crd_cel_test.go b/go/api/v1alpha3/configuration_crd_cel_test.go index 671669b751..e21143cb85 100644 --- a/go/api/v1alpha3/configuration_crd_cel_test.go +++ b/go/api/v1alpha3/configuration_crd_cel_test.go @@ -393,6 +393,55 @@ func TestConfigurationCRDValidation(t *testing.T) { name: "AgentTemplate accepts outputSchemaStrict true with outputSchemaFrom", object: unstructuredStrictTemplate(namespace, "strict-true-schema-from", true, nil, &ConfigMapKeyReference{Name: "schemas", Key: "output.json"}), }, + { + name: "AgentTemplate accepts workflowFrom without a prompt", + object: workflowTemplate(namespace, "workflow-only", func(s *AgentTemplateSpec) {}), + }, + { + name: "AgentTemplate rejects workflowFrom with systemPrompt", + object: workflowTemplate(namespace, "workflow-prompt", func(s *AgentTemplateSpec) { + s.SystemPrompt = "You are helpful." + }), + wantReject: "workflowFrom cannot be combined with systemPrompt or systemPromptFrom", + }, + { + name: "AgentTemplate rejects workflowFrom with systemPromptFrom", + object: workflowTemplate(namespace, "workflow-prompt-from", func(s *AgentTemplateSpec) { + s.SystemPromptFrom = &ConfigMapKeyReference{Name: "prompts", Key: "system"} + }), + wantReject: "workflowFrom cannot be combined with systemPrompt or systemPromptFrom", + }, + { + name: "AgentTemplate rejects workflowFrom with an empty key", + object: workflowTemplate(namespace, "workflow-empty-key", func(s *AgentTemplateSpec) { + s.WorkflowFrom.Key = "" + }), + wantReject: "spec.workflowFrom.key", + }, + { + name: "AgentTemplate rejects workflowFrom with an empty name", + object: workflowTemplate(namespace, "workflow-empty-name", func(s *AgentTemplateSpec) { + s.WorkflowFrom.Name = "" + }), + wantReject: "spec.workflowFrom.name", + }, + { + name: "AgentTemplate accepts an Isolated subagent", + object: subAgentTemplate(namespace, "subagent-isolated", SubAgentIsolationIsolated), + }, + { + name: "AgentTemplate accepts a Shared subagent", + object: subAgentTemplate(namespace, "subagent-shared", SubAgentIsolationShared), + }, + { + name: "AgentTemplate accepts a subagent without isolation", + object: subAgentTemplate(namespace, "subagent-default", ""), + }, + { + name: "AgentTemplate rejects an unknown subagent isolation", + object: subAgentTemplate(namespace, "subagent-private", SubAgentIsolation("Private")), + wantReject: "Unsupported value", + }, { name: "AgentTemplate rejects unsupported MCP server kind", object: validAgentTemplate(namespace, "unsupported-mcp-kind", []ToolBinding{{ @@ -669,6 +718,19 @@ func validAgentTemplate(namespace, name string, tools []ToolBinding) *AgentTempl } } +func subAgentTemplate(namespace, name string, isolation SubAgentIsolation) *AgentTemplate { + return validAgentTemplate(namespace, name, []ToolBinding{{SubAgent: &SubAgentToolBinding{ + Name: "reviewer", Description: "Reviews work.", TemplateRef: &corev1.LocalObjectReference{Name: "reviewer"}, Isolation: isolation, + }}}) +} + +func workflowTemplate(namespace, name string, mutate func(*AgentTemplateSpec)) *AgentTemplate { + template := validAgentTemplate(namespace, name, nil) + template.Spec.WorkflowFrom = &ConfigMapKeyReference{Name: "workflows", Key: "agents.yaml"} + mutate(&template.Spec) + return template +} + func unstructuredStrictTemplate(namespace, name string, strict bool, schema *apiextensionsv1.JSON, schemaFrom *ConfigMapKeyReference) ctrlclient.Object { template := validAgentTemplate(namespace, name, nil) template.Spec.OutputSchema = schema diff --git a/go/api/v1alpha3/zz_generated.deepcopy.go b/go/api/v1alpha3/zz_generated.deepcopy.go index 5be448f83b..3883fc6c72 100644 --- a/go/api/v1alpha3/zz_generated.deepcopy.go +++ b/go/api/v1alpha3/zz_generated.deepcopy.go @@ -312,6 +312,11 @@ func (in *AgentTemplateSpec) DeepCopyInto(out *AgentTemplateSpec) { *out = new(ConfigMapKeyReference) **out = **in } + if in.WorkflowFrom != nil { + in, out := &in.WorkflowFrom, &out.WorkflowFrom + *out = new(ConfigMapKeyReference) + **out = **in + } if in.OutputSchema != nil { in, out := &in.OutputSchema, &out.OutputSchema *out = new(apiextensionsv1.JSON) diff --git a/go/core/internal/translator/adkconfig/builder.go b/go/core/internal/translator/adkconfig/builder.go index 54ce256187..1ae7860c70 100644 --- a/go/core/internal/translator/adkconfig/builder.go +++ b/go/core/internal/translator/adkconfig/builder.go @@ -88,6 +88,9 @@ func (c *Builder) Build(ctx context.Context, input *translator.HarnessInput) (*R return nil, err } } + if input.Harness.Spec.BYO != nil { + applyWorkflow(result.Config, input.Workflow) + } // The Python runtime needs an async SQLite driver; the Go runtime accepts // this URL and strips the driver before opening the same durable database. result.Config.SessionDbUrl = "sqlite+aiosqlite:////data/sessions.db" @@ -147,6 +150,10 @@ func (c *Builder) compileAgent(ctx context.Context, input *translator.AgentInput return nil, err } child.Config.Name, child.Config.Description = binding.Name, binding.Description + // Shared is the default and stays unset, so existing revisions do not change. + if binding.Isolation == v1alpha3.SubAgentIsolationIsolated { + child.Config.Isolation = configv1.ADKAgentIsolation_ADK_AGENT_ISOLATION_SESSION + } cfg.SubAgents = append(cfg.SubAgents, child.Config) result.MCPTools = append(result.MCPTools, child.MCPTools...) result.Models = append(result.Models, child.Models...) @@ -160,13 +167,16 @@ func (c *Builder) compileAgent(ctx context.Context, input *translator.AgentInput // BuildProvenance records every Kubernetes input that can change the compiled // runtime. Sorting makes the JSON stable across map iteration order. -func (c *Builder) BuildProvenance(ctx context.Context, harness *translator.HarnessConfiguration, compiled *Result) ([]byte, error) { +func (c *Builder) BuildProvenance(ctx context.Context, harness *translator.HarnessConfiguration, compiled *Result, extraConfigMaps ...string) ([]byte, error) { templates, models := compiled.Templates, compiled.Models var entries []provenanceEntry // The Harness is always a standalone resource. Inline template configuration // is recorded by the enclosing Agent provenance below. entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), "Harness", harness.Name, harness.Source.UID, harness.Source.Generation, harness.Spec)) configMaps := map[string]struct{}{} + for _, name := range extraConfigMaps { + configMaps[name] = struct{}{} + } for _, template := range templates { if template.Source != nil { entries = append(entries, objectProvenance(v1alpha3.GroupVersion.String(), "AgentTemplate", template.Name, template.Source.UID, template.Source.Generation, template.Spec)) @@ -177,6 +187,9 @@ func (c *Builder) BuildProvenance(ctx context.Context, harness *translator.Harne if template.Spec.OutputSchemaFrom != nil { configMaps[template.Spec.OutputSchemaFrom.Name] = struct{}{} } + if template.Spec.WorkflowFrom != nil { + configMaps[template.Spec.WorkflowFrom.Name] = struct{}{} + } if template.Spec.PromptTemplate != nil { for _, source := range template.Spec.PromptTemplate.DataSources { configMaps[source.Name] = struct{}{} diff --git a/go/core/internal/translator/adkconfig/runtime.go b/go/core/internal/translator/adkconfig/runtime.go index 776daf6bf3..1ec5a0fe7d 100644 --- a/go/core/internal/translator/adkconfig/runtime.go +++ b/go/core/internal/translator/adkconfig/runtime.go @@ -124,6 +124,18 @@ func applyOutputSchema(config *configv1.ADKAgentConfig, output *translator.Resol return nil } +// applyWorkflow delivers the validated declarative workflow document to the +// BYO image, which builds its agents from it. +func applyWorkflow(config *configv1.ADKAgentConfig, workflow *translator.ResolvedWorkflow) { + if workflow == nil { + return + } + config.Workflow = &configv1.ADKWorkflowConfig{ + Document: append([]byte(nil), workflow.Document...), + Sha256: workflow.SHA256, + } +} + func requireModels(input *translator.AgentInput) error { if input.ResolvedModelConfig == nil || input.ResolvedModelConfig.Config == nil { return translator.NewValidationError("kagent ModelConfig is required") @@ -152,3 +164,21 @@ func (c *Builder) applyMemory(result *Result, harness *translator.HarnessConfigu result.Egress = append(result.Egress, model.Egress...) return nil } + +// ValidateSubAgentIsolation rejects combinations the runtime cannot honor. +// +// STS token exchange caches the exchanged token by session ID. An Isolated +// subagent runs in a new session, so its MCP requests would find no token and +// fall back to the caller's own credential, which the exchange exists to +// replace. Refuse the combination instead of sending the wrong credential. +func ValidateSubAgentIsolation(config *configv1.ADKConfig) error { + if config.GetRuntime().GetTokenPropagation().GetStsWellKnownUri() == "" { + return nil + } + for _, sub := range config.GetAgent().GetSubAgents() { + if sub.GetIsolation() == configv1.ADKAgentIsolation_ADK_AGENT_ISOLATION_SESSION { + return translator.NewValidationError("subagent %q is Isolated, which cannot be combined with STS token exchange: the exchanged token is cached per session and an isolated subagent runs in a new one", sub.GetName()) + } + } + return nil +} diff --git a/go/core/internal/translator/byo/compiler.go b/go/core/internal/translator/byo/compiler.go index 9c1f6e69d7..47624bbcc2 100644 --- a/go/core/internal/translator/byo/compiler.go +++ b/go/core/internal/translator/byo/compiler.go @@ -38,10 +38,14 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) return nil, err } template, harness := input.Root.Template, input.Harness - configJSON, err := runtimeconfig.Marshal(&configv1.ADKConfig{ + adkConfig := &configv1.ADKConfig{ Runtime: translator.RuntimeConfig(c.settings, input.AgentName, template.Namespace), Agent: compiled.Config, - }) + } + if err := adkconfig.ValidateSubAgentIsolation(adkConfig); err != nil { + return nil, err + } + configJSON, err := runtimeconfig.Marshal(adkConfig) if err != nil { var invalid *protovalidate.ValidationError if errors.As(err, &invalid) { @@ -66,7 +70,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) AgentName: input.AgentName, AgentNamespace: template.Namespace, }, environment) } - provenance, err := c.config.BuildProvenance(ctx, harness, compiled) + provenance, err := c.config.BuildProvenance(ctx, harness, compiled, input.Workflow.ConfigMapNames()...) if err != nil { return nil, fmt.Errorf("build revision provenance: %w", err) } diff --git a/go/core/internal/translator/compiler.go b/go/core/internal/translator/compiler.go index e8602b3bc9..1131ef6eae 100644 --- a/go/core/internal/translator/compiler.go +++ b/go/core/internal/translator/compiler.go @@ -58,6 +58,7 @@ type resolvedTemplate struct { type resolvedTemplateBinding struct { Name string Description string + Isolation v1alpha3.SubAgentIsolation Agent *resolvedTemplate } @@ -68,6 +69,9 @@ type HarnessInput struct { Harness *HarnessConfiguration Root *AgentInput OutputSchema *ResolvedOutputSchema + // Workflow is the validated declarative workflow document of the root + // template. Only BYO Harnesses receive one. + Workflow *ResolvedWorkflow } // AgentInput contains resolved Kubernetes inputs for one agent. @@ -89,6 +93,7 @@ type ResolvedMCPTool struct { type AgentInputBinding struct { Name string Description string + Isolation v1alpha3.SubAgentIsolation Agent *AgentInput } @@ -218,6 +223,9 @@ func (c *Compiler) resolveTree(ctx context.Context, harness *HarnessConfiguratio defer delete(path, template.Name) } + if child && template.Spec.WorkflowFrom != nil { + return nil, NewValidationError("AgentTemplate %q sets workflowFrom, which is only supported on the root template", template.Name) + } resolved := &resolvedTemplate{Template: template} for _, tool := range template.Spec.Tools { if tool.SubAgent == nil { @@ -243,7 +251,7 @@ func (c *Compiler) resolveTree(ctx context.Context, harness *HarnessConfiguratio if child { return nil, NewValidationError("consecutive Shared AgentTemplate tools exceed the kagent runtime boundary") } - resolved.Shared = append(resolved.Shared, resolvedTemplateBinding{Name: binding.Name, Description: binding.Description, Agent: agent}) + resolved.Shared = append(resolved.Shared, resolvedTemplateBinding{Name: binding.Name, Description: binding.Description, Isolation: binding.Isolation, Agent: agent}) } return resolved, nil } @@ -316,7 +324,10 @@ func (c *Compiler) buildInputs(ctx context.Context, tree *resolvedTemplateTree) if err != nil { return nil, err } - input.Shared = append(input.Shared, AgentInputBinding{Name: binding.Name, Description: binding.Description, Agent: child}) + if err := validateSubAgentIsolation(harnessType(tree.Harness), binding.Name, binding.Isolation, child); err != nil { + return nil, err + } + input.Shared = append(input.Shared, AgentInputBinding{Name: binding.Name, Description: binding.Description, Isolation: binding.Isolation, Agent: child}) } return input, nil } @@ -332,5 +343,12 @@ func (c *Compiler) buildInputs(ctx context.Context, tree *resolvedTemplateTree) if outputSchema != nil && harnessType(tree.Harness) != HarnessTypeKagent { return nil, NewValidationError("Harness runtime %q does not support structured output", harnessType(tree.Harness)) } - return &HarnessInput{Harness: tree.Harness, Root: root, OutputSchema: outputSchema}, nil + workflow, err := c.resolveWorkflow(ctx, tree.Root.Template) + if err != nil { + return nil, err + } + if workflow != nil && harnessType(tree.Harness) != HarnessTypeBYO { + return nil, NewValidationError("Harness runtime %q does not run declarative workflows; use a BYO Harness", harnessType(tree.Harness)) + } + return &HarnessInput{Harness: tree.Harness, Root: root, OutputSchema: outputSchema, Workflow: workflow}, nil } diff --git a/go/core/internal/translator/kagent/compiler.go b/go/core/internal/translator/kagent/compiler.go index 866802404c..6cf13208e8 100644 --- a/go/core/internal/translator/kagent/compiler.go +++ b/go/core/internal/translator/kagent/compiler.go @@ -40,10 +40,14 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) return nil, err } template, harness := input.Root.Template, input.Harness - configJSON, err := runtimeconfig.Marshal(&configv1.ADKConfig{ + adkConfig := &configv1.ADKConfig{ Runtime: translator.RuntimeConfig(c.settings, input.AgentName, template.Namespace), Agent: compiled.Config, - }) + } + if err := adkconfig.ValidateSubAgentIsolation(adkConfig); err != nil { + return nil, err + } + configJSON, err := runtimeconfig.Marshal(adkConfig) if err != nil { var invalid *protovalidate.ValidationError if errors.As(err, &invalid) { diff --git a/go/core/internal/translator/outputschema.go b/go/core/internal/translator/outputschema.go index a4643f5574..01e7fbd149 100644 --- a/go/core/internal/translator/outputschema.go +++ b/go/core/internal/translator/outputschema.go @@ -15,6 +15,7 @@ import ( "k8s.io/apimachinery/pkg/types" adkoutputschema "github.com/kagent-dev/kagent/go/adk/pkg/outputschema" + "github.com/kagent-dev/kagent/go/api/v1alpha3" ) const maxOutputSchemaBytes = 64 << 10 @@ -81,6 +82,22 @@ type ResolvedOutputSchema struct { Strict bool } +// resolveConfigMapKey reads one key of a same-namespace ConfigMap. purpose names +// the input in errors. The fetch registers the ConfigMap as a dependency, so +// changes to it recompile the Agent. +func (c *Compiler) resolveConfigMapKey(namespace, purpose string, ref *v1alpha3.ConfigMapKeyReference) ([]byte, *ResolvedConfigMapKeyReference, error) { + key := types.NamespacedName{Namespace: namespace, Name: ref.Name} + configMap := krt.FetchOne(c.ctx, c.collections.ConfigMaps, krt.FilterObjectName(key)) + if configMap == nil { + return nil, nil, fmt.Errorf("resolve %s ConfigMap %q: not found", purpose, ref.Name) + } + value, ok := (*configMap).Data[ref.Key] + if !ok { + return nil, nil, fmt.Errorf("resolve %s ConfigMap %q: key %q not found", purpose, ref.Name, ref.Key) + } + return []byte(value), &ResolvedConfigMapKeyReference{NamespacedName: key, Key: ref.Key}, nil +} + // ResolvedConfigMapKeyReference identifies the exact ConfigMap input used by a // compiled revision. type ResolvedConfigMapKeyReference struct { @@ -95,18 +112,11 @@ func (c *Compiler) resolveOutputSchema(_ context.Context, template *TemplateConf case template.Spec.OutputSchema != nil: raw = template.Spec.OutputSchema.Raw case template.Spec.OutputSchemaFrom != nil: - ref := template.Spec.OutputSchemaFrom - key := types.NamespacedName{Namespace: template.Namespace, Name: ref.Name} - configMap := krt.FetchOne(c.ctx, c.collections.ConfigMaps, krt.FilterObjectName(key)) - if configMap == nil { - return nil, fmt.Errorf("resolve output schema ConfigMap %q: not found", ref.Name) - } - value, ok := (*configMap).Data[ref.Key] - if !ok { - return nil, fmt.Errorf("resolve output schema ConfigMap %q: key %q not found", ref.Name, ref.Key) + var err error + raw, source, err = c.resolveConfigMapKey(template.Namespace, "output schema", template.Spec.OutputSchemaFrom) + if err != nil { + return nil, err } - raw = []byte(value) - source = &ResolvedConfigMapKeyReference{NamespacedName: key, Key: ref.Key} default: return nil, nil } diff --git a/go/core/internal/translator/subagent.go b/go/core/internal/translator/subagent.go new file mode 100644 index 0000000000..48cbe0abd8 --- /dev/null +++ b/go/core/internal/translator/subagent.go @@ -0,0 +1,29 @@ +package translator + +import ( + "github.com/kagent-dev/kagent/go/api/v1alpha3" +) + +// validateSubAgentIsolation rejects an Isolated subagent that the selected +// runtime cannot run as intended. +// +// An Isolated subagent runs as a tool call in a conversation of its own, inside +// the parent's runtime. Only runtimes that build subagents from the shared ADK +// configuration can do that. The subagent also has no way to reach a person: its +// conversation ends with the tool call, so a tool that waits for human approval +// would hang the call. +func validateSubAgentIsolation(runtime HarnessType, name string, isolation v1alpha3.SubAgentIsolation, child *AgentInput) error { + if isolation != v1alpha3.SubAgentIsolationIsolated { + return nil + } + if runtime != HarnessTypeKagent && runtime != HarnessTypeBYO { + return NewValidationError("subagent %q is Isolated, which Harness runtime %q does not support", name, runtime) + } + for _, tool := range child.MCPTools { + if tool.Binding.RequireApproval { + return NewValidationError("subagent %q is Isolated and cannot pause for human approval, but its binding to %s %q requires it", + name, tool.Binding.Server.Kind, tool.Binding.Server.Name) + } + } + return nil +} diff --git a/go/core/internal/translator/subagent_isolation_test.go b/go/core/internal/translator/subagent_isolation_test.go new file mode 100644 index 0000000000..788c063257 --- /dev/null +++ b/go/core/internal/translator/subagent_isolation_test.go @@ -0,0 +1,168 @@ +package translator_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "istio.io/istio/pkg/kube/krt" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + runtimeconfig "github.com/kagent-dev/kagent/go/adk/pkg/config" + configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" + "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/translator" + byotranslator "github.com/kagent-dev/kagent/go/core/internal/translator/byo" + kagenttranslator "github.com/kagent-dev/kagent/go/core/internal/translator/kagent" +) + +func isolationHarness(kind string) *v1alpha3.Harness { + harness := &v1alpha3.Harness{ + ObjectMeta: metav1.ObjectMeta{Name: kind, Namespace: "test"}, + Spec: v1alpha3.HarnessSpec{ + Workload: v1alpha3.HarnessWorkload{Image: new("example.com/runtime@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")}, + Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}, + }, + } + switch kind { + case "kagent": + harness.Spec.Kagent = &v1alpha3.KagentHarness{} + case "byo": + harness.Spec.BYO = &v1alpha3.BYOHarness{} + harness.Spec.Workload.Command = []string{"/app"} + case "codex": + harness.Spec.Codex = &v1alpha3.CodexHarness{} + case "claude": + harness.Spec.Claude = &v1alpha3.ClaudeHarness{} + } + return harness +} + +// isolationTemplates returns a root with two subagent bindings: reviewer, whose +// isolation the test sets, and a Shared drafter. The reviewer's MCP binding can +// require approval. +func isolationTemplates(isolation v1alpha3.SubAgentIsolation, requireApproval bool) (root, reviewer, drafter *v1alpha3.AgentTemplate) { + model := &v1alpha3.ObjectReference{Name: "default-model"} + reviewer = &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "reviewer", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: model, SystemPrompt: "review", Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ + Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: "search"}, RequireApproval: requireApproval, + }}}}, + } + drafter = &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "drafter", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: model, SystemPrompt: "draft"}, + } + root = &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "coordinator", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: model, SystemPrompt: "coordinate", Tools: []v1alpha3.ToolBinding{ + {SubAgent: &v1alpha3.SubAgentToolBinding{Name: "reviewer", Description: "reviews", TemplateRef: &corev1.LocalObjectReference{Name: "reviewer"}, Isolation: isolation}}, + {SubAgent: &v1alpha3.SubAgentToolBinding{Name: "drafter", Description: "drafts", TemplateRef: &corev1.LocalObjectReference{Name: "drafter"}}}, + }}, + } + return root, reviewer, drafter +} + +func TestCompileAgentDeliversSubAgentIsolation(t *testing.T) { + for _, kind := range []string{"kagent", "byo"} { + t.Run(kind, func(t *testing.T) { + harness := isolationHarness(kind) + compile := func(isolation v1alpha3.SubAgentIsolation) (*translator.CompileResult, *configv1.ADKConfig) { + root, reviewer, drafter := isolationTemplates(isolation, false) + result, err := compiler(t, modelConfig(), reviewer, drafter, harness, remoteMCPServer("search", "https://search.example.com/mcp")). + CompileAgent(t.Context(), inlineAgent(harness, root)) + require.NoError(t, err) + var config configv1.ADKConfig + require.NoError(t, runtimeconfig.Unmarshal(result.ConfigJSON, &config)) + return result, &config + } + + isolated, isolatedConfig := compile(v1alpha3.SubAgentIsolationIsolated) + require.Len(t, isolatedConfig.Agent.SubAgents, 2) + require.Equal(t, configv1.ADKAgentIsolation_ADK_AGENT_ISOLATION_SESSION, isolatedConfig.Agent.SubAgents[0].Isolation) + require.Equal(t, configv1.ADKAgentIsolation_ADK_AGENT_ISOLATION_UNSPECIFIED, isolatedConfig.Agent.SubAgents[1].Isolation, "a binding that does not ask stays shared") + require.Equal(t, configv1.ADKAgentIsolation_ADK_AGENT_ISOLATION_UNSPECIFIED, isolatedConfig.Agent.Isolation, "the root has no parent to isolate from") + + // Shared, stated or omitted, produces the same runtime revision as before the field existed. + omitted, omittedConfig := compile("") + shared, _ := compile(v1alpha3.SubAgentIsolationShared) + require.Equal(t, configv1.ADKAgentIsolation_ADK_AGENT_ISOLATION_UNSPECIFIED, omittedConfig.Agent.SubAgents[0].Isolation) + omittedDigest, err := omitted.Digest() + require.NoError(t, err) + sharedDigest, err := shared.Digest() + require.NoError(t, err) + isolatedDigest, err := isolated.Digest() + require.NoError(t, err) + require.Equal(t, omittedDigest, sharedDigest, "Shared is the default") + require.NotEqual(t, omittedDigest, isolatedDigest, "isolation changes how the runtime behaves, so it prepares a new revision") + }) + } +} + +func TestCompileAgentRejectsIsolatedSubAgentOnUnsupportedHarnesses(t *testing.T) { + for _, kind := range []string{"codex", "claude"} { + t.Run(kind, func(t *testing.T) { + harness := isolationHarness(kind) + root, reviewer, drafter := isolationTemplates(v1alpha3.SubAgentIsolationIsolated, false) + _, err := compiler(t, modelConfig(), reviewer, drafter, harness, remoteMCPServer("search", "https://search.example.com/mcp")). + CompileAgent(t.Context(), inlineAgent(harness, root)) + var validation *translator.ValidationError + require.ErrorAs(t, err, &validation) + require.ErrorContains(t, err, `subagent "reviewer" is Isolated, which Harness runtime "`+kind+`" does not support`) + }) + } +} + +func TestCompileAgentRejectsIsolatedSubAgentThatNeedsApproval(t *testing.T) { + harness := isolationHarness("kagent") + servers := remoteMCPServer("search", "https://search.example.com/mcp") + + root, reviewer, drafter := isolationTemplates(v1alpha3.SubAgentIsolationIsolated, true) + _, err := compiler(t, modelConfig(), reviewer, drafter, harness, servers).CompileAgent(t.Context(), inlineAgent(harness, root)) + var validation *translator.ValidationError + require.ErrorAs(t, err, &validation) + require.ErrorContains(t, err, `subagent "reviewer" is Isolated and cannot pause for human approval, but its binding to RemoteMCPServer "search" requires it`) + + // The same subagent is fine when it shares the parent's session: the + // approval request can reach the person through the parent. + root, reviewer, drafter = isolationTemplates(v1alpha3.SubAgentIsolationShared, true) + _, err = compiler(t, modelConfig(), reviewer, drafter, harness, servers).CompileAgent(t.Context(), inlineAgent(harness, root)) + require.NoError(t, err) + + // An Isolated subagent without the approval requirement is fine. + root, reviewer, drafter = isolationTemplates(v1alpha3.SubAgentIsolationIsolated, false) + _, err = compiler(t, modelConfig(), reviewer, drafter, harness, servers).CompileAgent(t.Context(), inlineAgent(harness, root)) + require.NoError(t, err) +} + +func TestCompileAgentRejectsIsolatedSubAgentWithSTSExchange(t *testing.T) { + harness := isolationHarness("kagent") + root, reviewer, drafter := isolationTemplates(v1alpha3.SubAgentIsolationIsolated, false) + + sts := testRuntimeDefaults() + sts.TokenPropagation = &configv1.TokenPropagationConfig{Enabled: true, StsWellKnownUri: new("https://sts.example.com/.well-known/oauth-authorization-server")} + _, err := compilerWithSettings(t, sts, modelConfig(), reviewer, drafter, harness, remoteMCPServer("search", "https://search.example.com/mcp")). + CompileAgent(t.Context(), inlineAgent(harness, root)) + var validation *translator.ValidationError + require.ErrorAs(t, err, &validation) + require.ErrorContains(t, err, `subagent "reviewer" is Isolated, which cannot be combined with STS token exchange`) + + // Forwarding the caller's token without an exchange works in a new session: + // the credential travels with the request, not with the session. + propagate := testRuntimeDefaults() + propagate.TokenPropagation = &configv1.TokenPropagationConfig{Enabled: true} + _, err = compilerWithSettings(t, propagate, modelConfig(), reviewer, drafter, harness, remoteMCPServer("search", "https://search.example.com/mcp")). + CompileAgent(t.Context(), inlineAgent(harness, root)) + require.NoError(t, err) +} + +// compilerWithSettings is compiler with the given runtime defaults. +func compilerWithSettings(t *testing.T, settings *configv1.RuntimeDefaultsConfig, objects ...any) *translator.Compiler { + t.Helper() + collections := mockCollections(t, append(objects, defaultWorkerPool())...) + ctx := krt.TestingDummyContext{} + return translator.NewCompiler(ctx, collections, map[translator.HarnessType]translator.HarnessCompiler{ + translator.HarnessTypeKagent: kagenttranslator.NewCompiler(ctx, collections, settings), + translator.HarnessTypeBYO: byotranslator.NewCompiler(ctx, collections, settings), + }, testHarnessImages, settings) +} diff --git a/go/core/internal/translator/workflow.go b/go/core/internal/translator/workflow.go new file mode 100644 index 0000000000..697450caa1 --- /dev/null +++ b/go/core/internal/translator/workflow.go @@ -0,0 +1,125 @@ +package translator + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + + "github.com/kagent-dev/kagent/go/adk/pkg/declarative" + "github.com/kagent-dev/kagent/go/api/v1alpha3" +) + +// maxWorkflowBytes bounds a declarative workflow document, before and after +// canonicalization. The runtime config carries it in a bounded protobuf field. +const maxWorkflowBytes = 64 << 10 + +// ResolvedWorkflow is a declarative workflow document that passed structural +// validation, in the canonical form delivered to the runtime. Source is the +// ConfigMap key it was read from; Prompts are the ConfigMap keys whose text +// was inlined for instruction_from and global_instruction_from. +type ResolvedWorkflow struct { + Document []byte + SHA256 string + Source *ResolvedConfigMapKeyReference + Prompts []*ResolvedConfigMapKeyReference +} + +// ConfigMapNames returns the ConfigMaps the workflow was compiled from. +func (w *ResolvedWorkflow) ConfigMapNames() []string { + if w == nil { + return nil + } + var names []string + if w.Source != nil { + names = append(names, w.Source.NamespacedName.Name) + } + for _, p := range w.Prompts { + names = append(names, p.NamespacedName.Name) + } + return names +} + +// promptReferenceError is a prompt ConfigMap lookup failure. The ConfigMap +// may appear later, so it is retried rather than reported as incompatible. +type promptReferenceError struct{ err error } + +func (e *promptReferenceError) Error() string { return e.err.Error() } +func (e *promptReferenceError) Unwrap() error { return e.err } + +// resolveWorkflow reads and validates the template's workflow document. It +// returns nil when the template declares none. +func (c *Compiler) resolveWorkflow(_ context.Context, template *TemplateConfiguration) (*ResolvedWorkflow, error) { + ref := template.Spec.WorkflowFrom + if ref == nil { + return nil, nil + } + raw, source, err := c.resolveConfigMapKey(template.Namespace, "workflow", ref) + if err != nil { + return nil, err + } + var prompts []*ResolvedConfigMapKeyReference + resolve := func(_ string, src declarative.TextSource) (string, *declarative.TextSource, error) { + if src.ConfigMap == nil { + return "", nil, unsupportedPromptSource(src) + } + text, prompt, err := c.resolveConfigMapKey(template.Namespace, "workflow prompt", &v1alpha3.ConfigMapKeyReference{Name: src.ConfigMap.Name, Key: src.ConfigMap.Key}) + if err != nil { + return "", nil, &promptReferenceError{err: err} + } + prompts = append(prompts, prompt) + return string(text), nil, nil + } + canonical, err := validateWorkflow(raw, resolve) + if err != nil { + if _, ok := errors.AsType[*promptReferenceError](err); ok { + return nil, fmt.Errorf("workflow: %w", err) + } + return nil, NewValidationError("invalid workflow: %v", err) + } + digest := sha256.Sum256(canonical) + return &ResolvedWorkflow{Document: canonical, SHA256: hex.EncodeToString(digest[:]), Source: source, Prompts: prompts}, nil +} + +// unsupportedPromptSource explains why the controller does not deliver a +// Secret or environment prompt source. Secrets never enter a runtime: kagent +// injects credentials only at the egress gateway, so prompt text that must be +// visible to the runtime belongs in a ConfigMap. Env sources are reserved for +// what the runtime image itself sets and are not resolvable here. +func unsupportedPromptSource(src declarative.TextSource) error { + if src.Secret != nil { + return fmt.Errorf("secret %q: prompts cannot be read from Secrets because kagent never delivers Secret values to a runtime; use a ConfigMap", src.Secret.Name) + } + return fmt.Errorf("env %q: environment prompt sources are not supported in a delivered workflow; use a ConfigMap", src.Env) +} + +// validateWorkflow checks a document with the same structural rules the Go ADK +// applies before building it, resolves its prompt sources with resolve, and +// returns its canonical JSON. Names that only +// the runtime image can resolve (functions, tools, models) are not checked +// here; the runtime reports them at startup. +func validateWorkflow(raw []byte, resolve declarative.PromptResolver) ([]byte, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("document is empty") + } + if len(raw) > maxWorkflowBytes { + return nil, fmt.Errorf("document exceeds %d bytes", maxWorkflowBytes) + } + spec, err := declarative.Parse(raw) + if err != nil { + return nil, err + } + if err := declarative.ResolvePrompts(spec, resolve); err != nil { + return nil, err + } + canonical, err := json.Marshal(spec) + if err != nil { + return nil, fmt.Errorf("canonicalize document: %w", err) + } + if len(canonical) > maxWorkflowBytes { + return nil, fmt.Errorf("canonical document exceeds %d bytes", maxWorkflowBytes) + } + return canonical, nil +} diff --git a/go/core/internal/translator/workflow_compile_test.go b/go/core/internal/translator/workflow_compile_test.go new file mode 100644 index 0000000000..f0e1071950 --- /dev/null +++ b/go/core/internal/translator/workflow_compile_test.go @@ -0,0 +1,243 @@ +package translator_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + runtimeconfig "github.com/kagent-dev/kagent/go/adk/pkg/config" + configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" + "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/translator" +) + +const workflowYAML = ` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: support + agent_class: Workflow + entry: triage + nodes: + - { id: triage, type: function, function: triage } +` + +func workflowConfigMap(document string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "workflows", Namespace: "test", UID: "workflows-uid"}, + Data: map[string]string{"agents.yaml": document}, + } +} + +func byoHarness() *v1alpha3.Harness { + return &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Name: "byo", Namespace: "test"}, Spec: v1alpha3.HarnessSpec{ + BYO: &v1alpha3.BYOHarness{}, + Workload: v1alpha3.HarnessWorkload{ + Image: new("example.com/agent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + Command: []string{"/agent"}, + }, + Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}}, + }} +} + +func workflowTemplate(mutate func(*v1alpha3.AgentTemplateSpec)) *v1alpha3.AgentTemplate { + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "workflow", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ + WorkflowFrom: &v1alpha3.ConfigMapKeyReference{Name: "workflows", Key: "agents.yaml"}, + }, + } + if mutate != nil { + mutate(&template.Spec) + } + return template +} + +func TestCompileAgentDeliversWorkflowToBYO(t *testing.T) { + harness := byoHarness() + configMap := workflowConfigMap(workflowYAML) + template := workflowTemplate(nil) + + revision, err := compiler(t, configMap, harness).CompileAgent(t.Context(), inlineAgent(harness, template)) + require.NoError(t, err) + + var config configv1.ADKConfig + require.NoError(t, runtimeconfig.Unmarshal(revision.ConfigJSON, &config)) + workflow := config.Agent.Workflow + require.NotNil(t, workflow) + require.JSONEq(t, `{"apiVersion":"adk.declarative/v0.2","kind":"AgentSystem","agents":[{"name":"support","agent_class":"Workflow","entry":"triage","nodes":[{"id":"triage","type":"function","function":"triage"}]}]}`, + string(workflow.Document)) + require.Len(t, workflow.Sha256, 64) + require.Contains(t, string(revision.Provenance), `"kind":"ConfigMap"`, "the source ConfigMap is recorded") + + original, err := revision.Digest() + require.NoError(t, err) + configMap.Data["agents.yaml"] = workflowYAML + " - { id: extra, type: function, function: extra }\n edges: [{ from: triage, to: extra }]\n" + changed, err := compiler(t, configMap, harness).CompileAgent(t.Context(), inlineAgent(harness, template)) + require.NoError(t, err) + updated, err := changed.Digest() + require.NoError(t, err) + require.NotEqual(t, original, updated, "a changed document must prepare a new revision") +} + +func TestCompileAgentWithoutWorkflowSendsNone(t *testing.T) { + harness := byoHarness() + revision, err := compiler(t, harness).CompileAgent(t.Context(), inlineAgent(harness, workflowTemplate(func(s *v1alpha3.AgentTemplateSpec) { + s.WorkflowFrom = nil + s.SystemPrompt = "be helpful" + }))) + require.NoError(t, err) + var config configv1.ADKConfig + require.NoError(t, runtimeconfig.Unmarshal(revision.ConfigJSON, &config)) + require.Nil(t, config.Agent.Workflow) +} + +func TestCompileAgentRejectsInvalidWorkflow(t *testing.T) { + harness := byoHarness() + const invalid = ` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: support + agent_class: Workflow + nodes: + - { id: triage, type: function } + edges: + - { from: START, to: triage } + - { from: triage, to: ghost } +` + _, err := compiler(t, workflowConfigMap(invalid), harness).CompileAgent(t.Context(), inlineAgent(harness, workflowTemplate(nil))) + var validation *translator.ValidationError + require.ErrorAs(t, err, &validation, "an invalid document is a compatibility problem, not a retryable lookup failure") + require.ErrorContains(t, err, "invalid workflow") + require.ErrorContains(t, err, "agents[0].nodes[0].function: required for node type function") + require.ErrorContains(t, err, `agents[0].edges[1].to: unknown node "ghost"`) +} + +func TestCompileAgentWorkflowSourceFailuresAreNotValidationErrors(t *testing.T) { + harness := byoHarness() + tests := []struct { + name string + objects []any + wantErr string + }{ + {name: "missing ConfigMap", objects: []any{harness}, wantErr: `resolve workflow ConfigMap "workflows": not found`}, + {name: "missing key", objects: []any{harness, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "workflows", Namespace: "test"}}}, wantErr: `resolve workflow ConfigMap "workflows": key "agents.yaml" not found`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := compiler(t, tt.objects...).CompileAgent(t.Context(), inlineAgent(harness, workflowTemplate(nil))) + require.ErrorContains(t, err, tt.wantErr) + var validation *translator.ValidationError + require.False(t, errors.As(err, &validation), "a ConfigMap that may appear later must be retried") + }) + } +} + +func TestCompileAgentRejectsWorkflowOnOtherHarnesses(t *testing.T) { + configMap := workflowConfigMap(workflowYAML) + for name, spec := range map[string]v1alpha3.HarnessSpec{ + "kagent": {Kagent: &v1alpha3.KagentHarness{}}, + "codex": {Codex: &v1alpha3.CodexHarness{}}, + "claude": {Claude: &v1alpha3.ClaudeHarness{}}, + } { + t.Run(name, func(t *testing.T) { + spec.Substrate = v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}} + harness := &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "test"}, Spec: spec} + // No ModelConfig: managed harnesses would also reject that, and the + // workflow gate must be the reported reason. + _, err := compiler(t, configMap, harness).CompileAgent(t.Context(), inlineAgent(harness, workflowTemplate(nil))) + var validation *translator.ValidationError + require.ErrorAs(t, err, &validation) + require.ErrorContains(t, err, "does not run declarative workflows; use a BYO Harness") + }) + } +} + +func TestCompileAgentRejectsWorkflowOnSharedTemplates(t *testing.T) { + harness := byoHarness() + child := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "child", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ + WorkflowFrom: &v1alpha3.ConfigMapKeyReference{Name: "workflows", Key: "agents.yaml"}, + }, + } + root := workflowTemplate(func(s *v1alpha3.AgentTemplateSpec) { + s.WorkflowFrom = nil + s.Tools = []v1alpha3.ToolBinding{{SubAgent: &v1alpha3.SubAgentToolBinding{ + Name: "child", Description: "delegate", TemplateRef: &corev1.LocalObjectReference{Name: child.Name}, + }}} + }) + _, err := compiler(t, workflowConfigMap(workflowYAML), child, harness).CompileAgent(t.Context(), inlineAgent(harness, root)) + var validation *translator.ValidationError + require.ErrorAs(t, err, &validation) + require.ErrorContains(t, err, `AgentTemplate "child" sets workflowFrom, which is only supported on the root template`) +} + +const promptWorkflowYAML = ` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: support + agent_class: LlmAgent + model: stub + instruction_from: { %s: { name: prompts, key: support.md } } +` + +func TestCompileAgentInlinesWorkflowPromptsFromConfigMaps(t *testing.T) { + harness := byoHarness() + prompts := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "prompts", Namespace: "test", UID: "prompts-uid"}, + Data: map[string]string{"support.md": "Answer support tickets."}, + } + document := fmt.Sprintf(promptWorkflowYAML, "config_map") + revision, err := compiler(t, workflowConfigMap(document), prompts, harness).CompileAgent(t.Context(), inlineAgent(harness, workflowTemplate(nil))) + require.NoError(t, err) + + var config configv1.ADKConfig + require.NoError(t, runtimeconfig.Unmarshal(revision.ConfigJSON, &config)) + require.Contains(t, string(config.Agent.Workflow.Document), `"instruction":"Answer support tickets."`) + require.NotContains(t, string(config.Agent.Workflow.Document), "instruction_from") + require.Contains(t, string(revision.Provenance), "prompts-uid", "the prompt ConfigMap is recorded") + + _, err = compiler(t, workflowConfigMap(document), harness).CompileAgent(t.Context(), inlineAgent(harness, workflowTemplate(nil))) + require.ErrorContains(t, err, `resolve workflow prompt ConfigMap "prompts": not found`) + var validation *translator.ValidationError + require.False(t, errors.As(err, &validation), "a prompt ConfigMap that may appear later must be retried") +} + +func TestCompileAgentRejectsMCPConfigMapSources(t *testing.T) { + harness := byoHarness() + document := ` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +mcp_servers: + - name: crm + url: http://crm + headers: + - name: Authorization + value_from: { config_map: { name: credentials, key: token } } +agents: + - name: support + agent_class: LlmAgent + model: stub + instruction: hi +` + _, err := compiler(t, workflowConfigMap(document), harness).CompileAgent(t.Context(), inlineAgent(harness, workflowTemplate(nil))) + var validation *translator.ValidationError + require.ErrorAs(t, err, &validation) + require.ErrorContains(t, err, "mcp_servers[0].headers[0].value_from: must use env") +} + +func TestCompileAgentRejectsWorkflowPromptsFromSecrets(t *testing.T) { + harness := byoHarness() + _, err := compiler(t, workflowConfigMap(fmt.Sprintf(promptWorkflowYAML, "secret")), harness).CompileAgent(t.Context(), inlineAgent(harness, workflowTemplate(nil))) + var validation *translator.ValidationError + require.ErrorAs(t, err, &validation) + require.ErrorContains(t, err, "agents[0].instruction_from") + require.ErrorContains(t, err, "use a ConfigMap") +} diff --git a/go/core/internal/translator/workflow_test.go b/go/core/internal/translator/workflow_test.go new file mode 100644 index 0000000000..1eb3aed2d1 --- /dev/null +++ b/go/core/internal/translator/workflow_test.go @@ -0,0 +1,100 @@ +package translator + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const workflowDocument = ` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: root + agent_class: Workflow + entry: only + nodes: + - { id: only, type: function, function: handle } +` + +func TestValidateWorkflow(t *testing.T) { + tests := []struct { + name string + raw string + wantErr []string + }{ + {name: "yaml document", raw: workflowDocument}, + { + name: "json document", + raw: `{"apiVersion":"adk.declarative/v0.2","kind":"AgentSystem","agents":[{"name":"root","agent_class":"Workflow","entry":"only","nodes":[{"id":"only","type":"function","function":"handle"}]}]}`, + }, + {name: "empty", raw: "", wantErr: []string{"document is empty"}}, + {name: "not a document", raw: "- just\n- a list", wantErr: []string{"failed to decode AgentSystem document"}}, + { + name: "unknown field", + raw: strings.Replace(workflowDocument, "entry: only", "entry: only\n colour: red", 1), + wantErr: []string{"failed to decode AgentSystem document", `"colour"`}, + }, + { + name: "all issues are reported with field paths", + raw: ` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: root + agent_class: Workflow + nodes: + - { id: a, type: function } + - { id: b, type: tool, tool: lookup } + edges: + - { from: START, to: a } + - { from: a, to: ghost } + - { from: a, to: b } +`, + wantErr: []string{ + "agents[0].nodes[0].function: required for node type function", + `agents[0].edges[1].to: unknown node "ghost"`, + "agents[0].nodes[1]: terminal tool node emits structured output", + }, + }, + {name: "too large", raw: workflowDocument + "# " + strings.Repeat("x", maxWorkflowBytes), wantErr: []string{"document exceeds"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + canonical, err := validateWorkflow([]byte(tt.raw), nil) + if len(tt.wantErr) > 0 { + require.Error(t, err) + for _, want := range tt.wantErr { + require.ErrorContains(t, err, want) + } + return + } + require.NoError(t, err) + require.JSONEq(t, `{"apiVersion":"adk.declarative/v0.2","kind":"AgentSystem","agents":[{"name":"root","agent_class":"Workflow","nodes":[{"id":"only","type":"function","function":"handle"}],"entry":"only"}]}`, string(canonical)) + }) + } +} + +func TestValidateWorkflowCanonicalFormIsStable(t *testing.T) { + yamlForm, err := validateWorkflow([]byte(workflowDocument), nil) + require.NoError(t, err) + // The canonical form is itself a valid document with the same canonical form. + again, err := validateWorkflow(yamlForm, nil) + require.NoError(t, err) + require.Equal(t, string(yamlForm), string(again)) +} + +func TestValidateWorkflowBoundsCanonicalSize(t *testing.T) { + // JSON encoding escapes "<" as \u003c, so the canonical form is larger than + // the input. The proto field that carries it has the same bound. + raw := ` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - { name: a, agent_class: LlmAgent, model: m, instruction: "` + strings.Repeat("<", 20000) + `" } +` + require.Less(t, len(raw), maxWorkflowBytes) + _, err := validateWorkflow([]byte(raw), nil) + require.ErrorContains(t, err, "canonical document exceeds") +} diff --git a/go/core/test/e2e/subagent_isolation_test.go b/go/core/test/e2e/subagent_isolation_test.go new file mode 100644 index 0000000000..a1feda28e7 --- /dev/null +++ b/go/core/test/e2e/subagent_isolation_test.go @@ -0,0 +1,92 @@ +package e2e_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" +) + +// TestSubAgentIsolationValidation covers the controller's judgment of an +// Isolated subagent before any runtime exists. An Isolated subagent runs as a +// tool in a conversation of its own, so a runtime that cannot do that, and a +// subagent that needs a person to approve a tool call, are both rejected with +// Compatible=False. Neither case starts an Actor. +func TestSubAgentIsolationValidation(t *testing.T) { + t.Parallel() + interactionTarget(t) // skips without a cluster + for _, tt := range []struct { + name string + harness string + requireApproval bool + want string + }{ + { + name: "runtime without isolated subagents", harness: codexE2EHarness, + want: `subagent "reviewer" is Isolated, which Harness runtime "codex" does not support`, + }, + { + name: "subagent that needs approval", harness: "kagent", requireApproval: true, + want: `subagent "reviewer" is Isolated and cannot pause for human approval`, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + kube := interactionKubeClient(t) + + server := &v1alpha3.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "isolation-mcp-", Namespace: "kagent"}, + Spec: v1alpha3.RemoteMCPServerSpec{ + Description: "Subagent isolation E2E fixture", + Protocol: v1alpha3.RemoteMCPServerProtocolStreamableHttp, + URL: "http://127.0.0.1:1/mcp", + }, + } + require.NoError(t, kube.Create(t.Context(), server)) + deleteOnCleanup(t, kube, server) + + reviewer := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "isolation-reviewer-", Namespace: "kagent"}, + Spec: v1alpha3.AgentTemplateSpec{ + SystemPrompt: "Review the work.", + Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ + Server: corev1.TypedObjectReference{Kind: "RemoteMCPServer", Name: server.Name}, + RequireApproval: tt.requireApproval, + }}}, + }, + } + require.NoError(t, kube.Create(t.Context(), reviewer)) + deleteOnCleanup(t, kube, reviewer) + + root := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "isolation-root-", Namespace: "kagent"}, + Spec: v1alpha3.AgentTemplateSpec{ + SystemPrompt: "Coordinate.", + Tools: []v1alpha3.ToolBinding{{SubAgent: &v1alpha3.SubAgentToolBinding{ + Name: "reviewer", Description: "Reviews work.", Isolation: v1alpha3.SubAgentIsolationIsolated, + TemplateRef: &corev1.LocalObjectReference{Name: reviewer.Name}, + }}}, + }, + } + require.NoError(t, kube.Create(t.Context(), root)) + deleteOnCleanup(t, kube, root) + + agent := &v1alpha3.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: root.Name, Namespace: root.Namespace}, + Spec: v1alpha3.AgentSpec{ + Template: v1alpha3.AgentTemplateSource{Ref: &corev1.LocalObjectReference{Name: root.Name}}, + HarnessRef: corev1.LocalObjectReference{Name: tt.harness}, + }, + } + require.NoError(t, kube.Create(t.Context(), agent)) + deleteOnCleanup(t, kube, agent) + + compatible := waitForIncompatible(t, kube, agent) + require.Contains(t, compatible.Message, tt.want) + require.Empty(t, agent.Status.LatestSuccessfulRevision, "no revision is prepared for a rejected configuration") + }) + } +} diff --git a/go/core/test/e2e/workflow_test.go b/go/core/test/e2e/workflow_test.go new file mode 100644 index 0000000000..47fc19ee48 --- /dev/null +++ b/go/core/test/e2e/workflow_test.go @@ -0,0 +1,134 @@ +package e2e_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" +) + +const ( + validWorkflowDocument = ` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: support + agent_class: Workflow + entry: triage + nodes: + - { id: triage, type: function, function: triage } +` + invalidWorkflowDocument = ` +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: support + agent_class: Workflow + nodes: + - { id: triage, type: function } + edges: + - { from: START, to: triage } + - { from: triage, to: ghost } +` +) + +// TestAgentWorkflowFromValidation covers the controller's judgment of a +// workflow document before any runtime exists: an invalid document and an +// unsupported harness both fail compilation with Compatible=False and name the +// problem. Neither case starts an Actor. +func TestAgentWorkflowFromValidation(t *testing.T) { + t.Parallel() + interactionTarget(t) // skips without a cluster + for _, tt := range []struct { + name string + harness string + document string + want []string + }{ + { + name: "invalid document on a BYO harness", harness: "byo-adk-e2e", document: invalidWorkflowDocument, + want: []string{ + "invalid workflow", + "agents[0].nodes[0].function: required for node type function", + `agents[0].edges[1].to: unknown node "ghost"`, + }, + }, + { + name: "valid document on a managed harness", harness: "kagent", document: validWorkflowDocument, + want: []string{`Harness runtime "kagent" does not run declarative workflows; use a BYO Harness`}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + kube := interactionKubeClient(t) + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "workflow-", Namespace: "kagent"}, + Data: map[string]string{"agents.yaml": tt.document}, + } + require.NoError(t, kube.Create(t.Context(), configMap)) + deleteOnCleanup(t, kube, configMap) + + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "workflow-", Namespace: "kagent"}, + Spec: v1alpha3.AgentTemplateSpec{ + Description: "Workflow validation E2E fixture", + WorkflowFrom: &v1alpha3.ConfigMapKeyReference{Name: configMap.Name, Key: "agents.yaml"}, + }, + } + require.NoError(t, kube.Create(t.Context(), template)) + deleteOnCleanup(t, kube, template) + + agent := &v1alpha3.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: template.Name, Namespace: template.Namespace}, + Spec: v1alpha3.AgentSpec{ + Template: v1alpha3.AgentTemplateSource{Ref: &corev1.LocalObjectReference{Name: template.Name}}, + HarnessRef: corev1.LocalObjectReference{Name: tt.harness}, + }, + } + require.NoError(t, kube.Create(t.Context(), agent)) + deleteOnCleanup(t, kube, agent) + + compatible := waitForIncompatible(t, kube, agent) + for _, want := range tt.want { + require.Contains(t, compatible.Message, want) + } + require.Empty(t, agent.Status.LatestSuccessfulRevision, "no revision is prepared for a rejected document") + }) + } +} + +func deleteOnCleanup(t *testing.T, kube ctrlclient.Client, object ctrlclient.Object) { + t.Helper() + t.Cleanup(func() { + if err := kube.Delete(context.Background(), object); err != nil && !apierrors.IsNotFound(err) { + t.Errorf("delete %T %s/%s: %v", object, object.GetNamespace(), object.GetName(), err) + } + }) +} + +// waitForIncompatible waits for the controller to reject the Agent's current +// generation with Compatible=False for an unsupported configuration, and returns +// that condition. +func waitForIncompatible(t *testing.T, kube ctrlclient.Client, agent *v1alpha3.Agent) *metav1.Condition { + t.Helper() + var compatible *metav1.Condition + err := wait.PollUntilContextTimeout(t.Context(), time.Second, 2*time.Minute, true, func(ctx context.Context) (bool, error) { + if err := kube.Get(ctx, ctrlclient.ObjectKeyFromObject(agent), agent); err != nil { + return false, err + } + compatible = apimeta.FindStatusCondition(agent.Status.Conditions, v1alpha3.AgentConditionCompatible) + return compatible != nil && compatible.ObservedGeneration == agent.Generation && compatible.Status == metav1.ConditionFalse, nil + }) + require.NoError(t, err, "last conditions: %+v", agent.Status.Conditions) + require.Equal(t, "UnsupportedConfiguration", compatible.Reason) + return compatible +} diff --git a/go/go.mod b/go/go.mod index 0830a9a43a..879c2a10b5 100644 --- a/go/go.mod +++ b/go/go.mod @@ -26,6 +26,7 @@ require ( github.com/glebarez/sqlite v1.11.0 github.com/go-logr/logr v1.4.4 github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/cel-go v0.31.0 github.com/google/go-containerregistry v0.22.1 github.com/google/jsonschema-go v0.4.3 // api dependencies @@ -263,7 +264,6 @@ require ( github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/cel-go v0.31.0 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.10 // indirect diff --git a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml index 4ff456d011..2f98e52ac2 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml @@ -462,6 +462,16 @@ spec: route work to this binding. minLength: 1 type: string + isolation: + description: |- + Isolation selects how the parent runs the subagent. Omitting it is Shared. + Isolated gives every call its own conversation, which a parent needs when + it fans out to the same subagent in parallel. Only the kagent and BYO + Harnesses support Isolated. + enum: + - Shared + - Isolated + type: string name: minLength: 1 type: string @@ -496,8 +506,30 @@ spec: 1 maxItems: 50 type: array + workflowFrom: + description: |- + WorkflowFrom references an adk.declarative/v0.2 AgentSystem document, as + YAML or JSON, in a same-namespace ConfigMap key. The controller validates + the document's structure and delivers it to the runtime; the BYO image + resolves the functions, tools, and models it names. Only a root template + on a BYO Harness may set it. + properties: + key: + minLength: 1 + type: string + name: + minLength: 1 + type: string + required: + - key + - name + type: object type: object x-kubernetes-validations: + - message: workflowFrom cannot be combined with systemPrompt or + systemPromptFrom + rule: '!has(self.workflowFrom) || (!has(self.systemPrompt) && + !has(self.systemPromptFrom))' - message: outputSchemaStrict requires outputSchema or outputSchemaFrom rule: '!has(self.outputSchemaStrict) || !self.outputSchemaStrict || has(self.outputSchema) || has(self.outputSchemaFrom)' diff --git a/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml b/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml index 3450e33831..4bd906313d 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml @@ -390,6 +390,16 @@ spec: work to this binding. minLength: 1 type: string + isolation: + description: |- + Isolation selects how the parent runs the subagent. Omitting it is Shared. + Isolated gives every call its own conversation, which a parent needs when + it fans out to the same subagent in parallel. Only the kagent and BYO + Harnesses support Isolated. + enum: + - Shared + - Isolated + type: string name: minLength: 1 type: string @@ -422,8 +432,28 @@ spec: rule: (has(self.mcp)?1:0)+(has(self.subAgent)?1:0) == 1 maxItems: 50 type: array + workflowFrom: + description: |- + WorkflowFrom references an adk.declarative/v0.2 AgentSystem document, as + YAML or JSON, in a same-namespace ConfigMap key. The controller validates + the document's structure and delivers it to the runtime; the BYO image + resolves the functions, tools, and models it names. Only a root template + on a BYO Harness may set it. + properties: + key: + minLength: 1 + type: string + name: + minLength: 1 + type: string + required: + - key + - name + type: object type: object x-kubernetes-validations: + - message: workflowFrom cannot be combined with systemPrompt or systemPromptFrom + rule: '!has(self.workflowFrom) || (!has(self.systemPrompt) && !has(self.systemPromptFrom))' - message: outputSchemaStrict requires outputSchema or outputSchemaFrom rule: '!has(self.outputSchemaStrict) || !self.outputSchemaStrict || has(self.outputSchema) || has(self.outputSchemaFrom)' diff --git a/proto/kagent/config/v1/adk.proto b/proto/kagent/config/v1/adk.proto index 1fec60439a..3cebcc0335 100644 --- a/proto/kagent/config/v1/adk.proto +++ b/proto/kagent/config/v1/adk.proto @@ -35,6 +35,24 @@ message ADKAgentConfig { string skills_directory = 16; repeated ADKAgentConfig sub_agents = 17; ADKOutputConfig output = 18; + // workflow carries a declarative AgentSystem document for BYO images. The + // managed runtimes reject it. + ADKWorkflowConfig workflow = 19; + // isolation applies to a subagent and says how its parent runs it. Unspecified + // is shared. The root agent must leave it unspecified. + ADKAgentIsolation isolation = 20 [(buf.validate.field).enum.defined_only = true]; +} + +// ADKAgentIsolation says how much of the session a subagent shares with its parent. +enum ADKAgentIsolation { + ADK_AGENT_ISOLATION_UNSPECIFIED = 0; + // SHARED hands the conversation to the subagent in the parent's session: the + // parent transfers control and the subagent answers in the same history. + ADK_AGENT_ISOLATION_SHARED = 1; + // SESSION calls the subagent as a tool in a new session seeded with a copy of + // the parent's state. It never sees the parent's history, parallel calls get + // one session each, and it cannot pause for human input. + ADK_AGENT_ISOLATION_SESSION = 2; } // ADKModelConfig shares transport settings across a single selected provider. @@ -355,6 +373,17 @@ message ADKOutputConfig { bytes json_schema = 1; string sha256 = 2; } + +// ADKWorkflowConfig is an adk.declarative AgentSystem document that the +// controller validated. The BYO image owns the Go behavior the document names. +message ADKWorkflowConfig { + // Canonical JSON bytes retain the exact document hashed by the compiler. + bytes document = 1 [(buf.validate.field).bytes = { + min_len: 1 + max_len: 65536 + }]; + string sha256 = 2 [(buf.validate.field).string = {pattern: "^[0-9a-f]{64}$"}]; +} message ADKAgentPlugins { repeated ADKSkill skills = 1; repeated ADKPluginBundle plugins = 2; diff --git a/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.py b/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.py index 8766ffebe0..2c04238812 100644 --- a/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.py +++ b/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.py @@ -27,7 +27,7 @@ from kagent.config.v1 import config_pb2 as kagent_dot_config_dot_v1_dot_config__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1akagent/config/v1/adk.proto\x12\x10kagent.config.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1dkagent/config/v1/config.proto\"\x81\x01\n\tADKConfig\x12<\n\x07runtime\x18\x01 \x01(\x0b\x32\".kagent.config.v1.ADKRuntimeConfigR\x07runtime\x12\x36\n\x05\x61gent\x18\x02 \x01(\x0b\x32 .kagent.config.v1.ADKAgentConfigR\x05\x61gent\"\x8b\x07\n\x0e\x41\x44KAgentConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x36\n\x05model\x18\x02 \x01(\x0b\x32 .kagent.config.v1.ADKModelConfigR\x05model\x12 \n\x0b\x64\x65scription\x18\x03 \x01(\tR\x0b\x64\x65scription\x12 \n\x0binstruction\x18\x04 \x01(\tR\x0binstruction\x12G\n\nhttp_tools\x18\x05 \x03(\x0b\x32(.kagent.config.v1.ADKHTTPMCPServerConfigR\thttpTools\x12\x44\n\tsse_tools\x18\x06 \x03(\x0b\x32\'.kagent.config.v1.ADKSSEMCPServerConfigR\x08sseTools\x12J\n\x0bstdio_tools\x18\x07 \x03(\x0b\x32).kagent.config.v1.ADKStdioMCPServerConfigR\nstdioTools\x12K\n\rremote_agents\x18\x08 \x03(\x0b\x32&.kagent.config.v1.ADKRemoteAgentConfigR\x0cremoteAgents\x12\x1b\n\x06stream\x18\t \x01(\x08H\x00R\x06stream\x88\x01\x01\x12\x39\n\x06memory\x18\n \x01(\x0b\x32!.kagent.config.v1.ADKMemoryConfigR\x06memory\x12\x46\n\ragent_plugins\x18\x0c \x01(\x0b\x32!.kagent.config.v1.ADKAgentPluginsR\x0c\x61gentPlugins\x12I\n\x0e\x63ontext_config\x18\r \x01(\x0b\x32\".kagent.config.v1.ADKContextConfigR\rcontextConfig\x12$\n\x0esession_db_url\x18\x0f \x01(\tR\x0csessionDbUrl\x12)\n\x10skills_directory\x18\x10 \x01(\tR\x0fskillsDirectory\x12?\n\nsub_agents\x18\x11 \x03(\x0b\x32 .kagent.config.v1.ADKAgentConfigR\tsubAgents\x12\x39\n\x06output\x18\x12 \x01(\x0b\x32!.kagent.config.v1.ADKOutputConfigR\x06outputB\t\n\x07_stream\"\xa1\t\n\x0e\x41\x44KModelConfig\x12\x14\n\x05model\x18\x01 \x01(\tR\x05model\x12G\n\x07headers\x18\x02 \x03(\x0b\x32-.kagent.config.v1.ADKModelConfig.HeadersEntryR\x07headers\x12<\n\x18tls_insecure_skip_verify\x18\x03 \x01(\x08H\x01R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x04 \x01(\tH\x02R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x05 \x01(\x08H\x03R\x13tlsDisableSystemCas\x88\x01\x01\x12.\n\x13\x61pi_key_passthrough\x18\x06 \x01(\x08R\x11\x61piKeyPassthrough\x12\x35\n\x06openai\x18\n \x01(\x0b\x32\x1b.kagent.config.v1.ADKOpenAIH\x00R\x06openai\x12\x45\n\x0c\x61zure_openai\x18\x0b \x01(\x0b\x32 .kagent.config.v1.ADKAzureOpenAIH\x00R\x0b\x61zureOpenai\x12>\n\tanthropic\x18\x0c \x01(\x0b\x32\x1e.kagent.config.v1.ADKAnthropicH\x00R\tanthropic\x12O\n\x10gemini_vertex_ai\x18\r \x01(\x0b\x32#.kagent.config.v1.ADKGeminiVertexAIH\x00R\x0egeminiVertexAi\x12Q\n\x10gemini_anthropic\x18\x0e \x01(\x0b\x32$.kagent.config.v1.ADKGeminiAnthropicH\x00R\x0fgeminiAnthropic\x12\x35\n\x06ollama\x18\x0f \x01(\x0b\x32\x1b.kagent.config.v1.ADKOllamaH\x00R\x06ollama\x12\x35\n\x06gemini\x18\x10 \x01(\x0b\x32\x1b.kagent.config.v1.ADKGeminiH\x00R\x06gemini\x12\x38\n\x07\x62\x65\x64rock\x18\x11 \x01(\x0b\x32\x1c.kagent.config.v1.ADKBedrockH\x00R\x07\x62\x65\x64rock\x12@\n\x0bsap_ai_core\x18\x12 \x01(\x0b\x32\x1e.kagent.config.v1.ADKSAPAICoreH\x00R\tsapAiCore\x12\x38\n\x07\x66oundry\x18\x13 \x01(\x0b\x32\x1c.kagent.config.v1.ADKFoundryH\x00R\x07\x66oundry\x12\x38\n\x07mistral\x18\x14 \x01(\x0b\x32\x1c.kagent.config.v1.ADKMistralH\x00R\x07mistral\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x11\n\x08provider\x12\x05\xbaH\x02\x08\x01\x42\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\xb8\x08\n\tADKOpenAI\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12I\n\x11\x66requency_penalty\x18\x02 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\xc0H\x00R\x10\x66requencyPenalty\x88\x01\x01\x12+\n\nmax_tokens\x18\x03 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x01R\tmaxTokens\x88\x01\x01\x12@\n\x15max_completion_tokens\x18\x04 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x02R\x13maxCompletionTokens\x88\x01\x01\x12\x1a\n\x01n\x18\x05 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x03R\x01n\x88\x01\x01\x12G\n\x10presence_penalty\x18\x06 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\xc0H\x04R\x0fpresencePenalty\x88\x01\x01\x12.\n\x10reasoning_effort\x18\x07 \x01(\tH\x05R\x0freasoningEffort\x88\x01\x01\x12\x17\n\x04seed\x18\x08 \x01(\x03H\x06R\x04seed\x88\x01\x01\x12>\n\x0btemperature\x18\t \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x07R\x0btemperature\x88\x01\x01\x12\x45\n\x07timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12\x31\n\x05top_p\x18\x0b \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x08R\x04topP\x88\x01\x01\x12\x42\n\napi_format\x18\x0c \x01(\tB#\xbaH r\x1eR\x00R\x0f\x63hatCompletionsR\tresponsesR\tapiFormat\x12O\n\x0etoken_exchange\x18\r \x01(\x0b\x32(.kagent.config.v1.ADKTokenExchangeConfigR\rtokenExchange\x12\x30\n\x14strict_output_schema\x18\x0e \x01(\x08R\x12strictOutputSchema:\x94\x01\xbaH\x90\x01\x1a\x8d\x01\n\x13openai.token_limits\x12;max_tokens and max_completion_tokens are mutually exclusive\x1a\x39!has(this.max_tokens) || !has(this.max_completion_tokens)B\x14\n\x12_frequency_penaltyB\r\n\x0b_max_tokensB\x18\n\x16_max_completion_tokensB\x04\n\x02_nB\x13\n\x11_presence_penaltyB\x13\n\x11_reasoning_effortB\x07\n\x05_seedB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"\x8b\x01\n\x16\x41\x44KTokenExchangeConfig\x12`\n\x14gdch_service_account\x18\x01 \x01(\x0b\x32,.kagent.config.v1.ADKGDCHTokenExchangeConfigH\x00R\x12gdchServiceAccountB\x0f\n\x06source\x12\x05\xbaH\x02\x08\x01\"|\n\x1a\x41\x44KGDCHTokenExchangeConfig\x12\x39\n\x14service_account_path\x18\x01 \x01(\tB\x07\xbaH\x04r\x02\x10\x01R\x12serviceAccountPath\x12#\n\x08\x61udience\x18\x02 \x01(\tB\x07\xbaH\x04r\x02\x10\x01R\x08\x61udience\"\xac\x03\n\x0e\x41\x44KAzureOpenAI\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\x02 \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\x03 \x01(\tR\napiVersion\x12+\n\nmax_tokens\x18\x04 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x05 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x06 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12\x42\n\napi_format\x18\x07 \x01(\tB#\xbaH r\x1eR\x00R\x0f\x63hatCompletionsR\tresponsesR\tapiFormat\x12\x30\n\x14strict_output_schema\x18\x08 \x01(\x08R\x12strictOutputSchemaB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"\xbb\x03\n\x0c\x41\x44KAnthropic\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12+\n\nmax_tokens\x18\x02 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x03 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x04 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12!\n\x05top_k\x18\x05 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x00H\x03R\x04topK\x88\x01\x01\x12\x45\n\x07timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12%\n\x0eprompt_caching\x18\x07 \x01(\x08R\rpromptCaching\x12,\n\tcache_ttl\x18\x08 \x01(\tB\x0f\xbaH\x0cr\nR\x00R\x02\x35mR\x02\x31hR\x08\x63\x61\x63heTtlB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_pB\x08\n\x06_top_k\"\xb7\x02\n\nADKMistral\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12+\n\nmax_tokens\x18\x02 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x03 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x04 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12\x45\n\x07timeout\x18\x05 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeoutB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"i\n\x11\x41\x44KGeminiVertexAI\x12>\n\x11max_output_tokens\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x0fmaxOutputTokens\x88\x01\x01\x42\x14\n\x12_max_output_tokens\"\x14\n\x12\x41\x44KGeminiAnthropic\"\x8b\x01\n\tADKOllama\x12\x42\n\x07options\x18\x01 \x03(\x0b\x32(.kagent.config.v1.ADKOllama.OptionsEntryR\x07options\x1a:\n\x0cOptionsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"|\n\tADKGemini\x12>\n\x11max_output_tokens\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x0fmaxOutputTokens\x88\x01\x01\x12\x19\n\x08\x62\x61se_url\x18\x02 \x01(\tR\x07\x62\x61seUrlB\x14\n\x12_max_output_tokens\"\xab\x03\n\nADKBedrock\x12\x16\n\x06region\x18\x01 \x01(\tR\x06region\x12\x45\n\x1f\x61\x64\x64itional_model_request_fields\x18\x02 \x01(\x0cR\x1c\x61\x64\x64itionalModelRequestFields\x12%\n\x0eprompt_caching\x18\x03 \x01(\x08R\rpromptCaching\x12,\n\tcache_ttl\x18\x04 \x01(\tB\x0f\xbaH\x0cr\nR\x00R\x02\x35mR\x02\x31hR\x08\x63\x61\x63heTtl\x12\x43\n\tguardrail\x18\x05 \x01(\x0b\x32%.kagent.config.v1.ADKBedrockGuardrailR\tguardrail\x12N\n\x0cread_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0breadTimeout\x12T\n\x0f\x63onnect_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0e\x63onnectTimeout\"e\n\x13\x41\x44KBedrockGuardrail\x12\x1e\n\nidentifier\x18\x01 \x01(\tR\nidentifier\x12\x18\n\x07version\x18\x02 \x01(\tR\x07version\x12\x14\n\x05trace\x18\x03 \x01(\tR\x05trace\"k\n\x0c\x41\x44KSAPAICore\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12%\n\x0eresource_group\x18\x02 \x01(\tR\rresourceGroup\x12\x19\n\x08\x61uth_url\x18\x03 \x01(\tR\x07\x61uthUrl\"\xd6\x01\n\nADKFoundry\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\x02 \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\x03 \x01(\tR\napiVersion\x12\x39\n\napi_format\x18\x04 \x01(\tB\x1a\xbaH\x17r\x15R\x00R\x06openaiR\tanthropicR\tapiFormat\x12\x30\n\x14strict_output_schema\x18\x05 \x01(\x08R\x12strictOutputSchema\"\x94\x05\n\x17\x41\x44KHTTPConnectionParams\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12P\n\x07headers\x18\x02 \x03(\x0b\x32\x36.kagent.config.v1.ADKHTTPConnectionParams.HeadersEntryR\x07headers\x12\x45\n\x07timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12U\n\x10sse_read_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0esseReadTimeout\x12\x31\n\x12terminate_on_close\x18\x05 \x01(\x08H\x00R\x10terminateOnClose\x88\x01\x01\x12<\n\x18tls_insecure_skip_verify\x18\x06 \x01(\x08H\x01R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x07 \x01(\tH\x02R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x08 \x01(\x08H\x03R\x13tlsDisableSystemCas\x88\x01\x01\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x15\n\x13_terminate_on_closeB\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\xc8\x04\n\x16\x41\x44KSSEConnectionParams\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12O\n\x07headers\x18\x02 \x03(\x0b\x32\x35.kagent.config.v1.ADKSSEConnectionParams.HeadersEntryR\x07headers\x12\x45\n\x07timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12U\n\x10sse_read_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0esseReadTimeout\x12<\n\x18tls_insecure_skip_verify\x18\x05 \x01(\x08H\x00R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x06 \x01(\tH\x01R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x07 \x01(\x08H\x02R\x13tlsDisableSystemCas\x88\x01\x01\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\xfa\x01\n\x16\x41\x44KHTTPMCPServerConfig\x12I\n\x06params\x18\x01 \x01(\x0b\x32).kagent.config.v1.ADKHTTPConnectionParamsB\x06\xbaH\x03\xc8\x01\x01R\x06params\x12\x14\n\x05tools\x18\x02 \x03(\tR\x05tools\x12\'\n\x0f\x61llowed_headers\x18\x03 \x03(\tR\x0e\x61llowedHeaders\x12)\n\x10require_approval\x18\x04 \x01(\x08R\x0frequireApproval\x12+\n\x11token_passthrough\x18\x05 \x01(\x08R\x10tokenPassthrough\"\xf8\x01\n\x15\x41\x44KSSEMCPServerConfig\x12H\n\x06params\x18\x01 \x01(\x0b\x32(.kagent.config.v1.ADKSSEConnectionParamsB\x06\xbaH\x03\xc8\x01\x01R\x06params\x12\x14\n\x05tools\x18\x02 \x03(\tR\x05tools\x12\'\n\x0f\x61llowed_headers\x18\x03 \x03(\tR\x0e\x61llowedHeaders\x12)\n\x10require_approval\x18\x04 \x01(\x08R\x0frequireApproval\x12+\n\x11token_passthrough\x18\x05 \x01(\x08R\x10tokenPassthrough\"\xd7\x01\n\x17\x41\x44KStdioMCPServerConfig\x12\x18\n\x07\x63ommand\x18\x01 \x01(\tR\x07\x63ommand\x12\x12\n\x04\x61rgs\x18\x02 \x03(\tR\x04\x61rgs\x12\x44\n\x03\x65nv\x18\x03 \x03(\x0b\x32\x32.kagent.config.v1.ADKStdioMCPServerConfig.EnvEntryR\x03\x65nv\x12\x10\n\x03\x64ir\x18\x04 \x01(\tR\x03\x64ir\x1a\x36\n\x08\x45nvEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xdb\x02\n\x14\x41\x44KRemoteAgentConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n\x03url\x18\x02 \x01(\tR\x03url\x12M\n\x07headers\x18\x03 \x03(\x0b\x32\x33.kagent.config.v1.ADKRemoteAgentConfig.HeadersEntryR\x07headers\x12 \n\x0b\x64\x65scription\x18\x04 \x01(\tR\x0b\x64\x65scription\x12)\n\x10isolate_sessions\x18\x05 \x01(\x08R\x0fisolateSessions\x12\x45\n\x07timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xe1\x03\n\x12\x41\x44KEmbeddingConfig\x12\x1a\n\x08provider\x18\x01 \x01(\tR\x08provider\x12\x14\n\x05model\x18\x02 \x01(\tR\x05model\x12\x19\n\x08\x62\x61se_url\x18\x03 \x01(\tR\x07\x62\x61seUrl\x12.\n\x13\x61pi_key_passthrough\x18\x04 \x01(\x08R\x11\x61piKeyPassthrough\x12<\n\x18tls_insecure_skip_verify\x18\x05 \x01(\x08H\x00R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x06 \x01(\tH\x01R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x07 \x01(\x08H\x02R\x13tlsDisableSystemCas\x88\x01\x01\x12\x1a\n\x08\x65ndpoint\x18\x08 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\t \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\n \x01(\tR\napiVersionB\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"}\n\x0f\x41\x44KMemoryConfig\x12&\n\x08ttl_days\x18\x01 \x01(\x03\x42\x0b\xbaH\x08\"\x06\x18\xff\xc1\x06(\x00R\x07ttlDays\x12\x42\n\tembedding\x18\x02 \x01(\x0b\x32$.kagent.config.v1.ADKEmbeddingConfigR\tembedding\"Z\n\x10\x41\x44KContextConfig\x12\x46\n\ncompaction\x18\x01 \x01(\x0b\x32&.kagent.config.v1.ADKCompressionConfigR\ncompaction\"\xe1\x03\n\x14\x41\x44KCompressionConfig\x12\x43\n\x13\x63ompaction_interval\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x12\x63ompactionInterval\x88\x01\x01\x12\x35\n\x0coverlap_size\x18\x02 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x00H\x01R\x0boverlapSize\x88\x01\x01\x12K\n\x10summarizer_model\x18\x03 \x01(\x0b\x32 .kagent.config.v1.ADKModelConfigR\x0fsummarizerModel\x12\'\n\x0fprompt_template\x18\x04 \x01(\tR\x0epromptTemplate\x12;\n\x0ftoken_threshold\x18\x05 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x02R\x0etokenThreshold\x88\x01\x01\x12\x44\n\x14\x65vent_retention_size\x18\x06 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x00H\x03R\x12\x65ventRetentionSize\x88\x01\x01\x42\x16\n\x14_compaction_intervalB\x0f\n\r_overlap_sizeB\x12\n\x10_token_thresholdB\x17\n\x15_event_retention_size\"J\n\x0f\x41\x44KOutputConfig\x12\x1f\n\x0bjson_schema\x18\x01 \x01(\x0cR\njsonSchema\x12\x16\n\x06sha256\x18\x02 \x01(\tR\x06sha256\"\x82\x01\n\x0f\x41\x44KAgentPlugins\x12\x32\n\x06skills\x18\x01 \x03(\x0b\x32\x1a.kagent.config.v1.ADKSkillR\x06skills\x12;\n\x07plugins\x18\x02 \x03(\x0b\x32!.kagent.config.v1.ADKPluginBundleR\x07plugins\"Y\n\x08\x41\x44KSkill\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x39\n\x06source\x18\x02 \x01(\x0b\x32!.kagent.config.v1.ADKPluginSourceR\x06source\"d\n\x0f\x41\x44KPluginBundle\x12\x39\n\x06source\x18\x01 \x01(\x0b\x32!.kagent.config.v1.ADKPluginSourceR\x06source\x12\x16\n\x06skills\x18\x02 \x03(\tR\x06skills\"\xc7\x01\n\x0f\x41\x44KPluginSource\x12\x38\n\x03git\x18\x02 \x01(\x0b\x32$.kagent.config.v1.ADKPluginGitSourceH\x00R\x03git\x12\x35\n\x02s3\x18\x03 \x01(\x0b\x32#.kagent.config.v1.ADKPluginS3SourceH\x00R\x02s3\x12\x1e\n\tdirectory\x18\x05 \x01(\tH\x00R\tdirectory\x12\x12\n\x04path\x18\x04 \x01(\tR\x04pathB\x0f\n\x06source\x12\x05\xbaH\x02\x08\x01\">\n\x12\x41\x44KPluginGitSource\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12\x16\n\x06\x63ommit\x18\x02 \x01(\tR\x06\x63ommit\"\x90\x01\n\x11\x41\x44KPluginS3Source\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x16\n\x06\x62ucket\x18\x02 \x01(\tR\x06\x62ucket\x12\x10\n\x03key\x18\x03 \x01(\tR\x03key\x12\x1d\n\nversion_id\x18\x04 \x01(\tR\tversionId\x12\x16\n\x06region\x18\x05 \x01(\tR\x06region\"\x83\x07\n\x10\x41\x44KRuntimeConfig\x12J\n\x11\x63ontrol_plane_url\x18\x01 \x01(\tB\x1e\xbaH\x1b\xd8\x01\x01r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01R\x0f\x63ontrolPlaneUrl\x12?\n\x0bgateway_url\x18\x02 \x01(\tB\x1e\xbaH\x1b\xd8\x01\x01r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01R\ngatewayUrl\x12\x12\n\x04name\x18\x03 \x01(\tR\x04name\x12\x1c\n\tnamespace\x18\x04 \x01(\tR\tnamespace\x12>\n\tlog_level\x18\x05 \x01(\tB!\xbaH\x1er\x1cR\x00R\x05\x64\x65\x62ugR\x04infoR\x04warnR\x05\x65rrorR\x08logLevel\x12\x12\n\x04host\x18\x06 \x01(\tR\x04host\x12$\n\x04port\x18\x07 \x01(\rB\x0b\xbaH\x08*\x06\x18\xff\xff\x03 \x00H\x00R\x04port\x88\x01\x01\x12:\n\x12max_content_length\x18\x08 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x00H\x01R\x10maxContentLength\x88\x01\x01\x12U\n\x11token_propagation\x18\t \x01(\x0b\x32(.kagent.config.v1.TokenPropagationConfigR\x10tokenPropagation\x12\xb7\x02\n\ttelemetry\x18\n \x01(\x0b\x32!.kagent.config.v1.TelemetryConfigB\xf5\x01\xbaH\xf1\x01\xba\x01\xed\x01\n\"runtime.telemetry.service_identity\x12\x62runtime telemetry resource_attributes must not override compiled service.name or service.namespace\x1a\x63!(\'service.name\' in this.resource_attributes) && !(\'service.namespace\' in this.resource_attributes)R\ttelemetry\x12I\n\rbuiltin_tools\x18\x0b \x01(\x0b\x32$.kagent.config.v1.BuiltinToolsConfigR\x0c\x62uiltinToolsB\x07\n\x05_portB\x15\n\x13_max_content_lengthBCZAgithub.com/kagent-dev/kagent/go/api/gen/kagent/config/v1;configv1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1akagent/config/v1/adk.proto\x12\x10kagent.config.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1dkagent/config/v1/config.proto\"\x81\x01\n\tADKConfig\x12<\n\x07runtime\x18\x01 \x01(\x0b\x32\".kagent.config.v1.ADKRuntimeConfigR\x07runtime\x12\x36\n\x05\x61gent\x18\x02 \x01(\x0b\x32 .kagent.config.v1.ADKAgentConfigR\x05\x61gent\"\x99\x08\n\x0e\x41\x44KAgentConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x36\n\x05model\x18\x02 \x01(\x0b\x32 .kagent.config.v1.ADKModelConfigR\x05model\x12 \n\x0b\x64\x65scription\x18\x03 \x01(\tR\x0b\x64\x65scription\x12 \n\x0binstruction\x18\x04 \x01(\tR\x0binstruction\x12G\n\nhttp_tools\x18\x05 \x03(\x0b\x32(.kagent.config.v1.ADKHTTPMCPServerConfigR\thttpTools\x12\x44\n\tsse_tools\x18\x06 \x03(\x0b\x32\'.kagent.config.v1.ADKSSEMCPServerConfigR\x08sseTools\x12J\n\x0bstdio_tools\x18\x07 \x03(\x0b\x32).kagent.config.v1.ADKStdioMCPServerConfigR\nstdioTools\x12K\n\rremote_agents\x18\x08 \x03(\x0b\x32&.kagent.config.v1.ADKRemoteAgentConfigR\x0cremoteAgents\x12\x1b\n\x06stream\x18\t \x01(\x08H\x00R\x06stream\x88\x01\x01\x12\x39\n\x06memory\x18\n \x01(\x0b\x32!.kagent.config.v1.ADKMemoryConfigR\x06memory\x12\x46\n\ragent_plugins\x18\x0c \x01(\x0b\x32!.kagent.config.v1.ADKAgentPluginsR\x0c\x61gentPlugins\x12I\n\x0e\x63ontext_config\x18\r \x01(\x0b\x32\".kagent.config.v1.ADKContextConfigR\rcontextConfig\x12$\n\x0esession_db_url\x18\x0f \x01(\tR\x0csessionDbUrl\x12)\n\x10skills_directory\x18\x10 \x01(\tR\x0fskillsDirectory\x12?\n\nsub_agents\x18\x11 \x03(\x0b\x32 .kagent.config.v1.ADKAgentConfigR\tsubAgents\x12\x39\n\x06output\x18\x12 \x01(\x0b\x32!.kagent.config.v1.ADKOutputConfigR\x06output\x12?\n\x08workflow\x18\x13 \x01(\x0b\x32#.kagent.config.v1.ADKWorkflowConfigR\x08workflow\x12K\n\tisolation\x18\x14 \x01(\x0e\x32#.kagent.config.v1.ADKAgentIsolationB\x08\xbaH\x05\x82\x01\x02\x10\x01R\tisolationB\t\n\x07_stream\"\xa1\t\n\x0e\x41\x44KModelConfig\x12\x14\n\x05model\x18\x01 \x01(\tR\x05model\x12G\n\x07headers\x18\x02 \x03(\x0b\x32-.kagent.config.v1.ADKModelConfig.HeadersEntryR\x07headers\x12<\n\x18tls_insecure_skip_verify\x18\x03 \x01(\x08H\x01R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x04 \x01(\tH\x02R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x05 \x01(\x08H\x03R\x13tlsDisableSystemCas\x88\x01\x01\x12.\n\x13\x61pi_key_passthrough\x18\x06 \x01(\x08R\x11\x61piKeyPassthrough\x12\x35\n\x06openai\x18\n \x01(\x0b\x32\x1b.kagent.config.v1.ADKOpenAIH\x00R\x06openai\x12\x45\n\x0c\x61zure_openai\x18\x0b \x01(\x0b\x32 .kagent.config.v1.ADKAzureOpenAIH\x00R\x0b\x61zureOpenai\x12>\n\tanthropic\x18\x0c \x01(\x0b\x32\x1e.kagent.config.v1.ADKAnthropicH\x00R\tanthropic\x12O\n\x10gemini_vertex_ai\x18\r \x01(\x0b\x32#.kagent.config.v1.ADKGeminiVertexAIH\x00R\x0egeminiVertexAi\x12Q\n\x10gemini_anthropic\x18\x0e \x01(\x0b\x32$.kagent.config.v1.ADKGeminiAnthropicH\x00R\x0fgeminiAnthropic\x12\x35\n\x06ollama\x18\x0f \x01(\x0b\x32\x1b.kagent.config.v1.ADKOllamaH\x00R\x06ollama\x12\x35\n\x06gemini\x18\x10 \x01(\x0b\x32\x1b.kagent.config.v1.ADKGeminiH\x00R\x06gemini\x12\x38\n\x07\x62\x65\x64rock\x18\x11 \x01(\x0b\x32\x1c.kagent.config.v1.ADKBedrockH\x00R\x07\x62\x65\x64rock\x12@\n\x0bsap_ai_core\x18\x12 \x01(\x0b\x32\x1e.kagent.config.v1.ADKSAPAICoreH\x00R\tsapAiCore\x12\x38\n\x07\x66oundry\x18\x13 \x01(\x0b\x32\x1c.kagent.config.v1.ADKFoundryH\x00R\x07\x66oundry\x12\x38\n\x07mistral\x18\x14 \x01(\x0b\x32\x1c.kagent.config.v1.ADKMistralH\x00R\x07mistral\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x11\n\x08provider\x12\x05\xbaH\x02\x08\x01\x42\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\xb8\x08\n\tADKOpenAI\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12I\n\x11\x66requency_penalty\x18\x02 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\xc0H\x00R\x10\x66requencyPenalty\x88\x01\x01\x12+\n\nmax_tokens\x18\x03 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x01R\tmaxTokens\x88\x01\x01\x12@\n\x15max_completion_tokens\x18\x04 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x02R\x13maxCompletionTokens\x88\x01\x01\x12\x1a\n\x01n\x18\x05 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x03R\x01n\x88\x01\x01\x12G\n\x10presence_penalty\x18\x06 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\xc0H\x04R\x0fpresencePenalty\x88\x01\x01\x12.\n\x10reasoning_effort\x18\x07 \x01(\tH\x05R\x0freasoningEffort\x88\x01\x01\x12\x17\n\x04seed\x18\x08 \x01(\x03H\x06R\x04seed\x88\x01\x01\x12>\n\x0btemperature\x18\t \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x07R\x0btemperature\x88\x01\x01\x12\x45\n\x07timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12\x31\n\x05top_p\x18\x0b \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x08R\x04topP\x88\x01\x01\x12\x42\n\napi_format\x18\x0c \x01(\tB#\xbaH r\x1eR\x00R\x0f\x63hatCompletionsR\tresponsesR\tapiFormat\x12O\n\x0etoken_exchange\x18\r \x01(\x0b\x32(.kagent.config.v1.ADKTokenExchangeConfigR\rtokenExchange\x12\x30\n\x14strict_output_schema\x18\x0e \x01(\x08R\x12strictOutputSchema:\x94\x01\xbaH\x90\x01\x1a\x8d\x01\n\x13openai.token_limits\x12;max_tokens and max_completion_tokens are mutually exclusive\x1a\x39!has(this.max_tokens) || !has(this.max_completion_tokens)B\x14\n\x12_frequency_penaltyB\r\n\x0b_max_tokensB\x18\n\x16_max_completion_tokensB\x04\n\x02_nB\x13\n\x11_presence_penaltyB\x13\n\x11_reasoning_effortB\x07\n\x05_seedB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"\x8b\x01\n\x16\x41\x44KTokenExchangeConfig\x12`\n\x14gdch_service_account\x18\x01 \x01(\x0b\x32,.kagent.config.v1.ADKGDCHTokenExchangeConfigH\x00R\x12gdchServiceAccountB\x0f\n\x06source\x12\x05\xbaH\x02\x08\x01\"|\n\x1a\x41\x44KGDCHTokenExchangeConfig\x12\x39\n\x14service_account_path\x18\x01 \x01(\tB\x07\xbaH\x04r\x02\x10\x01R\x12serviceAccountPath\x12#\n\x08\x61udience\x18\x02 \x01(\tB\x07\xbaH\x04r\x02\x10\x01R\x08\x61udience\"\xac\x03\n\x0e\x41\x44KAzureOpenAI\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\x02 \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\x03 \x01(\tR\napiVersion\x12+\n\nmax_tokens\x18\x04 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x05 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x06 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12\x42\n\napi_format\x18\x07 \x01(\tB#\xbaH r\x1eR\x00R\x0f\x63hatCompletionsR\tresponsesR\tapiFormat\x12\x30\n\x14strict_output_schema\x18\x08 \x01(\x08R\x12strictOutputSchemaB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"\xbb\x03\n\x0c\x41\x44KAnthropic\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12+\n\nmax_tokens\x18\x02 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x03 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x04 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12!\n\x05top_k\x18\x05 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x00H\x03R\x04topK\x88\x01\x01\x12\x45\n\x07timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12%\n\x0eprompt_caching\x18\x07 \x01(\x08R\rpromptCaching\x12,\n\tcache_ttl\x18\x08 \x01(\tB\x0f\xbaH\x0cr\nR\x00R\x02\x35mR\x02\x31hR\x08\x63\x61\x63heTtlB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_pB\x08\n\x06_top_k\"\xb7\x02\n\nADKMistral\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12+\n\nmax_tokens\x18\x02 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x03 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x04 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12\x45\n\x07timeout\x18\x05 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeoutB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"i\n\x11\x41\x44KGeminiVertexAI\x12>\n\x11max_output_tokens\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x0fmaxOutputTokens\x88\x01\x01\x42\x14\n\x12_max_output_tokens\"\x14\n\x12\x41\x44KGeminiAnthropic\"\x8b\x01\n\tADKOllama\x12\x42\n\x07options\x18\x01 \x03(\x0b\x32(.kagent.config.v1.ADKOllama.OptionsEntryR\x07options\x1a:\n\x0cOptionsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"|\n\tADKGemini\x12>\n\x11max_output_tokens\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x0fmaxOutputTokens\x88\x01\x01\x12\x19\n\x08\x62\x61se_url\x18\x02 \x01(\tR\x07\x62\x61seUrlB\x14\n\x12_max_output_tokens\"\xab\x03\n\nADKBedrock\x12\x16\n\x06region\x18\x01 \x01(\tR\x06region\x12\x45\n\x1f\x61\x64\x64itional_model_request_fields\x18\x02 \x01(\x0cR\x1c\x61\x64\x64itionalModelRequestFields\x12%\n\x0eprompt_caching\x18\x03 \x01(\x08R\rpromptCaching\x12,\n\tcache_ttl\x18\x04 \x01(\tB\x0f\xbaH\x0cr\nR\x00R\x02\x35mR\x02\x31hR\x08\x63\x61\x63heTtl\x12\x43\n\tguardrail\x18\x05 \x01(\x0b\x32%.kagent.config.v1.ADKBedrockGuardrailR\tguardrail\x12N\n\x0cread_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0breadTimeout\x12T\n\x0f\x63onnect_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0e\x63onnectTimeout\"e\n\x13\x41\x44KBedrockGuardrail\x12\x1e\n\nidentifier\x18\x01 \x01(\tR\nidentifier\x12\x18\n\x07version\x18\x02 \x01(\tR\x07version\x12\x14\n\x05trace\x18\x03 \x01(\tR\x05trace\"k\n\x0c\x41\x44KSAPAICore\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12%\n\x0eresource_group\x18\x02 \x01(\tR\rresourceGroup\x12\x19\n\x08\x61uth_url\x18\x03 \x01(\tR\x07\x61uthUrl\"\xd6\x01\n\nADKFoundry\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\x02 \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\x03 \x01(\tR\napiVersion\x12\x39\n\napi_format\x18\x04 \x01(\tB\x1a\xbaH\x17r\x15R\x00R\x06openaiR\tanthropicR\tapiFormat\x12\x30\n\x14strict_output_schema\x18\x05 \x01(\x08R\x12strictOutputSchema\"\x94\x05\n\x17\x41\x44KHTTPConnectionParams\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12P\n\x07headers\x18\x02 \x03(\x0b\x32\x36.kagent.config.v1.ADKHTTPConnectionParams.HeadersEntryR\x07headers\x12\x45\n\x07timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12U\n\x10sse_read_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0esseReadTimeout\x12\x31\n\x12terminate_on_close\x18\x05 \x01(\x08H\x00R\x10terminateOnClose\x88\x01\x01\x12<\n\x18tls_insecure_skip_verify\x18\x06 \x01(\x08H\x01R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x07 \x01(\tH\x02R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x08 \x01(\x08H\x03R\x13tlsDisableSystemCas\x88\x01\x01\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x15\n\x13_terminate_on_closeB\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\xc8\x04\n\x16\x41\x44KSSEConnectionParams\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12O\n\x07headers\x18\x02 \x03(\x0b\x32\x35.kagent.config.v1.ADKSSEConnectionParams.HeadersEntryR\x07headers\x12\x45\n\x07timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12U\n\x10sse_read_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0esseReadTimeout\x12<\n\x18tls_insecure_skip_verify\x18\x05 \x01(\x08H\x00R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x06 \x01(\tH\x01R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x07 \x01(\x08H\x02R\x13tlsDisableSystemCas\x88\x01\x01\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\xfa\x01\n\x16\x41\x44KHTTPMCPServerConfig\x12I\n\x06params\x18\x01 \x01(\x0b\x32).kagent.config.v1.ADKHTTPConnectionParamsB\x06\xbaH\x03\xc8\x01\x01R\x06params\x12\x14\n\x05tools\x18\x02 \x03(\tR\x05tools\x12\'\n\x0f\x61llowed_headers\x18\x03 \x03(\tR\x0e\x61llowedHeaders\x12)\n\x10require_approval\x18\x04 \x01(\x08R\x0frequireApproval\x12+\n\x11token_passthrough\x18\x05 \x01(\x08R\x10tokenPassthrough\"\xf8\x01\n\x15\x41\x44KSSEMCPServerConfig\x12H\n\x06params\x18\x01 \x01(\x0b\x32(.kagent.config.v1.ADKSSEConnectionParamsB\x06\xbaH\x03\xc8\x01\x01R\x06params\x12\x14\n\x05tools\x18\x02 \x03(\tR\x05tools\x12\'\n\x0f\x61llowed_headers\x18\x03 \x03(\tR\x0e\x61llowedHeaders\x12)\n\x10require_approval\x18\x04 \x01(\x08R\x0frequireApproval\x12+\n\x11token_passthrough\x18\x05 \x01(\x08R\x10tokenPassthrough\"\xd7\x01\n\x17\x41\x44KStdioMCPServerConfig\x12\x18\n\x07\x63ommand\x18\x01 \x01(\tR\x07\x63ommand\x12\x12\n\x04\x61rgs\x18\x02 \x03(\tR\x04\x61rgs\x12\x44\n\x03\x65nv\x18\x03 \x03(\x0b\x32\x32.kagent.config.v1.ADKStdioMCPServerConfig.EnvEntryR\x03\x65nv\x12\x10\n\x03\x64ir\x18\x04 \x01(\tR\x03\x64ir\x1a\x36\n\x08\x45nvEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xdb\x02\n\x14\x41\x44KRemoteAgentConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n\x03url\x18\x02 \x01(\tR\x03url\x12M\n\x07headers\x18\x03 \x03(\x0b\x32\x33.kagent.config.v1.ADKRemoteAgentConfig.HeadersEntryR\x07headers\x12 \n\x0b\x64\x65scription\x18\x04 \x01(\tR\x0b\x64\x65scription\x12)\n\x10isolate_sessions\x18\x05 \x01(\x08R\x0fisolateSessions\x12\x45\n\x07timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xe1\x03\n\x12\x41\x44KEmbeddingConfig\x12\x1a\n\x08provider\x18\x01 \x01(\tR\x08provider\x12\x14\n\x05model\x18\x02 \x01(\tR\x05model\x12\x19\n\x08\x62\x61se_url\x18\x03 \x01(\tR\x07\x62\x61seUrl\x12.\n\x13\x61pi_key_passthrough\x18\x04 \x01(\x08R\x11\x61piKeyPassthrough\x12<\n\x18tls_insecure_skip_verify\x18\x05 \x01(\x08H\x00R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x06 \x01(\tH\x01R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x07 \x01(\x08H\x02R\x13tlsDisableSystemCas\x88\x01\x01\x12\x1a\n\x08\x65ndpoint\x18\x08 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\t \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\n \x01(\tR\napiVersionB\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"}\n\x0f\x41\x44KMemoryConfig\x12&\n\x08ttl_days\x18\x01 \x01(\x03\x42\x0b\xbaH\x08\"\x06\x18\xff\xc1\x06(\x00R\x07ttlDays\x12\x42\n\tembedding\x18\x02 \x01(\x0b\x32$.kagent.config.v1.ADKEmbeddingConfigR\tembedding\"Z\n\x10\x41\x44KContextConfig\x12\x46\n\ncompaction\x18\x01 \x01(\x0b\x32&.kagent.config.v1.ADKCompressionConfigR\ncompaction\"\xe1\x03\n\x14\x41\x44KCompressionConfig\x12\x43\n\x13\x63ompaction_interval\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x12\x63ompactionInterval\x88\x01\x01\x12\x35\n\x0coverlap_size\x18\x02 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x00H\x01R\x0boverlapSize\x88\x01\x01\x12K\n\x10summarizer_model\x18\x03 \x01(\x0b\x32 .kagent.config.v1.ADKModelConfigR\x0fsummarizerModel\x12\'\n\x0fprompt_template\x18\x04 \x01(\tR\x0epromptTemplate\x12;\n\x0ftoken_threshold\x18\x05 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x02R\x0etokenThreshold\x88\x01\x01\x12\x44\n\x14\x65vent_retention_size\x18\x06 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x00H\x03R\x12\x65ventRetentionSize\x88\x01\x01\x42\x16\n\x14_compaction_intervalB\x0f\n\r_overlap_sizeB\x12\n\x10_token_thresholdB\x17\n\x15_event_retention_size\"J\n\x0f\x41\x44KOutputConfig\x12\x1f\n\x0bjson_schema\x18\x01 \x01(\x0cR\njsonSchema\x12\x16\n\x06sha256\x18\x02 \x01(\tR\x06sha256\"k\n\x11\x41\x44KWorkflowConfig\x12\'\n\x08\x64ocument\x18\x01 \x01(\x0c\x42\x0b\xbaH\x08z\x06\x10\x01\x18\x80\x80\x04R\x08\x64ocument\x12-\n\x06sha256\x18\x02 \x01(\tB\x15\xbaH\x12r\x10\x32\x0e^[0-9a-f]{64}$R\x06sha256\"\x82\x01\n\x0f\x41\x44KAgentPlugins\x12\x32\n\x06skills\x18\x01 \x03(\x0b\x32\x1a.kagent.config.v1.ADKSkillR\x06skills\x12;\n\x07plugins\x18\x02 \x03(\x0b\x32!.kagent.config.v1.ADKPluginBundleR\x07plugins\"Y\n\x08\x41\x44KSkill\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x39\n\x06source\x18\x02 \x01(\x0b\x32!.kagent.config.v1.ADKPluginSourceR\x06source\"d\n\x0f\x41\x44KPluginBundle\x12\x39\n\x06source\x18\x01 \x01(\x0b\x32!.kagent.config.v1.ADKPluginSourceR\x06source\x12\x16\n\x06skills\x18\x02 \x03(\tR\x06skills\"\xc7\x01\n\x0f\x41\x44KPluginSource\x12\x38\n\x03git\x18\x02 \x01(\x0b\x32$.kagent.config.v1.ADKPluginGitSourceH\x00R\x03git\x12\x35\n\x02s3\x18\x03 \x01(\x0b\x32#.kagent.config.v1.ADKPluginS3SourceH\x00R\x02s3\x12\x1e\n\tdirectory\x18\x05 \x01(\tH\x00R\tdirectory\x12\x12\n\x04path\x18\x04 \x01(\tR\x04pathB\x0f\n\x06source\x12\x05\xbaH\x02\x08\x01\">\n\x12\x41\x44KPluginGitSource\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12\x16\n\x06\x63ommit\x18\x02 \x01(\tR\x06\x63ommit\"\x90\x01\n\x11\x41\x44KPluginS3Source\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x16\n\x06\x62ucket\x18\x02 \x01(\tR\x06\x62ucket\x12\x10\n\x03key\x18\x03 \x01(\tR\x03key\x12\x1d\n\nversion_id\x18\x04 \x01(\tR\tversionId\x12\x16\n\x06region\x18\x05 \x01(\tR\x06region\"\x83\x07\n\x10\x41\x44KRuntimeConfig\x12J\n\x11\x63ontrol_plane_url\x18\x01 \x01(\tB\x1e\xbaH\x1b\xd8\x01\x01r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01R\x0f\x63ontrolPlaneUrl\x12?\n\x0bgateway_url\x18\x02 \x01(\tB\x1e\xbaH\x1b\xd8\x01\x01r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01R\ngatewayUrl\x12\x12\n\x04name\x18\x03 \x01(\tR\x04name\x12\x1c\n\tnamespace\x18\x04 \x01(\tR\tnamespace\x12>\n\tlog_level\x18\x05 \x01(\tB!\xbaH\x1er\x1cR\x00R\x05\x64\x65\x62ugR\x04infoR\x04warnR\x05\x65rrorR\x08logLevel\x12\x12\n\x04host\x18\x06 \x01(\tR\x04host\x12$\n\x04port\x18\x07 \x01(\rB\x0b\xbaH\x08*\x06\x18\xff\xff\x03 \x00H\x00R\x04port\x88\x01\x01\x12:\n\x12max_content_length\x18\x08 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x00H\x01R\x10maxContentLength\x88\x01\x01\x12U\n\x11token_propagation\x18\t \x01(\x0b\x32(.kagent.config.v1.TokenPropagationConfigR\x10tokenPropagation\x12\xb7\x02\n\ttelemetry\x18\n \x01(\x0b\x32!.kagent.config.v1.TelemetryConfigB\xf5\x01\xbaH\xf1\x01\xba\x01\xed\x01\n\"runtime.telemetry.service_identity\x12\x62runtime telemetry resource_attributes must not override compiled service.name or service.namespace\x1a\x63!(\'service.name\' in this.resource_attributes) && !(\'service.namespace\' in this.resource_attributes)R\ttelemetry\x12I\n\rbuiltin_tools\x18\x0b \x01(\x0b\x32$.kagent.config.v1.BuiltinToolsConfigR\x0c\x62uiltinToolsB\x07\n\x05_portB\x15\n\x13_max_content_length*y\n\x11\x41\x44KAgentIsolation\x12#\n\x1f\x41\x44K_AGENT_ISOLATION_UNSPECIFIED\x10\x00\x12\x1e\n\x1a\x41\x44K_AGENT_ISOLATION_SHARED\x10\x01\x12\x1f\n\x1b\x41\x44K_AGENT_ISOLATION_SESSION\x10\x02\x42\x43ZAgithub.com/kagent-dev/kagent/go/api/gen/kagent/config/v1;configv1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -35,6 +35,8 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'ZAgithub.com/kagent-dev/kagent/go/api/gen/kagent/config/v1;configv1' + _globals['_ADKAGENTCONFIG'].fields_by_name['isolation']._loaded_options = None + _globals['_ADKAGENTCONFIG'].fields_by_name['isolation']._serialized_options = b'\272H\005\202\001\002\020\001' _globals['_ADKMODELCONFIG_HEADERSENTRY']._loaded_options = None _globals['_ADKMODELCONFIG_HEADERSENTRY']._serialized_options = b'8\001' _globals['_ADKMODELCONFIG'].oneofs_by_name['provider']._loaded_options = None @@ -139,6 +141,10 @@ _globals['_ADKCOMPRESSIONCONFIG'].fields_by_name['token_threshold']._serialized_options = b'\272H\n\"\010\030\377\377\377\377\007(\001' _globals['_ADKCOMPRESSIONCONFIG'].fields_by_name['event_retention_size']._loaded_options = None _globals['_ADKCOMPRESSIONCONFIG'].fields_by_name['event_retention_size']._serialized_options = b'\272H\n\"\010\030\377\377\377\377\007(\000' + _globals['_ADKWORKFLOWCONFIG'].fields_by_name['document']._loaded_options = None + _globals['_ADKWORKFLOWCONFIG'].fields_by_name['document']._serialized_options = b'\272H\010z\006\020\001\030\200\200\004' + _globals['_ADKWORKFLOWCONFIG'].fields_by_name['sha256']._loaded_options = None + _globals['_ADKWORKFLOWCONFIG'].fields_by_name['sha256']._serialized_options = b'\272H\022r\0202\016^[0-9a-f]{64}$' _globals['_ADKPLUGINSOURCE'].oneofs_by_name['source']._loaded_options = None _globals['_ADKPLUGINSOURCE'].oneofs_by_name['source']._serialized_options = b'\272H\002\010\001' _globals['_ADKRUNTIMECONFIG'].fields_by_name['control_plane_url']._loaded_options = None @@ -153,86 +159,90 @@ _globals['_ADKRUNTIMECONFIG'].fields_by_name['max_content_length']._serialized_options = b'\272H\004\"\002(\000' _globals['_ADKRUNTIMECONFIG'].fields_by_name['telemetry']._loaded_options = None _globals['_ADKRUNTIMECONFIG'].fields_by_name['telemetry']._serialized_options = b'\272H\361\001\272\001\355\001\n\"runtime.telemetry.service_identity\022bruntime telemetry resource_attributes must not override compiled service.name or service.namespace\032c!(\'service.name\' in this.resource_attributes) && !(\'service.namespace\' in this.resource_attributes)' + _globals['_ADKAGENTISOLATION']._serialized_start=11645 + _globals['_ADKAGENTISOLATION']._serialized_end=11766 _globals['_ADKCONFIG']._serialized_start=141 _globals['_ADKCONFIG']._serialized_end=270 _globals['_ADKAGENTCONFIG']._serialized_start=273 - _globals['_ADKAGENTCONFIG']._serialized_end=1180 - _globals['_ADKMODELCONFIG']._serialized_start=1183 - _globals['_ADKMODELCONFIG']._serialized_end=2368 - _globals['_ADKMODELCONFIG_HEADERSENTRY']._serialized_start=2214 - _globals['_ADKMODELCONFIG_HEADERSENTRY']._serialized_end=2272 - _globals['_ADKOPENAI']._serialized_start=2371 - _globals['_ADKOPENAI']._serialized_end=3451 - _globals['_ADKTOKENEXCHANGECONFIG']._serialized_start=3454 - _globals['_ADKTOKENEXCHANGECONFIG']._serialized_end=3593 - _globals['_ADKGDCHTOKENEXCHANGECONFIG']._serialized_start=3595 - _globals['_ADKGDCHTOKENEXCHANGECONFIG']._serialized_end=3719 - _globals['_ADKAZUREOPENAI']._serialized_start=3722 - _globals['_ADKAZUREOPENAI']._serialized_end=4150 - _globals['_ADKANTHROPIC']._serialized_start=4153 - _globals['_ADKANTHROPIC']._serialized_end=4596 - _globals['_ADKMISTRAL']._serialized_start=4599 - _globals['_ADKMISTRAL']._serialized_end=4910 - _globals['_ADKGEMINIVERTEXAI']._serialized_start=4912 - _globals['_ADKGEMINIVERTEXAI']._serialized_end=5017 - _globals['_ADKGEMINIANTHROPIC']._serialized_start=5019 - _globals['_ADKGEMINIANTHROPIC']._serialized_end=5039 - _globals['_ADKOLLAMA']._serialized_start=5042 - _globals['_ADKOLLAMA']._serialized_end=5181 - _globals['_ADKOLLAMA_OPTIONSENTRY']._serialized_start=5123 - _globals['_ADKOLLAMA_OPTIONSENTRY']._serialized_end=5181 - _globals['_ADKGEMINI']._serialized_start=5183 - _globals['_ADKGEMINI']._serialized_end=5307 - _globals['_ADKBEDROCK']._serialized_start=5310 - _globals['_ADKBEDROCK']._serialized_end=5737 - _globals['_ADKBEDROCKGUARDRAIL']._serialized_start=5739 - _globals['_ADKBEDROCKGUARDRAIL']._serialized_end=5840 - _globals['_ADKSAPAICORE']._serialized_start=5842 - _globals['_ADKSAPAICORE']._serialized_end=5949 - _globals['_ADKFOUNDRY']._serialized_start=5952 - _globals['_ADKFOUNDRY']._serialized_end=6166 - _globals['_ADKHTTPCONNECTIONPARAMS']._serialized_start=6169 - _globals['_ADKHTTPCONNECTIONPARAMS']._serialized_end=6829 - _globals['_ADKHTTPCONNECTIONPARAMS_HEADERSENTRY']._serialized_start=2214 - _globals['_ADKHTTPCONNECTIONPARAMS_HEADERSENTRY']._serialized_end=2272 - _globals['_ADKSSECONNECTIONPARAMS']._serialized_start=6832 - _globals['_ADKSSECONNECTIONPARAMS']._serialized_end=7416 - _globals['_ADKSSECONNECTIONPARAMS_HEADERSENTRY']._serialized_start=2214 - _globals['_ADKSSECONNECTIONPARAMS_HEADERSENTRY']._serialized_end=2272 - _globals['_ADKHTTPMCPSERVERCONFIG']._serialized_start=7419 - _globals['_ADKHTTPMCPSERVERCONFIG']._serialized_end=7669 - _globals['_ADKSSEMCPSERVERCONFIG']._serialized_start=7672 - _globals['_ADKSSEMCPSERVERCONFIG']._serialized_end=7920 - _globals['_ADKSTDIOMCPSERVERCONFIG']._serialized_start=7923 - _globals['_ADKSTDIOMCPSERVERCONFIG']._serialized_end=8138 - _globals['_ADKSTDIOMCPSERVERCONFIG_ENVENTRY']._serialized_start=8084 - _globals['_ADKSTDIOMCPSERVERCONFIG_ENVENTRY']._serialized_end=8138 - _globals['_ADKREMOTEAGENTCONFIG']._serialized_start=8141 - _globals['_ADKREMOTEAGENTCONFIG']._serialized_end=8488 - _globals['_ADKREMOTEAGENTCONFIG_HEADERSENTRY']._serialized_start=2214 - _globals['_ADKREMOTEAGENTCONFIG_HEADERSENTRY']._serialized_end=2272 - _globals['_ADKEMBEDDINGCONFIG']._serialized_start=8491 - _globals['_ADKEMBEDDINGCONFIG']._serialized_end=8972 - _globals['_ADKMEMORYCONFIG']._serialized_start=8974 - _globals['_ADKMEMORYCONFIG']._serialized_end=9099 - _globals['_ADKCONTEXTCONFIG']._serialized_start=9101 - _globals['_ADKCONTEXTCONFIG']._serialized_end=9191 - _globals['_ADKCOMPRESSIONCONFIG']._serialized_start=9194 - _globals['_ADKCOMPRESSIONCONFIG']._serialized_end=9675 - _globals['_ADKOUTPUTCONFIG']._serialized_start=9677 - _globals['_ADKOUTPUTCONFIG']._serialized_end=9751 - _globals['_ADKAGENTPLUGINS']._serialized_start=9754 - _globals['_ADKAGENTPLUGINS']._serialized_end=9884 - _globals['_ADKSKILL']._serialized_start=9886 - _globals['_ADKSKILL']._serialized_end=9975 - _globals['_ADKPLUGINBUNDLE']._serialized_start=9977 - _globals['_ADKPLUGINBUNDLE']._serialized_end=10077 - _globals['_ADKPLUGINSOURCE']._serialized_start=10080 - _globals['_ADKPLUGINSOURCE']._serialized_end=10279 - _globals['_ADKPLUGINGITSOURCE']._serialized_start=10281 - _globals['_ADKPLUGINGITSOURCE']._serialized_end=10343 - _globals['_ADKPLUGINS3SOURCE']._serialized_start=10346 - _globals['_ADKPLUGINS3SOURCE']._serialized_end=10490 - _globals['_ADKRUNTIMECONFIG']._serialized_start=10493 - _globals['_ADKRUNTIMECONFIG']._serialized_end=11392 + _globals['_ADKAGENTCONFIG']._serialized_end=1322 + _globals['_ADKMODELCONFIG']._serialized_start=1325 + _globals['_ADKMODELCONFIG']._serialized_end=2510 + _globals['_ADKMODELCONFIG_HEADERSENTRY']._serialized_start=2356 + _globals['_ADKMODELCONFIG_HEADERSENTRY']._serialized_end=2414 + _globals['_ADKOPENAI']._serialized_start=2513 + _globals['_ADKOPENAI']._serialized_end=3593 + _globals['_ADKTOKENEXCHANGECONFIG']._serialized_start=3596 + _globals['_ADKTOKENEXCHANGECONFIG']._serialized_end=3735 + _globals['_ADKGDCHTOKENEXCHANGECONFIG']._serialized_start=3737 + _globals['_ADKGDCHTOKENEXCHANGECONFIG']._serialized_end=3861 + _globals['_ADKAZUREOPENAI']._serialized_start=3864 + _globals['_ADKAZUREOPENAI']._serialized_end=4292 + _globals['_ADKANTHROPIC']._serialized_start=4295 + _globals['_ADKANTHROPIC']._serialized_end=4738 + _globals['_ADKMISTRAL']._serialized_start=4741 + _globals['_ADKMISTRAL']._serialized_end=5052 + _globals['_ADKGEMINIVERTEXAI']._serialized_start=5054 + _globals['_ADKGEMINIVERTEXAI']._serialized_end=5159 + _globals['_ADKGEMINIANTHROPIC']._serialized_start=5161 + _globals['_ADKGEMINIANTHROPIC']._serialized_end=5181 + _globals['_ADKOLLAMA']._serialized_start=5184 + _globals['_ADKOLLAMA']._serialized_end=5323 + _globals['_ADKOLLAMA_OPTIONSENTRY']._serialized_start=5265 + _globals['_ADKOLLAMA_OPTIONSENTRY']._serialized_end=5323 + _globals['_ADKGEMINI']._serialized_start=5325 + _globals['_ADKGEMINI']._serialized_end=5449 + _globals['_ADKBEDROCK']._serialized_start=5452 + _globals['_ADKBEDROCK']._serialized_end=5879 + _globals['_ADKBEDROCKGUARDRAIL']._serialized_start=5881 + _globals['_ADKBEDROCKGUARDRAIL']._serialized_end=5982 + _globals['_ADKSAPAICORE']._serialized_start=5984 + _globals['_ADKSAPAICORE']._serialized_end=6091 + _globals['_ADKFOUNDRY']._serialized_start=6094 + _globals['_ADKFOUNDRY']._serialized_end=6308 + _globals['_ADKHTTPCONNECTIONPARAMS']._serialized_start=6311 + _globals['_ADKHTTPCONNECTIONPARAMS']._serialized_end=6971 + _globals['_ADKHTTPCONNECTIONPARAMS_HEADERSENTRY']._serialized_start=2356 + _globals['_ADKHTTPCONNECTIONPARAMS_HEADERSENTRY']._serialized_end=2414 + _globals['_ADKSSECONNECTIONPARAMS']._serialized_start=6974 + _globals['_ADKSSECONNECTIONPARAMS']._serialized_end=7558 + _globals['_ADKSSECONNECTIONPARAMS_HEADERSENTRY']._serialized_start=2356 + _globals['_ADKSSECONNECTIONPARAMS_HEADERSENTRY']._serialized_end=2414 + _globals['_ADKHTTPMCPSERVERCONFIG']._serialized_start=7561 + _globals['_ADKHTTPMCPSERVERCONFIG']._serialized_end=7811 + _globals['_ADKSSEMCPSERVERCONFIG']._serialized_start=7814 + _globals['_ADKSSEMCPSERVERCONFIG']._serialized_end=8062 + _globals['_ADKSTDIOMCPSERVERCONFIG']._serialized_start=8065 + _globals['_ADKSTDIOMCPSERVERCONFIG']._serialized_end=8280 + _globals['_ADKSTDIOMCPSERVERCONFIG_ENVENTRY']._serialized_start=8226 + _globals['_ADKSTDIOMCPSERVERCONFIG_ENVENTRY']._serialized_end=8280 + _globals['_ADKREMOTEAGENTCONFIG']._serialized_start=8283 + _globals['_ADKREMOTEAGENTCONFIG']._serialized_end=8630 + _globals['_ADKREMOTEAGENTCONFIG_HEADERSENTRY']._serialized_start=2356 + _globals['_ADKREMOTEAGENTCONFIG_HEADERSENTRY']._serialized_end=2414 + _globals['_ADKEMBEDDINGCONFIG']._serialized_start=8633 + _globals['_ADKEMBEDDINGCONFIG']._serialized_end=9114 + _globals['_ADKMEMORYCONFIG']._serialized_start=9116 + _globals['_ADKMEMORYCONFIG']._serialized_end=9241 + _globals['_ADKCONTEXTCONFIG']._serialized_start=9243 + _globals['_ADKCONTEXTCONFIG']._serialized_end=9333 + _globals['_ADKCOMPRESSIONCONFIG']._serialized_start=9336 + _globals['_ADKCOMPRESSIONCONFIG']._serialized_end=9817 + _globals['_ADKOUTPUTCONFIG']._serialized_start=9819 + _globals['_ADKOUTPUTCONFIG']._serialized_end=9893 + _globals['_ADKWORKFLOWCONFIG']._serialized_start=9895 + _globals['_ADKWORKFLOWCONFIG']._serialized_end=10002 + _globals['_ADKAGENTPLUGINS']._serialized_start=10005 + _globals['_ADKAGENTPLUGINS']._serialized_end=10135 + _globals['_ADKSKILL']._serialized_start=10137 + _globals['_ADKSKILL']._serialized_end=10226 + _globals['_ADKPLUGINBUNDLE']._serialized_start=10228 + _globals['_ADKPLUGINBUNDLE']._serialized_end=10328 + _globals['_ADKPLUGINSOURCE']._serialized_start=10331 + _globals['_ADKPLUGINSOURCE']._serialized_end=10530 + _globals['_ADKPLUGINGITSOURCE']._serialized_start=10532 + _globals['_ADKPLUGINGITSOURCE']._serialized_end=10594 + _globals['_ADKPLUGINS3SOURCE']._serialized_start=10597 + _globals['_ADKPLUGINS3SOURCE']._serialized_end=10741 + _globals['_ADKRUNTIMECONFIG']._serialized_start=10744 + _globals['_ADKRUNTIMECONFIG']._serialized_end=11643 # @@protoc_insertion_point(module_scope) diff --git a/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.pyi b/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.pyi index 8abc5b97fa..c864ce595b 100644 --- a/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.pyi +++ b/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.pyi @@ -4,6 +4,7 @@ from buf.validate import validate_pb2 as _validate_pb2 from google.protobuf import duration_pb2 as _duration_pb2 from kagent.config.v1 import config_pb2 as _config_pb2 from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper from google.protobuf import descriptor as _descriptor from google.protobuf import message as _message from collections.abc import Iterable as _Iterable, Mapping as _Mapping @@ -11,6 +12,15 @@ from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union DESCRIPTOR: _descriptor.FileDescriptor +class ADKAgentIsolation(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + ADK_AGENT_ISOLATION_UNSPECIFIED: _ClassVar[ADKAgentIsolation] + ADK_AGENT_ISOLATION_SHARED: _ClassVar[ADKAgentIsolation] + ADK_AGENT_ISOLATION_SESSION: _ClassVar[ADKAgentIsolation] +ADK_AGENT_ISOLATION_UNSPECIFIED: ADKAgentIsolation +ADK_AGENT_ISOLATION_SHARED: ADKAgentIsolation +ADK_AGENT_ISOLATION_SESSION: ADKAgentIsolation + class ADKConfig(_message.Message): __slots__ = ("runtime", "agent") RUNTIME_FIELD_NUMBER: _ClassVar[int] @@ -20,7 +30,7 @@ class ADKConfig(_message.Message): def __init__(self, runtime: _Optional[_Union[ADKRuntimeConfig, _Mapping]] = ..., agent: _Optional[_Union[ADKAgentConfig, _Mapping]] = ...) -> None: ... class ADKAgentConfig(_message.Message): - __slots__ = ("name", "model", "description", "instruction", "http_tools", "sse_tools", "stdio_tools", "remote_agents", "stream", "memory", "agent_plugins", "context_config", "session_db_url", "skills_directory", "sub_agents", "output") + __slots__ = ("name", "model", "description", "instruction", "http_tools", "sse_tools", "stdio_tools", "remote_agents", "stream", "memory", "agent_plugins", "context_config", "session_db_url", "skills_directory", "sub_agents", "output", "workflow", "isolation") NAME_FIELD_NUMBER: _ClassVar[int] MODEL_FIELD_NUMBER: _ClassVar[int] DESCRIPTION_FIELD_NUMBER: _ClassVar[int] @@ -37,6 +47,8 @@ class ADKAgentConfig(_message.Message): SKILLS_DIRECTORY_FIELD_NUMBER: _ClassVar[int] SUB_AGENTS_FIELD_NUMBER: _ClassVar[int] OUTPUT_FIELD_NUMBER: _ClassVar[int] + WORKFLOW_FIELD_NUMBER: _ClassVar[int] + ISOLATION_FIELD_NUMBER: _ClassVar[int] name: str model: ADKModelConfig description: str @@ -53,7 +65,9 @@ class ADKAgentConfig(_message.Message): skills_directory: str sub_agents: _containers.RepeatedCompositeFieldContainer[ADKAgentConfig] output: ADKOutputConfig - def __init__(self, name: _Optional[str] = ..., model: _Optional[_Union[ADKModelConfig, _Mapping]] = ..., description: _Optional[str] = ..., instruction: _Optional[str] = ..., http_tools: _Optional[_Iterable[_Union[ADKHTTPMCPServerConfig, _Mapping]]] = ..., sse_tools: _Optional[_Iterable[_Union[ADKSSEMCPServerConfig, _Mapping]]] = ..., stdio_tools: _Optional[_Iterable[_Union[ADKStdioMCPServerConfig, _Mapping]]] = ..., remote_agents: _Optional[_Iterable[_Union[ADKRemoteAgentConfig, _Mapping]]] = ..., stream: _Optional[bool] = ..., memory: _Optional[_Union[ADKMemoryConfig, _Mapping]] = ..., agent_plugins: _Optional[_Union[ADKAgentPlugins, _Mapping]] = ..., context_config: _Optional[_Union[ADKContextConfig, _Mapping]] = ..., session_db_url: _Optional[str] = ..., skills_directory: _Optional[str] = ..., sub_agents: _Optional[_Iterable[_Union[ADKAgentConfig, _Mapping]]] = ..., output: _Optional[_Union[ADKOutputConfig, _Mapping]] = ...) -> None: ... + workflow: ADKWorkflowConfig + isolation: ADKAgentIsolation + def __init__(self, name: _Optional[str] = ..., model: _Optional[_Union[ADKModelConfig, _Mapping]] = ..., description: _Optional[str] = ..., instruction: _Optional[str] = ..., http_tools: _Optional[_Iterable[_Union[ADKHTTPMCPServerConfig, _Mapping]]] = ..., sse_tools: _Optional[_Iterable[_Union[ADKSSEMCPServerConfig, _Mapping]]] = ..., stdio_tools: _Optional[_Iterable[_Union[ADKStdioMCPServerConfig, _Mapping]]] = ..., remote_agents: _Optional[_Iterable[_Union[ADKRemoteAgentConfig, _Mapping]]] = ..., stream: _Optional[bool] = ..., memory: _Optional[_Union[ADKMemoryConfig, _Mapping]] = ..., agent_plugins: _Optional[_Union[ADKAgentPlugins, _Mapping]] = ..., context_config: _Optional[_Union[ADKContextConfig, _Mapping]] = ..., session_db_url: _Optional[str] = ..., skills_directory: _Optional[str] = ..., sub_agents: _Optional[_Iterable[_Union[ADKAgentConfig, _Mapping]]] = ..., output: _Optional[_Union[ADKOutputConfig, _Mapping]] = ..., workflow: _Optional[_Union[ADKWorkflowConfig, _Mapping]] = ..., isolation: _Optional[_Union[ADKAgentIsolation, str]] = ...) -> None: ... class ADKModelConfig(_message.Message): __slots__ = ("model", "headers", "tls_insecure_skip_verify", "tls_ca_cert_path", "tls_disable_system_cas", "api_key_passthrough", "openai", "azure_openai", "anthropic", "gemini_vertex_ai", "gemini_anthropic", "ollama", "gemini", "bedrock", "sap_ai_core", "foundry", "mistral") @@ -467,6 +481,14 @@ class ADKOutputConfig(_message.Message): sha256: str def __init__(self, json_schema: _Optional[bytes] = ..., sha256: _Optional[str] = ...) -> None: ... +class ADKWorkflowConfig(_message.Message): + __slots__ = ("document", "sha256") + DOCUMENT_FIELD_NUMBER: _ClassVar[int] + SHA256_FIELD_NUMBER: _ClassVar[int] + document: bytes + sha256: str + def __init__(self, document: _Optional[bytes] = ..., sha256: _Optional[str] = ...) -> None: ... + class ADKAgentPlugins(_message.Message): __slots__ = ("skills", "plugins") SKILLS_FIELD_NUMBER: _ClassVar[int] diff --git a/ui/src/api/chat/a2aGrpcChatClient.test.ts b/ui/src/api/chat/a2aGrpcChatClient.test.ts index 278ca76673..d743e5bf3b 100644 --- a/ui/src/api/chat/a2aGrpcChatClient.test.ts +++ b/ui/src/api/chat/a2aGrpcChatClient.test.ts @@ -790,6 +790,83 @@ describe("A2AGrpcChatClient.send", () => { ).toEqual(["first", "second"]); }); + it("names the agent that wrote each workflow reply and keeps the replies apart", async () => { + const artifact = (id: string, author: string, reply: string) => ({ + payload: { + case: "artifactUpdate" as const, + value: { + taskId: "task-1", + artifact: { + artifactId: id, + parts: [text(reply)], + metadata: { [A2A_METADATA.author]: author }, + }, + }, + }, + }); + const events = await turn([ + artifact("draft", "writer", "A draft."), + artifact("review-1", "critic_style", "REVISE: shorter."), + artifact("review-2", "critic_clarity", "APPROVE"), + ]); + + expect( + transcript(events) + .filter((message) => message.role === "agent") + .map((message) => [message.author, textOf(message)]), + ).toEqual([ + ["writer", "A draft."], + ["critic_style", "REVISE: shorter."], + ["critic_clarity", "APPROVE"], + ]); + }); + + it("keeps a reply's author when a later chunk replaces it without one", async () => { + const chunk = (reply: string, extra: { append?: boolean; lastChunk?: boolean; author?: string }) => ({ + payload: { + case: "artifactUpdate" as const, + value: { + taskId: "task-1", + append: extra.append, + lastChunk: extra.lastChunk, + artifact: { + artifactId: "draft", + parts: [text(reply)], + metadata: extra.author ? { [A2A_METADATA.author]: extra.author } : undefined, + }, + }, + }, + }); + const events = await turn([ + chunk("A", { author: "writer" }), + chunk(" draft.", { append: true }), + chunk("A draft.", { lastChunk: true }), + ]); + + expect( + transcript(events) + .filter((message) => message.role === "agent") + .map((message) => [message.author, textOf(message)]), + ).toEqual([["writer", "A draft."]]); + }); + + it("leaves the author unset when the runtime does not report one", async () => { + const events = await turn([ + { + payload: { + case: "artifactUpdate" as const, + value: { taskId: "task-1", artifact: { artifactId: "only", parts: [text("hello")] } }, + }, + }, + ]); + + expect( + transcript(events) + .filter((message) => message.role === "agent") + .map((message) => message.author), + ).toEqual([undefined]); + }); + it("labels a tool call and its result distinguishably", async () => { const events = await turn([ statusFrame({ @@ -1011,6 +1088,51 @@ describe("A2AGrpcChatClient.history", () => { }); } + it("keeps each workflow reply's author when a conversation is reloaded", async () => { + const position = (value: string, author: string) => ({ + [A2A_METADATA.timelinePosition]: value, + [A2A_METADATA.author]: author, + }); + serveTasks([ + { + id: "task-1", + contextId: CONVERSATION.id, + status: { state: TaskState.COMPLETED, timestamp: { seconds: 1767225600n } }, + history: [ + { + messageId: "opening", + role: Role.USER, + parts: [text("why the sky is blue")], + metadata: { [A2A_METADATA.timelinePosition]: "2026-01-01T00:00:00.000000001Z" }, + }, + ], + artifacts: [ + { + artifactId: "draft", + parts: [text("A draft.")], + metadata: position("2026-01-01T00:00:00.000000002Z", "writer"), + }, + { + artifactId: "review", + parts: [text("APPROVE")], + metadata: position("2026-01-01T00:00:00.000000003Z", "critic_clarity"), + }, + ], + }, + ]); + + const { messages } = await new A2AGrpcChatClient().history(CONVERSATION); + + expect( + messages + .filter((message) => message.role === "agent") + .map((message) => [message.author, textOf(message)]), + ).toEqual([ + ["writer", "A draft."], + ["critic_clarity", "APPROVE"], + ]); + }); + it("replays a completed approval as one structured record without protocol text", async () => { serveTasks([ { diff --git a/ui/src/api/chat/a2aGrpcChatClient.ts b/ui/src/api/chat/a2aGrpcChatClient.ts index 94b7657940..048ba0a81a 100644 --- a/ui/src/api/chat/a2aGrpcChatClient.ts +++ b/ui/src/api/chat/a2aGrpcChatClient.ts @@ -576,6 +576,8 @@ export class A2AGrpcChatClient implements ChatClient { * belongs to this turn and must not outlive it. */ const artifacts = new Map(); + // The sender of each artifact, kept because a replacing chunk may omit it. + const artifactAuthors = new Map(); const options = this.callOptions(conversation, signal); let stream: AsyncIterable; @@ -725,6 +727,8 @@ export class A2AGrpcChatClient implements ChatClient { const body = textOf(parts); const artifactId = event.artifact?.artifactId || ""; const known = artifactId !== "" && artifacts.has(artifactId); + const author = metadataString(event.artifact?.metadata, A2A_METADATA.author) + ?? artifactAuthors.get(artifactId); if (known) { const id = artifactId; @@ -747,6 +751,7 @@ export class A2AGrpcChatClient implements ChatClient { parts: updated, createdAt: new Date().toISOString(), taskId: event.taskId, + author, }, }; } @@ -767,6 +772,7 @@ export class A2AGrpcChatClient implements ChatClient { const id = artifactId || nextId("artifact"); artifacts.set(id, parts); + if (author) artifactAuthors.set(id, author); yield { type: "message", message: { @@ -775,6 +781,7 @@ export class A2AGrpcChatClient implements ChatClient { parts, createdAt: new Date().toISOString(), taskId: event.taskId, + author, }, }; continue; @@ -799,6 +806,8 @@ export class A2AGrpcChatClient implements ChatClient { for (const artifact of task.artifacts) { if (artifact.artifactId) { artifacts.set(artifact.artifactId, toParts(artifact.parts)); + const author = metadataString(artifact.metadata, A2A_METADATA.author); + if (author) artifactAuthors.set(artifact.artifactId, author); } } yield { @@ -1003,6 +1012,7 @@ export function messagesFromTask(task: A2ATask): ChatMessage[] { parts, createdAt, taskId: task.id || undefined, + author: metadataString(artifact.metadata, A2A_METADATA.author), }; agent.push(converted); positioned.push({ message: converted, position: timelinePosition(artifact.metadata) }); diff --git a/ui/src/api/chat/a2aMetadata.ts b/ui/src/api/chat/a2aMetadata.ts index d681754880..51a8a38bbc 100644 --- a/ui/src/api/chat/a2aMetadata.ts +++ b/ui/src/api/chat/a2aMetadata.ts @@ -1,4 +1,6 @@ export const A2A_METADATA = { + /** The agent that wrote an artifact; a workflow has one per node. */ + author: "kagent.dev/a2a/author", outputSchemaSha256: "kagent.dev/a2a/output-schema-sha256", partType: "kagent.dev/a2a/part-type", timelinePosition: "kagent.dev/a2a/timeline-position", diff --git a/ui/src/api/chat/types.ts b/ui/src/api/chat/types.ts index 6d712b7d32..9d04fd2ba1 100644 --- a/ui/src/api/chat/types.ts +++ b/ui/src/api/chat/types.ts @@ -73,6 +73,8 @@ export interface ChatMessage { createdAt: string; /** The turn this message belongs to, when the transport groups turns. */ taskId?: string; + /** The agent that wrote an agent message. A workflow reports one per node. */ + author?: string; } /** Lifecycle of one agent turn. */ diff --git a/ui/src/api/domain/agentTemplates.ts b/ui/src/api/domain/agentTemplates.ts index 29257121fc..20decd1a91 100644 --- a/ui/src/api/domain/agentTemplates.ts +++ b/ui/src/api/domain/agentTemplates.ts @@ -36,11 +36,21 @@ export interface McpToolBinding { requireApproval?: boolean; } +/** + * How the parent runs a sub-agent. `Shared` (the default) transfers the conversation + * to it in the parent's Session. `Isolated` calls it as a tool in a new conversation + * seeded with a copy of the parent's state: it never sees the parent's history, + * parallel calls cannot interfere, and it cannot pause for human input, so its MCP + * tools cannot require approval. Only the kagent and BYO harnesses support it. + */ +export type SubAgentIsolation = "Shared" | "Isolated"; + /** Another AgentTemplate exposed as a tool in the parent's runtime. */ export interface SubAgentToolBinding { name: string; description: string; templateRef: AgentTemplateLocalRef; + isolation?: SubAgentIsolation; // Deferred until Dedicated subagent execution is available. // agentRef?: AgentTemplateLocalRef; } @@ -110,6 +120,13 @@ export interface AgentTemplateSpec { /** Mutually exclusive with `systemPromptFrom` — the CRD rejects both. */ systemPrompt?: string; systemPromptFrom?: ConfigMapKeyRef; + /** + * A same-namespace ConfigMap key containing an `adk.declarative/v0.2` AgentSystem + * document, as YAML or JSON. Only a root template on a BYO harness may set it, and + * the CRD rejects it together with `systemPrompt` or `systemPromptFrom`. The form + * does not author it; edits preserve it. + */ + workflowFrom?: ConfigMapKeyRef; /** * The JSON Schema for a successful terminal response from this template when it * runs as the root agent. Mutually exclusive with `outputSchemaFrom`. diff --git a/ui/src/components/agent-template-form/AgentTemplateForm.tsx b/ui/src/components/agent-template-form/AgentTemplateForm.tsx index 9961b9518b..91131d020d 100644 --- a/ui/src/components/agent-template-form/AgentTemplateForm.tsx +++ b/ui/src/components/agent-template-form/AgentTemplateForm.tsx @@ -525,7 +525,7 @@ export function AgentTemplateForm({ {/* Tools — sub-agents */} {readOnly && draft.subAgentTools.length === 0 @@ -567,6 +567,21 @@ export function AgentTemplateForm({ }} {...readOnlyInput} /> + {readOnly ? ( + tool.isolated ? Isolated session : null + ) : ( + { + const next = [...draft.subAgentTools]; + next[index] = { ...next[index], isolated: event.target.checked }; + set("subAgentTools", next); + }} + > + Isolated session + + )} {readOnly ? null : (