From 236bc88b4cf9471cfe78f823bce1e1706bc69283 Mon Sep 17 00:00:00 2001 From: Dmytro Rashko Date: Sun, 25 Oct 2026 00:08:25 +0200 Subject: [PATCH 01/15] feat(adk): declarative workflow agents for the Go ADK Add declarative ADK Go v2 workflow agents (adk.declarative AgentSystem documents) with a registry for functions, tools, toolsets, models and node types; runnable workflow design patterns in Go and YAML; controller validation of workflow documents for BYO agents (spec.workflowFrom); session isolation for fan-out and Shared subagents; and architecture docs. Supersedes #2, rebased onto main and reconciled with runtime-level ask_user disabling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Dmytro Rashko --- docs/architecture/README.md | 2 + docs/architecture/a2a-subagents.md | 48 + .../configuration-and-compilation.md | 7 +- docs/architecture/workflow-agents.md | 227 +++ docs/architecture/workflow-patterns.md | 1577 +++++++++++++++++ go/adk/examples/declarative/agents.yaml | 27 + go/adk/examples/declarative/main.go | 194 ++ go/adk/examples/declarative/main_test.go | 41 + .../anti/converging_routes.yaml | 16 + .../anti/fan_out_without_gather.yaml | 13 + .../anti/multicast_without_gather.yaml | 13 + .../anti/schema_mismatch.yaml | 11 + .../workflowpatterns/anti/task_mode_node.yaml | 10 + .../workflowpatterns/anti/terminal_tool.yaml | 11 + .../anti/unconditional_cycle.yaml | 12 + go/adk/examples/workflowpatterns/catalog.go | 395 +++++ go/adk/examples/workflowpatterns/doc_test.go | 198 +++ go/adk/examples/workflowpatterns/helpers.go | 246 +++ go/adk/examples/workflowpatterns/main.go | 89 + go/adk/examples/workflowpatterns/patterns.go | 467 +++++ .../patterns/agent_as_tool.yaml | 9 + .../patterns/conditional.yaml | 13 + .../workflowpatterns/patterns/dynamic.yaml | 14 + .../workflowpatterns/patterns/fan_in.yaml | 19 + .../patterns/fan_out_agents.yaml | 18 + .../patterns/human_approval.yaml | 17 + .../patterns/isolated_children.yaml | 13 + .../workflowpatterns/patterns/llm_stage.yaml | 13 + .../workflowpatterns/patterns/loop_agent.yaml | 8 + .../workflowpatterns/patterns/map_reduce.yaml | 13 + .../workflowpatterns/patterns/nested.yaml | 20 + .../patterns/parallel_agent.yaml | 8 + .../workflowpatterns/patterns/resilient.yaml | 13 + .../workflowpatterns/patterns/retry_loop.yaml | 14 + .../workflowpatterns/patterns/router.yaml | 15 + .../workflowpatterns/patterns/sequence.yaml | 13 + .../patterns/sequential_agent.yaml | 8 + .../patterns/shared_children.yaml | 13 + .../workflowpatterns/patterns/tool_stage.yaml | 13 + .../patterns/typed_contract.yaml | 17 + .../workflowpatterns/patterns_test.go | 104 ++ go/adk/examples/workflowpatterns/registry.go | 129 ++ go/adk/pkg/agent/agent.go | 58 +- go/adk/pkg/agent/agent_test.go | 11 + go/adk/pkg/agent/isolation_test.go | 209 +++ go/adk/pkg/config/contract_test.go | 24 + go/adk/pkg/declarative/build.go | 274 +++ go/adk/pkg/declarative/build_test.go | 585 ++++++ go/adk/pkg/declarative/doc.go | 19 + go/adk/pkg/declarative/helpers_test.go | 137 ++ go/adk/pkg/declarative/isolation_test.go | 370 ++++ go/adk/pkg/declarative/load.go | 50 + go/adk/pkg/declarative/load_test.go | 41 + go/adk/pkg/declarative/nodes.go | 297 ++++ go/adk/pkg/declarative/registry.go | 312 ++++ go/adk/pkg/declarative/registry_test.go | 170 ++ go/adk/pkg/declarative/schema_test.go | 129 ++ go/adk/pkg/declarative/spec.go | 209 +++ go/adk/pkg/declarative/stub.go | 52 + .../pkg/declarative/testdata/composites.yaml | 46 + go/adk/pkg/declarative/testdata/showcase.yaml | 136 ++ go/adk/pkg/declarative/validate.go | 774 ++++++++ go/adk/pkg/declarative/validate_test.go | 291 +++ go/api/gen/kagent/config/v1/adk.pb.go | 425 +++-- go/api/v1alpha3/agenttemplate_types.go | 31 + go/api/v1alpha3/configuration_crd_cel_test.go | 62 + go/api/v1alpha3/zz_generated.deepcopy.go | 5 + .../internal/translator/adkconfig/builder.go | 10 + .../internal/translator/adkconfig/runtime.go | 30 + go/core/internal/translator/byo/compiler.go | 8 +- go/core/internal/translator/compiler.go | 24 +- .../internal/translator/kagent/compiler.go | 8 +- go/core/internal/translator/outputschema.go | 32 +- go/core/internal/translator/subagent.go | 29 + .../translator/subagent_isolation_test.go | 168 ++ go/core/internal/translator/workflow.go | 68 + .../translator/workflow_compile_test.go | 178 ++ go/core/internal/translator/workflow_test.go | 100 ++ go/core/test/e2e/subagent_isolation_test.go | 92 + go/core/test/e2e/workflow_test.go | 134 ++ .../templates/api.kagent.dev_agents.yaml | 32 + .../api.kagent.dev_agenttemplates.yaml | 30 + proto/kagent/config/v1/adk.proto | 29 + .../src/kagent/config/v1/adk_pb2.py | 174 +- .../src/kagent/config/v1/adk_pb2.pyi | 26 +- ui/src/api/domain/agentTemplates.ts | 17 + .../agent-template-form/AgentTemplateForm.tsx | 17 +- .../agentTemplateDraft.test.ts | 47 + .../agent-template-form/agentTemplateDraft.ts | 5 + 89 files changed, 9833 insertions(+), 250 deletions(-) create mode 100644 docs/architecture/workflow-agents.md create mode 100644 docs/architecture/workflow-patterns.md create mode 100644 go/adk/examples/declarative/agents.yaml create mode 100644 go/adk/examples/declarative/main.go create mode 100644 go/adk/examples/declarative/main_test.go create mode 100644 go/adk/examples/workflowpatterns/anti/converging_routes.yaml create mode 100644 go/adk/examples/workflowpatterns/anti/fan_out_without_gather.yaml create mode 100644 go/adk/examples/workflowpatterns/anti/multicast_without_gather.yaml create mode 100644 go/adk/examples/workflowpatterns/anti/schema_mismatch.yaml create mode 100644 go/adk/examples/workflowpatterns/anti/task_mode_node.yaml create mode 100644 go/adk/examples/workflowpatterns/anti/terminal_tool.yaml create mode 100644 go/adk/examples/workflowpatterns/anti/unconditional_cycle.yaml create mode 100644 go/adk/examples/workflowpatterns/catalog.go create mode 100644 go/adk/examples/workflowpatterns/doc_test.go create mode 100644 go/adk/examples/workflowpatterns/helpers.go create mode 100644 go/adk/examples/workflowpatterns/main.go create mode 100644 go/adk/examples/workflowpatterns/patterns.go create mode 100644 go/adk/examples/workflowpatterns/patterns/agent_as_tool.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/conditional.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/dynamic.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/fan_in.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/fan_out_agents.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/human_approval.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/isolated_children.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/llm_stage.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/loop_agent.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/map_reduce.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/nested.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/parallel_agent.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/resilient.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/retry_loop.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/router.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/sequence.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/sequential_agent.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/shared_children.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/tool_stage.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns/typed_contract.yaml create mode 100644 go/adk/examples/workflowpatterns/patterns_test.go create mode 100644 go/adk/examples/workflowpatterns/registry.go create mode 100644 go/adk/pkg/agent/isolation_test.go create mode 100644 go/adk/pkg/declarative/build.go create mode 100644 go/adk/pkg/declarative/build_test.go create mode 100644 go/adk/pkg/declarative/doc.go create mode 100644 go/adk/pkg/declarative/helpers_test.go create mode 100644 go/adk/pkg/declarative/isolation_test.go create mode 100644 go/adk/pkg/declarative/load.go create mode 100644 go/adk/pkg/declarative/load_test.go create mode 100644 go/adk/pkg/declarative/nodes.go create mode 100644 go/adk/pkg/declarative/registry.go create mode 100644 go/adk/pkg/declarative/registry_test.go create mode 100644 go/adk/pkg/declarative/schema_test.go create mode 100644 go/adk/pkg/declarative/spec.go create mode 100644 go/adk/pkg/declarative/stub.go create mode 100644 go/adk/pkg/declarative/testdata/composites.yaml create mode 100644 go/adk/pkg/declarative/testdata/showcase.yaml create mode 100644 go/adk/pkg/declarative/validate.go create mode 100644 go/adk/pkg/declarative/validate_test.go create mode 100644 go/core/internal/translator/subagent.go create mode 100644 go/core/internal/translator/subagent_isolation_test.go create mode 100644 go/core/internal/translator/workflow.go create mode 100644 go/core/internal/translator/workflow_compile_test.go create mode 100644 go/core/internal/translator/workflow_test.go create mode 100644 go/core/test/e2e/subagent_isolation_test.go create mode 100644 go/core/test/e2e/workflow_test.go diff --git a/docs/architecture/README.md b/docs/architecture/README.md index b91a39ddbb..cdb4d1b640 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -111,6 +111,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 9900d62aff..2923aab2af 100644 --- a/docs/architecture/configuration-and-compilation.md +++ b/docs/architecture/configuration-and-compilation.md @@ -8,7 +8,10 @@ literal environment values, WorkerPool configuration, and snapshot location. `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 @@ -20,6 +23,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..48bfee191c --- /dev/null +++ b/docs/architecture/workflow-agents.md @@ -0,0 +1,227 @@ +# 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` | +| `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 | + +`function`, `tool`, `agent`, `llm`, `parallel_worker` and `dynamic` nodes accept +`timeout` and `retry`, which map to `workflow.NodeConfig`. Unset retry fields +take the ADK defaults. + +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` node returns; `DEFAULT` is the edge taken when no route matched. +Unknown fields anywhere in the document are decode errors. + +### 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. +- `workflowagent.Config` exposes no engine options, so a concurrency cap for the + whole graph is not available; `parallel_worker.max_concurrency` bounds one + worker. +- 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` serves a document through the A2A application +builder, with Go functions, a tool, and an OpenAI model resolver registered. +[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..9c66d33f42 --- /dev/null +++ b/docs/architecture/workflow-patterns.md @@ -0,0 +1,1577 @@ +# 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. + +```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{}) + return workflowagent.New(workflowagent.Config{ + Name: "fan_in", + Edges: workflow.NewEdgeBuilder(). + AddFanOut(workflow.Start, shout, whisper, mirror). + AddFanIn(gather, shout, whisper, mirror). + Add(gather, merge). + Build(), + }) +} +``` + + + +```yaml +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: fan_in + agent_class: Workflow + 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 } +``` + +
+ +**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 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 + 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 } + 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/examples/declarative/agents.yaml b/go/adk/examples/declarative/agents.yaml new file mode 100644 index 0000000000..d4b835450d --- /dev/null +++ b/go/adk/examples/declarative/agents.yaml @@ -0,0 +1,27 @@ +# A support triage workflow: classify the request, then answer it with a model +# node, look the customer up, or fall back to a canned reply. +# +# START -> classify -+- BILLING -> customer_lookup -> bill_reply +# +- QUESTION -> answer (llm) +# +- DEFAULT -> fallback +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: support_triage + agent_class: Workflow + description: Routes a support request to the right handler + nodes: + - { id: classify, type: function, function: classify } + - { id: customer_lookup, type: tool, tool: lookup_customer } + - { id: bill_reply, type: function, function: bill_reply } + - id: answer + type: llm + model: gpt-4o-mini + instruction: Answer the support question in two sentences. + - { id: fallback, type: function, function: fallback, timeout: 5s } + edges: + - { from: START, to: classify } + - { from: classify, to: customer_lookup, route: BILLING } + - { from: classify, to: answer, route: QUESTION } + - { from: classify, to: fallback, route: DEFAULT } + - { from: customer_lookup, to: bill_reply } diff --git a/go/adk/examples/declarative/main.go b/go/adk/examples/declarative/main.go new file mode 100644 index 0000000000..72dbc98d5b --- /dev/null +++ b/go/adk/examples/declarative/main.go @@ -0,0 +1,194 @@ +// Package main demonstrates declarative workflow agents: an AgentSystem +// document (agents.yaml) is compiled into a native ADK workflow agent by +// pkg/declarative and served over A2A. +// +// The document only names behavior; this program supplies it through a +// declarative.Registry: Go functions, a tool, and a model resolver that maps +// unregistered model names to OpenAI models. Use the "stub" model name in a +// document to run without credentials. +// +// Deploy through a BYO Harness. Process settings are loaded from the root +// runtime object in /config/config.json. OPENAI_API_KEY remains a provider +// credential. An AgentTemplate that sets spec.workflowFrom replaces the +// embedded document: the controller validates it and delivers it in +// /config/config.json. +package main + +import ( + "cmp" + "context" + _ "embed" + "fmt" + "log/slog" + "os" + "strings" + + 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" + "google.golang.org/adk/v2/server/adka2a/v2" + adksession "google.golang.org/adk/v2/session" + "google.golang.org/adk/v2/tool/functiontool" + + "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" + configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" + "github.com/kagent-dev/kagent/go/pkg/logging" +) + +//go:embed agents.yaml +var agentsDocument []byte + +type lookupArgs struct { + CustomerID string `json:"customer_id"` +} + +type customer struct { + ID string `json:"id"` + Plan string `json:"plan"` +} + +func newRegistry() (*declarative.Registry, error) { + reg := declarative.NewRegistry() + + // Unregistered model names resolve to OpenAI models. + reg.SetModelResolver(func(ctx context.Context, name string) (adkmodel.LLM, error) { + return models.NewOpenAIModel(ctx, &models.OpenAIConfig{Model: name}) + }) + + lookup, err := functiontool.New(functiontool.Config{ + Name: "lookup_customer", + Description: "Looks up a customer by id.", + }, func(_ adkagent.Context, args lookupArgs) (customer, error) { + return customer{ID: args.CustomerID, Plan: "pro"}, nil + }) + if err != nil { + return nil, fmt.Errorf("failed to create tool lookup_customer: %w", err) + } + + for name, fn := range map[string]declarative.Func{ + "classify": classify, + "bill_reply": billReply, + "fallback": func(adkagent.Context, any) (declarative.Result, error) { + return declarative.Output("I could not classify that request."), nil + }, + } { + if err := reg.RegisterFunction(name, fn); err != nil { + return nil, err + } + } + if err := reg.RegisterTool(lookup); err != nil { + return nil, err + } + return reg, nil +} + +// classify routes on the first word of the user message. BILLING requests +// carry the tool arguments their successor expects. +func classify(ctx adkagent.Context, _ any) (declarative.Result, error) { + var text strings.Builder + if uc := ctx.UserContent(); uc != nil { + for _, p := range uc.Parts { + if p != nil { + text.WriteString(p.Text) + } + } + } + switch first, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(text.String())), " "); first { + case "billing": + return declarative.Route(map[string]any{"customer_id": "c-42"}, "BILLING"), nil + case "question": + return declarative.Route(text.String(), "QUESTION"), nil + } + return declarative.Route(text.String(), "UNMATCHED"), nil +} + +func billReply(_ adkagent.Context, in any) (declarative.Result, error) { + return declarative.Output(fmt.Sprintf("Customer record: %v", in)), nil +} + +// loadDocument prefers the document the controller validated and delivered +// from AgentTemplate.spec.workflowFrom, and falls back to the embedded one. +func loadDocument(reg *declarative.Registry, delivered *configv1.ADKWorkflowConfig) (*declarative.Spec, error) { + if delivered != nil { + return reg.ParseConfig(delivered) + } + return reg.Parse(agentsDocument) +} + +func main() { + if err := config.MaterializeFromEnv("/config"); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + compiled, err := config.LoadAgentConfig("/config/config.json") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + runtime := config.ProcessConfig(compiled) + logger, err := logging.New(os.Stderr, cmp.Or(runtime.LogLevel, "info")) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + slog.SetDefault(logger) + ctx := logging.IntoContext(context.Background(), logger) + + reg, err := newRegistry() + if err != nil { + logger.Error("failed to create registry", "error", err) + os.Exit(1) + } + spec, err := loadDocument(reg, compiled.GetAgent().GetWorkflow()) + if err != nil { + logger.Error("failed to load the agent document", "error", err) + os.Exit(1) + } + system, err := declarative.Build(ctx, spec, reg) + if err != nil { + logger.Error("failed to build agents", "error", err) + os.Exit(1) + } + root := system.Root() + + executor := adka2a.NewExecutor(adka2a.ExecutorConfig{ + RunnerConfig: runner.Config{ + AppName: root.Name(), + Agent: root, + SessionService: adksession.InMemoryService(), + }, + }) + + kagentApp, err := app.New(app.AppConfig{ControlPlaneURL: runtime.ControlPlaneUrl, MaxContentLength: runtime.MaxContentLength, + AgentCard: a2atype.AgentCard{ + Name: root.Name(), + Description: root.Description(), + Version: "1.0.0", + SupportedInterfaces: []*a2atype.AgentInterface{ + a2atype.NewAgentInterface("http://localhost:8082", a2atype.TransportProtocolJSONRPC), + }, + DefaultInputModes: []string{"text/plain"}, + DefaultOutputModes: []string{"text/plain"}, + Skills: []a2atype.AgentSkill{{ + ID: "support-triage", + Name: "Support triage", + Description: "Routes a request to billing lookup, a model answer, or a fallback", + }}, + }, + Port: fmt.Sprint(cmp.Or(runtime.GetPort(), 8082)), + Logger: logger, + Agent: root, + }, executor) + if err != nil { + logger.Error("failed to create app", "error", err) + os.Exit(1) + } + if err := kagentApp.Run(); err != nil { + logger.Error("server failed", "error", err) + os.Exit(1) + } +} diff --git a/go/adk/examples/declarative/main_test.go b/go/adk/examples/declarative/main_test.go new file mode 100644 index 0000000000..b2f90fb43e --- /dev/null +++ b/go/adk/examples/declarative/main_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/kagent-dev/kagent/go/adk/pkg/declarative" + configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" +) + +// TestAgentsDocumentBuilds guards the example document against drift from the +// registry it is served with. +func TestAgentsDocumentBuilds(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "test-key") // building an OpenAI model needs a key; no request is made + reg, err := newRegistry() + require.NoError(t, err) + spec, err := reg.Parse(agentsDocument) + require.NoError(t, err) + system, err := declarative.Build(t.Context(), spec, reg) + require.NoError(t, err) + require.Equal(t, "support_triage", system.Root().Name()) +} + +func TestLoadDocumentPrefersTheDeliveredWorkflow(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "test-key") + reg, err := newRegistry() + require.NoError(t, err) + + embedded, err := loadDocument(reg, nil) + require.NoError(t, err) + require.Equal(t, "support_triage", embedded.Agents[0].Name) + + const document = `{"apiVersion":"adk.declarative/v0.2","kind":"AgentSystem","agents":[{"name":"delivered","agent_class":"LlmAgent","model":"stub","instruction":"hi"}]}` + sum := sha256.Sum256([]byte(document)) + delivered, err := loadDocument(reg, &configv1.ADKWorkflowConfig{Document: []byte(document), Sha256: hex.EncodeToString(sum[:])}) + require.NoError(t, err) + require.Equal(t, "delivered", delivered.Agents[0].Name) +} 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..1b857a1201 --- /dev/null +++ b/go/adk/examples/workflowpatterns/catalog.go @@ -0,0 +1,395 @@ +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.", + 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: "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 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/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..0f12eed758 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns.go @@ -0,0 +1,467 @@ +package main + +import ( + "fmt" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "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/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{}) + return workflowagent.New(workflowagent.Config{ + Name: "fan_in", + Edges: workflow.NewEdgeBuilder(). + AddFanOut(workflow.Start, shout, whisper, mirror). + AddFanIn(gather, shout, whisper, mirror). + Add(gather, merge). + Build(), + }) +} + +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 + 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), + }) +} 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/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..b27cd85847 --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/fan_in.yaml @@ -0,0 +1,19 @@ +apiVersion: adk.declarative/v0.2 +kind: AgentSystem +agents: + - name: fan_in + agent_class: Workflow + 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/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..77346b2bab --- /dev/null +++ b/go/adk/examples/workflowpatterns/patterns/resilient.yaml @@ -0,0 +1,13 @@ +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 } + 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..cc9323df7f --- /dev/null +++ b/go/adk/examples/workflowpatterns/registry.go @@ -0,0 +1,129 @@ +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 := 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/agent/agent.go b/go/adk/pkg/agent/agent.go index 179a9dd5a1..ffe85d3de7 100644 --- a/go/adk/pkg/agent/agent.go +++ b/go/adk/pkg/agent/agent.go @@ -15,6 +15,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" @@ -53,16 +54,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 { @@ -114,7 +130,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 } @@ -132,12 +151,22 @@ 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) + } + child, err := createGoogleADKAgent(ctx, childConfig, childConfig.Name, stsPlugin, runtime, 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) } @@ -224,7 +253,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") @@ -236,7 +265,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) @@ -246,6 +275,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..3cea954d5a --- /dev/null +++ b/go/adk/pkg/agent/isolation_test.go @@ -0,0 +1,209 @@ +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`}, + {"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..06fe1da4a3 --- /dev/null +++ b/go/adk/pkg/declarative/build.go @@ -0,0 +1,274 @@ +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/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 +} + +// 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)), + } + sys := &System{agents: b.built} + 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 { + 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 +} + +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.reg.resolveTools(l.Tools) + if err != nil { + return nil, err + } + subs, err := b.subAgents(subAgents) + if err != nil { + return nil, 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: l.Instruction, + GlobalInstruction: l.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) + } + } + 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 + } + 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 +} diff --git a/go/adk/pkg/declarative/build_test.go b/go/adk/pkg/declarative/build_test.go new file mode 100644 index 0000000000..4743013874 --- /dev/null +++ b/go/adk/pkg/declarative/build_test.go @@ -0,0 +1,585 @@ +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()) +} + +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/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/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/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/nodes.go b/go/adk/pkg/declarative/nodes.go new file mode 100644 index 0000000000..aee156cb6d --- /dev/null +++ b/go/adk/pkg/declarative/nodes.go @@ -0,0 +1,297 @@ +package declarative + +import ( + "encoding/json" + "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: + 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 + } + 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 + } + } + cfg.RetryConfig = rc + } + return cfg, nil +} diff --git a/go/adk/pkg/declarative/registry.go b/go/adk/pkg/declarative/registry.go new file mode 100644 index 0000000000..bad7adf6cd --- /dev/null +++ b/go/adk/pkg/declarative/registry.go @@ -0,0 +1,312 @@ +package declarative + +import ( + "context" + "errors" + "fmt" + "regexp" + + "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 int +} + +// 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) { + if e.MaxIters > 0 && e.runs >= e.MaxIters { + return nil, ErrIterationBudget + } + e.runs++ + 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 e.runs } + +// 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 +} + +// 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{}, + } + 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) +} + +// 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) 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. +func (r *Registry) resolveTools(names []string) ([]tool.Tool, []tool.Toolset, error) { + var tools []tool.Tool + var toolsets []tool.Toolset + for _, name := range names { + if t, ok := r.tools[name]; ok { + tools = append(tools, t) + } else if ts, ok := r.toolsets[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/registry_test.go b/go/adk/pkg/declarative/registry_test.go new file mode 100644 index 0000000000..8a81a6be51 --- /dev/null +++ b/go/adk/pkg/declarative/registry_test.go @@ -0,0 +1,170 @@ +package declarative + +import ( + "context" + "testing" + + "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 := reg.resolveTools([]string{"preload_memory", "load_memory"}) + require.NoError(t, err) + require.Len(t, tools, 2) + require.Empty(t, toolsets) + _, _, err = 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 := 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, runs: 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")) +} 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..e0d43971bd --- /dev/null +++ b/go/adk/pkg/declarative/spec.go @@ -0,0 +1,209 @@ +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" +) + +// 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"` +} + +// 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"` + // GlobalInstruction applies to the whole agent tree below the agent. + GlobalInstruction string `json:"global_instruction,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"` +} + +// 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"` + + // 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"` + + // 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"` + + // 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"` +} + +// 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"` +} 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..3122969f7a --- /dev/null +++ b/go/adk/pkg/declarative/testdata/showcase.yaml @@ -0,0 +1,136 @@ +# 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 + 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..9d42586823 --- /dev/null +++ b/go/adk/pkg/declarative/validate.go @@ -0,0 +1,774 @@ +package declarative + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "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 + 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) + } + 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", "tools", "output_key", "output_schema", "include_contents"} + +var agentRules = map[AgentClass]agentRule{ + ClassWorkflow: {required: []string{"nodes"}, optional: []string{"description", "sub_agents", "entry", "edges"}}, + 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, + }) + return present +} + +func llmPresent(l *LLMSpec) map[string]bool { + return map[string]bool{ + "model": l.Model != "", "mode": l.Mode != "", "instruction": l.Instruction != "", + "global_instruction": l.GlobalInstruction != "", "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: + 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) + } + } + 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.refs != nil && !v.refs.hasToolOrToolset(t): + v.addf(p, "tool %q is not registered", 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{"retry", "timeout"}}, + NodeAgent: {Required: []string{"agent"}, Optional: []string{"input_schema", "retry", "timeout"}}, + NodeLLM: { + Required: []string{"model", "instruction"}, + Optional: []string{"mode", "global_instruction", "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"}}, +} + +// 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 != "", + "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 != "", + }) + 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 !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: + 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) + } + + 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]") + } +} + +// 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 +} diff --git a/go/adk/pkg/declarative/validate_test.go b/go/adk/pkg/declarative/validate_test.go new file mode 100644 index 0000000000..d29e36036a --- /dev/null +++ b/go/adk/pkg/declarative/validate_test.go @@ -0,0 +1,291 @@ +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"}}, + {"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/gen/kagent/config/v1/adk.pb.go b/go/api/gen/kagent/config/v1/adk.pb.go index 3527639f18..e3c40982b8 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. @@ -100,8 +155,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() { @@ -260,6 +321,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"` @@ -2431,6 +2506,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[29] + 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[29] + 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{29} +} + +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"` @@ -2441,7 +2571,7 @@ type ADKAgentPlugins struct { func (x *ADKAgentPlugins) Reset() { *x = ADKAgentPlugins{} - 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) } @@ -2453,7 +2583,7 @@ func (x *ADKAgentPlugins) String() string { func (*ADKAgentPlugins) ProtoMessage() {} func (x *ADKAgentPlugins) 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 { @@ -2466,7 +2596,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{29} + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{30} } func (x *ADKAgentPlugins) GetSkills() []*ADKSkill { @@ -2493,7 +2623,7 @@ type ADKSkill struct { func (x *ADKSkill) Reset() { *x = ADKSkill{} - 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) } @@ -2505,7 +2635,7 @@ func (x *ADKSkill) String() string { func (*ADKSkill) ProtoMessage() {} func (x *ADKSkill) 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 { @@ -2518,7 +2648,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{30} + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{31} } func (x *ADKSkill) GetName() string { @@ -2545,7 +2675,7 @@ type ADKPluginBundle struct { func (x *ADKPluginBundle) Reset() { *x = ADKPluginBundle{} - 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) } @@ -2557,7 +2687,7 @@ func (x *ADKPluginBundle) String() string { func (*ADKPluginBundle) ProtoMessage() {} func (x *ADKPluginBundle) 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 { @@ -2570,7 +2700,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{31} + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{32} } func (x *ADKPluginBundle) GetSource() *ADKPluginSource { @@ -2602,7 +2732,7 @@ type ADKPluginSource struct { func (x *ADKPluginSource) Reset() { *x = ADKPluginSource{} - 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) } @@ -2614,7 +2744,7 @@ func (x *ADKPluginSource) String() string { func (*ADKPluginSource) ProtoMessage() {} func (x *ADKPluginSource) 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 { @@ -2627,7 +2757,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{32} + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{33} } func (x *ADKPluginSource) GetSource() isADKPluginSource_Source { @@ -2703,7 +2833,7 @@ type ADKPluginGitSource struct { func (x *ADKPluginGitSource) Reset() { *x = ADKPluginGitSource{} - 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) } @@ -2715,7 +2845,7 @@ func (x *ADKPluginGitSource) String() string { func (*ADKPluginGitSource) ProtoMessage() {} func (x *ADKPluginGitSource) 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 { @@ -2728,7 +2858,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{33} + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{34} } func (x *ADKPluginGitSource) GetUrl() string { @@ -2758,7 +2888,7 @@ type ADKPluginS3Source struct { func (x *ADKPluginS3Source) Reset() { *x = ADKPluginS3Source{} - 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) } @@ -2770,7 +2900,7 @@ func (x *ADKPluginS3Source) String() string { func (*ADKPluginS3Source) ProtoMessage() {} func (x *ADKPluginS3Source) 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 { @@ -2783,7 +2913,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{34} + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{35} } func (x *ADKPluginS3Source) GetEndpoint() string { @@ -2844,7 +2974,7 @@ type ADKRuntimeConfig struct { func (x *ADKRuntimeConfig) Reset() { *x = ADKRuntimeConfig{} - mi := &file_kagent_config_v1_adk_proto_msgTypes[35] + mi := &file_kagent_config_v1_adk_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2856,7 +2986,7 @@ func (x *ADKRuntimeConfig) String() string { func (*ADKRuntimeConfig) ProtoMessage() {} func (x *ADKRuntimeConfig) ProtoReflect() protoreflect.Message { - mi := &file_kagent_config_v1_adk_proto_msgTypes[35] + mi := &file_kagent_config_v1_adk_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2869,7 +2999,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{35} + return file_kagent_config_v1_adk_proto_rawDescGZIP(), []int{36} } func (x *ADKRuntimeConfig) GetControlPlaneUrl() string { @@ -2956,7 +3086,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\"\xff\a\n" + + "\x05agent\x18\x02 \x01(\v2 .kagent.config.v1.ADKAgentConfigR\x05agent\"\x8d\t\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" + @@ -2980,7 +3110,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_streamB\x0e\n" + "\f_share_tools\"\xa1\t\n" + "\x0eADKModelConfig\x12\x14\n" + @@ -3246,7 +3378,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" + @@ -3288,7 +3423,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 @@ -3302,118 +3441,123 @@ 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, 42) +var file_kagent_config_v1_adk_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_kagent_config_v1_adk_proto_msgTypes = make([]protoimpl.MessageInfo, 43) 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 - (*ADKNetworkConfig)(nil), // 25: kagent.config.v1.ADKNetworkConfig - (*ADKContextConfig)(nil), // 26: kagent.config.v1.ADKContextConfig - (*ADKCompressionConfig)(nil), // 27: kagent.config.v1.ADKCompressionConfig - (*ADKOutputConfig)(nil), // 28: kagent.config.v1.ADKOutputConfig - (*ADKAgentPlugins)(nil), // 29: kagent.config.v1.ADKAgentPlugins - (*ADKSkill)(nil), // 30: kagent.config.v1.ADKSkill - (*ADKPluginBundle)(nil), // 31: kagent.config.v1.ADKPluginBundle - (*ADKPluginSource)(nil), // 32: kagent.config.v1.ADKPluginSource - (*ADKPluginGitSource)(nil), // 33: kagent.config.v1.ADKPluginGitSource - (*ADKPluginS3Source)(nil), // 34: kagent.config.v1.ADKPluginS3Source - (*ADKRuntimeConfig)(nil), // 35: kagent.config.v1.ADKRuntimeConfig - nil, // 36: kagent.config.v1.ADKModelConfig.HeadersEntry - nil, // 37: kagent.config.v1.ADKOllama.OptionsEntry - nil, // 38: kagent.config.v1.ADKHTTPConnectionParams.HeadersEntry - nil, // 39: kagent.config.v1.ADKSSEConnectionParams.HeadersEntry - nil, // 40: kagent.config.v1.ADKStdioMCPServerConfig.EnvEntry - nil, // 41: kagent.config.v1.ADKRemoteAgentConfig.HeadersEntry - (*durationpb.Duration)(nil), // 42: google.protobuf.Duration - (*TokenPropagationConfig)(nil), // 43: kagent.config.v1.TokenPropagationConfig - (*TelemetryConfig)(nil), // 44: kagent.config.v1.TelemetryConfig - (*BuiltinToolsConfig)(nil), // 45: 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 + (*ADKNetworkConfig)(nil), // 26: kagent.config.v1.ADKNetworkConfig + (*ADKContextConfig)(nil), // 27: kagent.config.v1.ADKContextConfig + (*ADKCompressionConfig)(nil), // 28: kagent.config.v1.ADKCompressionConfig + (*ADKOutputConfig)(nil), // 29: kagent.config.v1.ADKOutputConfig + (*ADKWorkflowConfig)(nil), // 30: kagent.config.v1.ADKWorkflowConfig + (*ADKAgentPlugins)(nil), // 31: kagent.config.v1.ADKAgentPlugins + (*ADKSkill)(nil), // 32: kagent.config.v1.ADKSkill + (*ADKPluginBundle)(nil), // 33: kagent.config.v1.ADKPluginBundle + (*ADKPluginSource)(nil), // 34: kagent.config.v1.ADKPluginSource + (*ADKPluginGitSource)(nil), // 35: kagent.config.v1.ADKPluginGitSource + (*ADKPluginS3Source)(nil), // 36: kagent.config.v1.ADKPluginS3Source + (*ADKRuntimeConfig)(nil), // 37: kagent.config.v1.ADKRuntimeConfig + nil, // 38: kagent.config.v1.ADKModelConfig.HeadersEntry + nil, // 39: kagent.config.v1.ADKOllama.OptionsEntry + nil, // 40: kagent.config.v1.ADKHTTPConnectionParams.HeadersEntry + nil, // 41: kagent.config.v1.ADKSSEConnectionParams.HeadersEntry + nil, // 42: kagent.config.v1.ADKStdioMCPServerConfig.EnvEntry + nil, // 43: kagent.config.v1.ADKRemoteAgentConfig.HeadersEntry + (*durationpb.Duration)(nil), // 44: google.protobuf.Duration + (*TokenPropagationConfig)(nil), // 45: kagent.config.v1.TokenPropagationConfig + (*TelemetryConfig)(nil), // 46: kagent.config.v1.TelemetryConfig + (*BuiltinToolsConfig)(nil), // 47: kagent.config.v1.BuiltinToolsConfig } var file_kagent_config_v1_adk_proto_depIdxs = []int32{ - 35, // 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 - 25, // 8: kagent.config.v1.ADKAgentConfig.network:type_name -> kagent.config.v1.ADKNetworkConfig - 29, // 9: kagent.config.v1.ADKAgentConfig.agent_plugins:type_name -> kagent.config.v1.ADKAgentPlugins - 26, // 10: kagent.config.v1.ADKAgentConfig.context_config:type_name -> kagent.config.v1.ADKContextConfig - 1, // 11: kagent.config.v1.ADKAgentConfig.sub_agents:type_name -> kagent.config.v1.ADKAgentConfig - 28, // 12: kagent.config.v1.ADKAgentConfig.output:type_name -> kagent.config.v1.ADKOutputConfig - 36, // 13: kagent.config.v1.ADKModelConfig.headers:type_name -> kagent.config.v1.ADKModelConfig.HeadersEntry - 3, // 14: kagent.config.v1.ADKModelConfig.openai:type_name -> kagent.config.v1.ADKOpenAI - 6, // 15: kagent.config.v1.ADKModelConfig.azure_openai:type_name -> kagent.config.v1.ADKAzureOpenAI - 7, // 16: kagent.config.v1.ADKModelConfig.anthropic:type_name -> kagent.config.v1.ADKAnthropic - 9, // 17: kagent.config.v1.ADKModelConfig.gemini_vertex_ai:type_name -> kagent.config.v1.ADKGeminiVertexAI - 10, // 18: kagent.config.v1.ADKModelConfig.gemini_anthropic:type_name -> kagent.config.v1.ADKGeminiAnthropic - 11, // 19: kagent.config.v1.ADKModelConfig.ollama:type_name -> kagent.config.v1.ADKOllama - 12, // 20: kagent.config.v1.ADKModelConfig.gemini:type_name -> kagent.config.v1.ADKGemini - 13, // 21: kagent.config.v1.ADKModelConfig.bedrock:type_name -> kagent.config.v1.ADKBedrock - 15, // 22: kagent.config.v1.ADKModelConfig.sap_ai_core:type_name -> kagent.config.v1.ADKSAPAICore - 16, // 23: kagent.config.v1.ADKModelConfig.foundry:type_name -> kagent.config.v1.ADKFoundry - 8, // 24: kagent.config.v1.ADKModelConfig.mistral:type_name -> kagent.config.v1.ADKMistral - 42, // 25: kagent.config.v1.ADKOpenAI.timeout:type_name -> google.protobuf.Duration - 4, // 26: kagent.config.v1.ADKOpenAI.token_exchange:type_name -> kagent.config.v1.ADKTokenExchangeConfig - 5, // 27: kagent.config.v1.ADKTokenExchangeConfig.gdch_service_account:type_name -> kagent.config.v1.ADKGDCHTokenExchangeConfig - 42, // 28: kagent.config.v1.ADKAnthropic.timeout:type_name -> google.protobuf.Duration - 42, // 29: kagent.config.v1.ADKMistral.timeout:type_name -> google.protobuf.Duration - 37, // 30: kagent.config.v1.ADKOllama.options:type_name -> kagent.config.v1.ADKOllama.OptionsEntry - 14, // 31: kagent.config.v1.ADKBedrock.guardrail:type_name -> kagent.config.v1.ADKBedrockGuardrail - 42, // 32: kagent.config.v1.ADKBedrock.read_timeout:type_name -> google.protobuf.Duration - 42, // 33: kagent.config.v1.ADKBedrock.connect_timeout:type_name -> google.protobuf.Duration - 38, // 34: kagent.config.v1.ADKHTTPConnectionParams.headers:type_name -> kagent.config.v1.ADKHTTPConnectionParams.HeadersEntry - 42, // 35: kagent.config.v1.ADKHTTPConnectionParams.timeout:type_name -> google.protobuf.Duration - 42, // 36: kagent.config.v1.ADKHTTPConnectionParams.sse_read_timeout:type_name -> google.protobuf.Duration - 39, // 37: kagent.config.v1.ADKSSEConnectionParams.headers:type_name -> kagent.config.v1.ADKSSEConnectionParams.HeadersEntry - 42, // 38: kagent.config.v1.ADKSSEConnectionParams.timeout:type_name -> google.protobuf.Duration - 42, // 39: kagent.config.v1.ADKSSEConnectionParams.sse_read_timeout:type_name -> google.protobuf.Duration - 17, // 40: kagent.config.v1.ADKHTTPMCPServerConfig.params:type_name -> kagent.config.v1.ADKHTTPConnectionParams - 18, // 41: kagent.config.v1.ADKSSEMCPServerConfig.params:type_name -> kagent.config.v1.ADKSSEConnectionParams - 40, // 42: kagent.config.v1.ADKStdioMCPServerConfig.env:type_name -> kagent.config.v1.ADKStdioMCPServerConfig.EnvEntry - 41, // 43: kagent.config.v1.ADKRemoteAgentConfig.headers:type_name -> kagent.config.v1.ADKRemoteAgentConfig.HeadersEntry - 42, // 44: kagent.config.v1.ADKRemoteAgentConfig.timeout:type_name -> google.protobuf.Duration - 23, // 45: kagent.config.v1.ADKMemoryConfig.embedding:type_name -> kagent.config.v1.ADKEmbeddingConfig - 27, // 46: kagent.config.v1.ADKContextConfig.compaction:type_name -> kagent.config.v1.ADKCompressionConfig - 2, // 47: kagent.config.v1.ADKCompressionConfig.summarizer_model:type_name -> kagent.config.v1.ADKModelConfig - 30, // 48: kagent.config.v1.ADKAgentPlugins.skills:type_name -> kagent.config.v1.ADKSkill - 31, // 49: kagent.config.v1.ADKAgentPlugins.plugins:type_name -> kagent.config.v1.ADKPluginBundle - 32, // 50: kagent.config.v1.ADKSkill.source:type_name -> kagent.config.v1.ADKPluginSource - 32, // 51: kagent.config.v1.ADKPluginBundle.source:type_name -> kagent.config.v1.ADKPluginSource - 33, // 52: kagent.config.v1.ADKPluginSource.git:type_name -> kagent.config.v1.ADKPluginGitSource - 34, // 53: kagent.config.v1.ADKPluginSource.s3:type_name -> kagent.config.v1.ADKPluginS3Source - 43, // 54: kagent.config.v1.ADKRuntimeConfig.token_propagation:type_name -> kagent.config.v1.TokenPropagationConfig - 44, // 55: kagent.config.v1.ADKRuntimeConfig.telemetry:type_name -> kagent.config.v1.TelemetryConfig - 45, // 56: kagent.config.v1.ADKRuntimeConfig.builtin_tools:type_name -> kagent.config.v1.BuiltinToolsConfig - 57, // [57:57] is the sub-list for method output_type - 57, // [57:57] is the sub-list for method input_type - 57, // [57:57] is the sub-list for extension type_name - 57, // [57:57] is the sub-list for extension extendee - 0, // [0:57] is the sub-list for field type_name + 37, // 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 + 26, // 8: kagent.config.v1.ADKAgentConfig.network:type_name -> kagent.config.v1.ADKNetworkConfig + 31, // 9: kagent.config.v1.ADKAgentConfig.agent_plugins:type_name -> kagent.config.v1.ADKAgentPlugins + 27, // 10: kagent.config.v1.ADKAgentConfig.context_config:type_name -> kagent.config.v1.ADKContextConfig + 2, // 11: kagent.config.v1.ADKAgentConfig.sub_agents:type_name -> kagent.config.v1.ADKAgentConfig + 29, // 12: kagent.config.v1.ADKAgentConfig.output:type_name -> kagent.config.v1.ADKOutputConfig + 30, // 13: kagent.config.v1.ADKAgentConfig.workflow:type_name -> kagent.config.v1.ADKWorkflowConfig + 0, // 14: kagent.config.v1.ADKAgentConfig.isolation:type_name -> kagent.config.v1.ADKAgentIsolation + 38, // 15: kagent.config.v1.ADKModelConfig.headers:type_name -> kagent.config.v1.ADKModelConfig.HeadersEntry + 4, // 16: kagent.config.v1.ADKModelConfig.openai:type_name -> kagent.config.v1.ADKOpenAI + 7, // 17: kagent.config.v1.ADKModelConfig.azure_openai:type_name -> kagent.config.v1.ADKAzureOpenAI + 8, // 18: kagent.config.v1.ADKModelConfig.anthropic:type_name -> kagent.config.v1.ADKAnthropic + 10, // 19: kagent.config.v1.ADKModelConfig.gemini_vertex_ai:type_name -> kagent.config.v1.ADKGeminiVertexAI + 11, // 20: kagent.config.v1.ADKModelConfig.gemini_anthropic:type_name -> kagent.config.v1.ADKGeminiAnthropic + 12, // 21: kagent.config.v1.ADKModelConfig.ollama:type_name -> kagent.config.v1.ADKOllama + 13, // 22: kagent.config.v1.ADKModelConfig.gemini:type_name -> kagent.config.v1.ADKGemini + 14, // 23: kagent.config.v1.ADKModelConfig.bedrock:type_name -> kagent.config.v1.ADKBedrock + 16, // 24: kagent.config.v1.ADKModelConfig.sap_ai_core:type_name -> kagent.config.v1.ADKSAPAICore + 17, // 25: kagent.config.v1.ADKModelConfig.foundry:type_name -> kagent.config.v1.ADKFoundry + 9, // 26: kagent.config.v1.ADKModelConfig.mistral:type_name -> kagent.config.v1.ADKMistral + 44, // 27: kagent.config.v1.ADKOpenAI.timeout:type_name -> google.protobuf.Duration + 5, // 28: kagent.config.v1.ADKOpenAI.token_exchange:type_name -> kagent.config.v1.ADKTokenExchangeConfig + 6, // 29: kagent.config.v1.ADKTokenExchangeConfig.gdch_service_account:type_name -> kagent.config.v1.ADKGDCHTokenExchangeConfig + 44, // 30: kagent.config.v1.ADKAnthropic.timeout:type_name -> google.protobuf.Duration + 44, // 31: kagent.config.v1.ADKMistral.timeout:type_name -> google.protobuf.Duration + 39, // 32: kagent.config.v1.ADKOllama.options:type_name -> kagent.config.v1.ADKOllama.OptionsEntry + 15, // 33: kagent.config.v1.ADKBedrock.guardrail:type_name -> kagent.config.v1.ADKBedrockGuardrail + 44, // 34: kagent.config.v1.ADKBedrock.read_timeout:type_name -> google.protobuf.Duration + 44, // 35: kagent.config.v1.ADKBedrock.connect_timeout:type_name -> google.protobuf.Duration + 40, // 36: kagent.config.v1.ADKHTTPConnectionParams.headers:type_name -> kagent.config.v1.ADKHTTPConnectionParams.HeadersEntry + 44, // 37: kagent.config.v1.ADKHTTPConnectionParams.timeout:type_name -> google.protobuf.Duration + 44, // 38: kagent.config.v1.ADKHTTPConnectionParams.sse_read_timeout:type_name -> google.protobuf.Duration + 41, // 39: kagent.config.v1.ADKSSEConnectionParams.headers:type_name -> kagent.config.v1.ADKSSEConnectionParams.HeadersEntry + 44, // 40: kagent.config.v1.ADKSSEConnectionParams.timeout:type_name -> google.protobuf.Duration + 44, // 41: kagent.config.v1.ADKSSEConnectionParams.sse_read_timeout:type_name -> google.protobuf.Duration + 18, // 42: kagent.config.v1.ADKHTTPMCPServerConfig.params:type_name -> kagent.config.v1.ADKHTTPConnectionParams + 19, // 43: kagent.config.v1.ADKSSEMCPServerConfig.params:type_name -> kagent.config.v1.ADKSSEConnectionParams + 42, // 44: kagent.config.v1.ADKStdioMCPServerConfig.env:type_name -> kagent.config.v1.ADKStdioMCPServerConfig.EnvEntry + 43, // 45: kagent.config.v1.ADKRemoteAgentConfig.headers:type_name -> kagent.config.v1.ADKRemoteAgentConfig.HeadersEntry + 44, // 46: kagent.config.v1.ADKRemoteAgentConfig.timeout:type_name -> google.protobuf.Duration + 24, // 47: kagent.config.v1.ADKMemoryConfig.embedding:type_name -> kagent.config.v1.ADKEmbeddingConfig + 28, // 48: kagent.config.v1.ADKContextConfig.compaction:type_name -> kagent.config.v1.ADKCompressionConfig + 3, // 49: kagent.config.v1.ADKCompressionConfig.summarizer_model:type_name -> kagent.config.v1.ADKModelConfig + 32, // 50: kagent.config.v1.ADKAgentPlugins.skills:type_name -> kagent.config.v1.ADKSkill + 33, // 51: kagent.config.v1.ADKAgentPlugins.plugins:type_name -> kagent.config.v1.ADKPluginBundle + 34, // 52: kagent.config.v1.ADKSkill.source:type_name -> kagent.config.v1.ADKPluginSource + 34, // 53: kagent.config.v1.ADKPluginBundle.source:type_name -> kagent.config.v1.ADKPluginSource + 35, // 54: kagent.config.v1.ADKPluginSource.git:type_name -> kagent.config.v1.ADKPluginGitSource + 36, // 55: kagent.config.v1.ADKPluginSource.s3:type_name -> kagent.config.v1.ADKPluginS3Source + 45, // 56: kagent.config.v1.ADKRuntimeConfig.token_propagation:type_name -> kagent.config.v1.TokenPropagationConfig + 46, // 57: kagent.config.v1.ADKRuntimeConfig.telemetry:type_name -> kagent.config.v1.TelemetryConfig + 47, // 58: kagent.config.v1.ADKRuntimeConfig.builtin_tools:type_name -> kagent.config.v1.BuiltinToolsConfig + 59, // [59:59] is the sub-list for method output_type + 59, // [59:59] is the sub-list for method input_type + 59, // [59:59] is the sub-list for extension type_name + 59, // [59:59] is the sub-list for extension extendee + 0, // [0:59] is the sub-list for field type_name } func init() { file_kagent_config_v1_adk_proto_init() } @@ -3449,24 +3593,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[27].OneofWrappers = []any{} - file_kagent_config_v1_adk_proto_msgTypes[32].OneofWrappers = []any{ + file_kagent_config_v1_adk_proto_msgTypes[33].OneofWrappers = []any{ (*ADKPluginSource_Git)(nil), (*ADKPluginSource_S3)(nil), (*ADKPluginSource_Directory)(nil), } - file_kagent_config_v1_adk_proto_msgTypes[35].OneofWrappers = []any{} + file_kagent_config_v1_adk_proto_msgTypes[36].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: 42, + NumEnums: 1, + NumMessages: 43, 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 8909b8c922..51008769bf 100644 --- a/go/api/v1alpha3/agenttemplate_types.go +++ b/go/api/v1alpha3/agenttemplate_types.go @@ -65,6 +65,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 @@ -78,6 +95,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"` @@ -171,6 +194,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. @@ -184,6 +208,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 49900ef031..5906ca57c1 100644 --- a/go/api/v1alpha3/configuration_crd_cel_test.go +++ b/go/api/v1alpha3/configuration_crd_cel_test.go @@ -316,6 +316,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{{ @@ -592,6 +641,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 0179022d34..23408c072d 100644 --- a/go/api/v1alpha3/zz_generated.deepcopy.go +++ b/go/api/v1alpha3/zz_generated.deepcopy.go @@ -287,6 +287,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 e52854360f..c37c35d851 100644 --- a/go/core/internal/translator/adkconfig/builder.go +++ b/go/core/internal/translator/adkconfig/builder.go @@ -86,6 +86,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" @@ -145,6 +148,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.Models = append(result.Models, child.Models...) result.Templates = append(result.Templates, child.Templates...) @@ -173,6 +180,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 5e9ffb87ef..999621c5a0 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 3c6eb56289..3984d83783 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) { diff --git a/go/core/internal/translator/compiler.go b/go/core/internal/translator/compiler.go index 8a430fdbf6..b0d489d2ca 100644 --- a/go/core/internal/translator/compiler.go +++ b/go/core/internal/translator/compiler.go @@ -57,6 +57,7 @@ type resolvedTemplate struct { type resolvedTemplateBinding struct { Name string Description string + Isolation v1alpha3.SubAgentIsolation Agent *resolvedTemplate } @@ -67,6 +68,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. @@ -88,6 +92,7 @@ type ResolvedMCPTool struct { type AgentInputBinding struct { Name string Description string + Isolation v1alpha3.SubAgentIsolation Agent *AgentInput } @@ -217,6 +222,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 { @@ -242,7 +250,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 } @@ -262,6 +270,13 @@ 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)) } + 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)) + } var build func(*resolvedTemplate) (*AgentInput, error) build = func(agent *resolvedTemplate) (*AgentInput, error) { template := agent.Template @@ -323,7 +338,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 +350,5 @@ func (c *Compiler) buildInputs(ctx context.Context, tree *resolvedTemplateTree) if err != nil { return nil, err } - return &HarnessInput{Harness: tree.Harness, Root: root, OutputSchema: outputSchema}, nil + 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 25f6f8ae4b..3e5e83bcb9 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..57b55f4f52 --- /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 := &corev1.LocalObjectReference{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.TypedLocalObjectReference{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..fad3f299a2 --- /dev/null +++ b/go/core/internal/translator/workflow.go @@ -0,0 +1,68 @@ +package translator + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + "github.com/kagent-dev/kagent/go/adk/pkg/declarative" +) + +// 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. +type ResolvedWorkflow struct { + Document []byte + SHA256 string + Source *ResolvedConfigMapKeyReference +} + +// 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 + } + canonical, err := validateWorkflow(raw) + if err != nil { + return nil, NewValidationError("invalid workflow: %v", err) + } + digest := sha256.Sum256(canonical) + return &ResolvedWorkflow{Document: canonical, SHA256: hex.EncodeToString(digest[:]), Source: source}, nil +} + +// validateWorkflow checks a document with the same structural rules the Go ADK +// applies before building it, 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) ([]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 + } + 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..d7f45efb7b --- /dev/null +++ b/go/core/internal/translator/workflow_compile_test.go @@ -0,0 +1,178 @@ +package translator_test + +import ( + "errors" + "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`) +} diff --git a/go/core/internal/translator/workflow_test.go b/go/core/internal/translator/workflow_test.go new file mode 100644 index 0000000000..5dee761b30 --- /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)) + 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)) + require.NoError(t, err) + // The canonical form is itself a valid document with the same canonical form. + again, err := validateWorkflow(yamlForm) + 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)) + 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..40bab9cba1 --- /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.TypedLocalObjectReference{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/helm/kagent-crds/templates/api.kagent.dev_agents.yaml b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml index 1eacf239fc..5c1c249256 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml @@ -448,6 +448,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 @@ -482,8 +492,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 f55e21ef8b..08bd095f81 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml @@ -378,6 +378,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 @@ -410,8 +420,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 2ced6acb53..243f2ded0c 100644 --- a/proto/kagent/config/v1/adk.proto +++ b/proto/kagent/config/v1/adk.proto @@ -37,6 +37,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. @@ -358,6 +376,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 694719b80d..a3d202b0e1 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\"\xff\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<\n\x07network\x18\x0b \x01(\x0b\x32\".kagent.config.v1.ADKNetworkConfigR\x07network\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\x0bshare_tools\x18\x0e \x01(\x08H\x01R\nshareTools\x88\x01\x01\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_streamB\x0e\n\x0c_share_tools\"\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\"a\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\x42\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\";\n\x10\x41\x44KNetworkConfig\x12\'\n\x0f\x61llowed_domains\x18\x01 \x03(\tR\x0e\x61llowedDomains\"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\"\x8d\t\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<\n\x07network\x18\x0b \x01(\x0b\x32\".kagent.config.v1.ADKNetworkConfigR\x07network\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\x0bshare_tools\x18\x0e \x01(\x08H\x01R\nshareTools\x88\x01\x01\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_streamB\x0e\n\x0c_share_tools\"\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\"a\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\x42\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\";\n\x10\x41\x44KNetworkConfig\x12\'\n\x0f\x61llowed_domains\x18\x01 \x03(\tR\x0e\x61llowedDomains\"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,88 +159,92 @@ _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=11795 + _globals['_ADKAGENTISOLATION']._serialized_end=11916 _globals['_ADKCONFIG']._serialized_start=141 _globals['_ADKCONFIG']._serialized_end=270 _globals['_ADKAGENTCONFIG']._serialized_start=273 - _globals['_ADKAGENTCONFIG']._serialized_end=1296 - _globals['_ADKMODELCONFIG']._serialized_start=1299 - _globals['_ADKMODELCONFIG']._serialized_end=2484 - _globals['_ADKMODELCONFIG_HEADERSENTRY']._serialized_start=2330 - _globals['_ADKMODELCONFIG_HEADERSENTRY']._serialized_end=2388 - _globals['_ADKOPENAI']._serialized_start=2487 - _globals['_ADKOPENAI']._serialized_end=3567 - _globals['_ADKTOKENEXCHANGECONFIG']._serialized_start=3570 - _globals['_ADKTOKENEXCHANGECONFIG']._serialized_end=3709 - _globals['_ADKGDCHTOKENEXCHANGECONFIG']._serialized_start=3711 - _globals['_ADKGDCHTOKENEXCHANGECONFIG']._serialized_end=3835 - _globals['_ADKAZUREOPENAI']._serialized_start=3838 - _globals['_ADKAZUREOPENAI']._serialized_end=4266 - _globals['_ADKANTHROPIC']._serialized_start=4269 - _globals['_ADKANTHROPIC']._serialized_end=4712 - _globals['_ADKMISTRAL']._serialized_start=4715 - _globals['_ADKMISTRAL']._serialized_end=5026 - _globals['_ADKGEMINIVERTEXAI']._serialized_start=5028 - _globals['_ADKGEMINIVERTEXAI']._serialized_end=5133 - _globals['_ADKGEMINIANTHROPIC']._serialized_start=5135 - _globals['_ADKGEMINIANTHROPIC']._serialized_end=5155 - _globals['_ADKOLLAMA']._serialized_start=5158 - _globals['_ADKOLLAMA']._serialized_end=5297 - _globals['_ADKOLLAMA_OPTIONSENTRY']._serialized_start=5239 - _globals['_ADKOLLAMA_OPTIONSENTRY']._serialized_end=5297 - _globals['_ADKGEMINI']._serialized_start=5299 - _globals['_ADKGEMINI']._serialized_end=5396 - _globals['_ADKBEDROCK']._serialized_start=5399 - _globals['_ADKBEDROCK']._serialized_end=5826 - _globals['_ADKBEDROCKGUARDRAIL']._serialized_start=5828 - _globals['_ADKBEDROCKGUARDRAIL']._serialized_end=5929 - _globals['_ADKSAPAICORE']._serialized_start=5931 - _globals['_ADKSAPAICORE']._serialized_end=6038 - _globals['_ADKFOUNDRY']._serialized_start=6041 - _globals['_ADKFOUNDRY']._serialized_end=6255 - _globals['_ADKHTTPCONNECTIONPARAMS']._serialized_start=6258 - _globals['_ADKHTTPCONNECTIONPARAMS']._serialized_end=6918 - _globals['_ADKHTTPCONNECTIONPARAMS_HEADERSENTRY']._serialized_start=2330 - _globals['_ADKHTTPCONNECTIONPARAMS_HEADERSENTRY']._serialized_end=2388 - _globals['_ADKSSECONNECTIONPARAMS']._serialized_start=6921 - _globals['_ADKSSECONNECTIONPARAMS']._serialized_end=7505 - _globals['_ADKSSECONNECTIONPARAMS_HEADERSENTRY']._serialized_start=2330 - _globals['_ADKSSECONNECTIONPARAMS_HEADERSENTRY']._serialized_end=2388 - _globals['_ADKHTTPMCPSERVERCONFIG']._serialized_start=7508 - _globals['_ADKHTTPMCPSERVERCONFIG']._serialized_end=7758 - _globals['_ADKSSEMCPSERVERCONFIG']._serialized_start=7761 - _globals['_ADKSSEMCPSERVERCONFIG']._serialized_end=8009 - _globals['_ADKSTDIOMCPSERVERCONFIG']._serialized_start=8012 - _globals['_ADKSTDIOMCPSERVERCONFIG']._serialized_end=8227 - _globals['_ADKSTDIOMCPSERVERCONFIG_ENVENTRY']._serialized_start=8173 - _globals['_ADKSTDIOMCPSERVERCONFIG_ENVENTRY']._serialized_end=8227 - _globals['_ADKREMOTEAGENTCONFIG']._serialized_start=8230 - _globals['_ADKREMOTEAGENTCONFIG']._serialized_end=8577 - _globals['_ADKREMOTEAGENTCONFIG_HEADERSENTRY']._serialized_start=2330 - _globals['_ADKREMOTEAGENTCONFIG_HEADERSENTRY']._serialized_end=2388 - _globals['_ADKEMBEDDINGCONFIG']._serialized_start=8580 - _globals['_ADKEMBEDDINGCONFIG']._serialized_end=9061 - _globals['_ADKMEMORYCONFIG']._serialized_start=9063 - _globals['_ADKMEMORYCONFIG']._serialized_end=9188 - _globals['_ADKNETWORKCONFIG']._serialized_start=9190 - _globals['_ADKNETWORKCONFIG']._serialized_end=9249 - _globals['_ADKCONTEXTCONFIG']._serialized_start=9251 - _globals['_ADKCONTEXTCONFIG']._serialized_end=9341 - _globals['_ADKCOMPRESSIONCONFIG']._serialized_start=9344 - _globals['_ADKCOMPRESSIONCONFIG']._serialized_end=9825 - _globals['_ADKOUTPUTCONFIG']._serialized_start=9827 - _globals['_ADKOUTPUTCONFIG']._serialized_end=9901 - _globals['_ADKAGENTPLUGINS']._serialized_start=9904 - _globals['_ADKAGENTPLUGINS']._serialized_end=10034 - _globals['_ADKSKILL']._serialized_start=10036 - _globals['_ADKSKILL']._serialized_end=10125 - _globals['_ADKPLUGINBUNDLE']._serialized_start=10127 - _globals['_ADKPLUGINBUNDLE']._serialized_end=10227 - _globals['_ADKPLUGINSOURCE']._serialized_start=10230 - _globals['_ADKPLUGINSOURCE']._serialized_end=10429 - _globals['_ADKPLUGINGITSOURCE']._serialized_start=10431 - _globals['_ADKPLUGINGITSOURCE']._serialized_end=10493 - _globals['_ADKPLUGINS3SOURCE']._serialized_start=10496 - _globals['_ADKPLUGINS3SOURCE']._serialized_end=10640 - _globals['_ADKRUNTIMECONFIG']._serialized_start=10643 - _globals['_ADKRUNTIMECONFIG']._serialized_end=11542 + _globals['_ADKAGENTCONFIG']._serialized_end=1438 + _globals['_ADKMODELCONFIG']._serialized_start=1441 + _globals['_ADKMODELCONFIG']._serialized_end=2626 + _globals['_ADKMODELCONFIG_HEADERSENTRY']._serialized_start=2472 + _globals['_ADKMODELCONFIG_HEADERSENTRY']._serialized_end=2530 + _globals['_ADKOPENAI']._serialized_start=2629 + _globals['_ADKOPENAI']._serialized_end=3709 + _globals['_ADKTOKENEXCHANGECONFIG']._serialized_start=3712 + _globals['_ADKTOKENEXCHANGECONFIG']._serialized_end=3851 + _globals['_ADKGDCHTOKENEXCHANGECONFIG']._serialized_start=3853 + _globals['_ADKGDCHTOKENEXCHANGECONFIG']._serialized_end=3977 + _globals['_ADKAZUREOPENAI']._serialized_start=3980 + _globals['_ADKAZUREOPENAI']._serialized_end=4408 + _globals['_ADKANTHROPIC']._serialized_start=4411 + _globals['_ADKANTHROPIC']._serialized_end=4854 + _globals['_ADKMISTRAL']._serialized_start=4857 + _globals['_ADKMISTRAL']._serialized_end=5168 + _globals['_ADKGEMINIVERTEXAI']._serialized_start=5170 + _globals['_ADKGEMINIVERTEXAI']._serialized_end=5275 + _globals['_ADKGEMINIANTHROPIC']._serialized_start=5277 + _globals['_ADKGEMINIANTHROPIC']._serialized_end=5297 + _globals['_ADKOLLAMA']._serialized_start=5300 + _globals['_ADKOLLAMA']._serialized_end=5439 + _globals['_ADKOLLAMA_OPTIONSENTRY']._serialized_start=5381 + _globals['_ADKOLLAMA_OPTIONSENTRY']._serialized_end=5439 + _globals['_ADKGEMINI']._serialized_start=5441 + _globals['_ADKGEMINI']._serialized_end=5538 + _globals['_ADKBEDROCK']._serialized_start=5541 + _globals['_ADKBEDROCK']._serialized_end=5968 + _globals['_ADKBEDROCKGUARDRAIL']._serialized_start=5970 + _globals['_ADKBEDROCKGUARDRAIL']._serialized_end=6071 + _globals['_ADKSAPAICORE']._serialized_start=6073 + _globals['_ADKSAPAICORE']._serialized_end=6180 + _globals['_ADKFOUNDRY']._serialized_start=6183 + _globals['_ADKFOUNDRY']._serialized_end=6397 + _globals['_ADKHTTPCONNECTIONPARAMS']._serialized_start=6400 + _globals['_ADKHTTPCONNECTIONPARAMS']._serialized_end=7060 + _globals['_ADKHTTPCONNECTIONPARAMS_HEADERSENTRY']._serialized_start=2472 + _globals['_ADKHTTPCONNECTIONPARAMS_HEADERSENTRY']._serialized_end=2530 + _globals['_ADKSSECONNECTIONPARAMS']._serialized_start=7063 + _globals['_ADKSSECONNECTIONPARAMS']._serialized_end=7647 + _globals['_ADKSSECONNECTIONPARAMS_HEADERSENTRY']._serialized_start=2472 + _globals['_ADKSSECONNECTIONPARAMS_HEADERSENTRY']._serialized_end=2530 + _globals['_ADKHTTPMCPSERVERCONFIG']._serialized_start=7650 + _globals['_ADKHTTPMCPSERVERCONFIG']._serialized_end=7900 + _globals['_ADKSSEMCPSERVERCONFIG']._serialized_start=7903 + _globals['_ADKSSEMCPSERVERCONFIG']._serialized_end=8151 + _globals['_ADKSTDIOMCPSERVERCONFIG']._serialized_start=8154 + _globals['_ADKSTDIOMCPSERVERCONFIG']._serialized_end=8369 + _globals['_ADKSTDIOMCPSERVERCONFIG_ENVENTRY']._serialized_start=8315 + _globals['_ADKSTDIOMCPSERVERCONFIG_ENVENTRY']._serialized_end=8369 + _globals['_ADKREMOTEAGENTCONFIG']._serialized_start=8372 + _globals['_ADKREMOTEAGENTCONFIG']._serialized_end=8719 + _globals['_ADKREMOTEAGENTCONFIG_HEADERSENTRY']._serialized_start=2472 + _globals['_ADKREMOTEAGENTCONFIG_HEADERSENTRY']._serialized_end=2530 + _globals['_ADKEMBEDDINGCONFIG']._serialized_start=8722 + _globals['_ADKEMBEDDINGCONFIG']._serialized_end=9203 + _globals['_ADKMEMORYCONFIG']._serialized_start=9205 + _globals['_ADKMEMORYCONFIG']._serialized_end=9330 + _globals['_ADKNETWORKCONFIG']._serialized_start=9332 + _globals['_ADKNETWORKCONFIG']._serialized_end=9391 + _globals['_ADKCONTEXTCONFIG']._serialized_start=9393 + _globals['_ADKCONTEXTCONFIG']._serialized_end=9483 + _globals['_ADKCOMPRESSIONCONFIG']._serialized_start=9486 + _globals['_ADKCOMPRESSIONCONFIG']._serialized_end=9967 + _globals['_ADKOUTPUTCONFIG']._serialized_start=9969 + _globals['_ADKOUTPUTCONFIG']._serialized_end=10043 + _globals['_ADKWORKFLOWCONFIG']._serialized_start=10045 + _globals['_ADKWORKFLOWCONFIG']._serialized_end=10152 + _globals['_ADKAGENTPLUGINS']._serialized_start=10155 + _globals['_ADKAGENTPLUGINS']._serialized_end=10285 + _globals['_ADKSKILL']._serialized_start=10287 + _globals['_ADKSKILL']._serialized_end=10376 + _globals['_ADKPLUGINBUNDLE']._serialized_start=10378 + _globals['_ADKPLUGINBUNDLE']._serialized_end=10478 + _globals['_ADKPLUGINSOURCE']._serialized_start=10481 + _globals['_ADKPLUGINSOURCE']._serialized_end=10680 + _globals['_ADKPLUGINGITSOURCE']._serialized_start=10682 + _globals['_ADKPLUGINGITSOURCE']._serialized_end=10744 + _globals['_ADKPLUGINS3SOURCE']._serialized_start=10747 + _globals['_ADKPLUGINS3SOURCE']._serialized_end=10891 + _globals['_ADKRUNTIMECONFIG']._serialized_start=10894 + _globals['_ADKRUNTIMECONFIG']._serialized_end=11793 # @@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 333c4c5a31..ef33a55dbd 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", "network", "agent_plugins", "context_config", "share_tools", "session_db_url", "skills_directory", "sub_agents", "output") + __slots__ = ("name", "model", "description", "instruction", "http_tools", "sse_tools", "stdio_tools", "remote_agents", "stream", "memory", "network", "agent_plugins", "context_config", "share_tools", "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] @@ -39,6 +49,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 @@ -57,7 +69,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]] = ..., network: _Optional[_Union[ADKNetworkConfig, _Mapping]] = ..., agent_plugins: _Optional[_Union[ADKAgentPlugins, _Mapping]] = ..., context_config: _Optional[_Union[ADKContextConfig, _Mapping]] = ..., share_tools: _Optional[bool] = ..., 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]] = ..., network: _Optional[_Union[ADKNetworkConfig, _Mapping]] = ..., agent_plugins: _Optional[_Union[ADKAgentPlugins, _Mapping]] = ..., context_config: _Optional[_Union[ADKContextConfig, _Mapping]] = ..., share_tools: _Optional[bool] = ..., 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") @@ -475,6 +489,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/domain/agentTemplates.ts b/ui/src/api/domain/agentTemplates.ts index d68477c251..69368df4c7 100644 --- a/ui/src/api/domain/agentTemplates.ts +++ b/ui/src/api/domain/agentTemplates.ts @@ -24,11 +24,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; } @@ -98,6 +108,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 b7b1053d65..890ae6d939 100644 --- a/ui/src/components/agent-template-form/AgentTemplateForm.tsx +++ b/ui/src/components/agent-template-form/AgentTemplateForm.tsx @@ -516,7 +516,7 @@ export function AgentTemplateForm({ {/* Tools — sub-agents */} {readOnly && draft.subAgentTools.length === 0 @@ -558,6 +558,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 : (