# Agent Composition

Learn how to compose agents using agent-as-tool patterns, run trees, and streaming topology.

Source: https://goa.design/docs/2-goa-ai/agent-composition/

Relative links resolve against the source URL above.


This guide demonstrates how to compose agents by treating one agent as a tool of another, and explains how Goa-AI models agent runs as a tree with streaming projections for different audiences.

## What You'll Build

- A planning agent that exports planning tools
- An orchestrator agent that uses the planning agent's tools
- Cross-process composition with inline execution

---

## Designing Composed Agents

Create `design/design.go`:

```go
package design

import (
    . "goa.design/goa/v3/dsl"
    . "goa.design/goa-ai/dsl"
)

var _ = API("orchestrator", func() {})

var PlanRequest = Type("PlanRequest", func() {
    Attribute("goal", String, "Goal to plan for")
    Required("goal")
})

var PlanResult = Type("PlanResult", func() {
    Attribute("plan", String, "Generated plan")
    Required("plan")
})

var _ = Service("orchestrator", func() {
    // Planning agent that exports tools
    Agent("planner", "Planning agent", func() {
        Export("planning.tools", func() {
            Tool("create_plan", "Create a plan", func() {
                Args(PlanRequest)
                Return(PlanResult)
            })
        })
        RunPolicy(func() {
            DefaultCaps(MaxToolCalls(5))
            TimeBudget("1m")
        })
    })
    
    // Orchestrator agent that uses planning tools
    Agent("orchestrator", "Orchestration agent", func() {
        Use(AgentToolset("orchestrator", "planner", "planning.tools"))
        RunPolicy(func() {
            DefaultCaps(MaxToolCalls(10))
            TimeBudget("5m")
        })
    })
})
```

Generate code:

```bash
goa gen example.com/tutorial/design
```

---

## Implementing Planners

The generated code provides helpers for both agents. Wire them together:

```go
package main

import (
    "context"
    
    planner "example.com/tutorial/gen/orchestrator/agents/planner"
    orchestrator "example.com/tutorial/gen/orchestrator/agents/orchestrator"
    "goa.design/goa-ai/runtime/agent/runtime"
    storageinmem "goa.design/goa-ai/runtime/agent/storage/inmem"
)

func main() {
    rt := runtime.New(storageinmem.New())
    ctx := context.Background()
    
    // Register planning agent
    if err := planner.RegisterPlannerAgent(ctx, rt, planner.PlannerAgentConfig{
        Planner: &PlanningPlanner{},
    }); err != nil {
        panic(err)
    }
    
    // Register orchestrator agent (automatically uses planning tools)
    if err := orchestrator.RegisterOrchestratorAgent(ctx, rt, orchestrator.OrchestratorAgentConfig{
        Planner: &OrchestratorPlanner{},
    }); err != nil {
        panic(err)
    }
    
    // Use orchestrator agent
    client := orchestrator.NewClient(rt)
    // ... run agent ...
}
```

**Key Concepts:**

- **Export**: Declares toolsets that other agents can use
- **AgentToolset**: References an exported toolset from another agent
- **Inline Execution**: From the caller's perspective, an agent-as-tool behaves like a normal tool call; the runtime runs the provider agent as a child run and aggregates its output into a single `ToolResult` (with a `RunLink` back to the child run)
- **Cross-Process**: Agents can execute on different workers while maintaining a coherent run tree; `child_run_linked` stream events and run handles link parent tool calls to child agent runs for streaming and observability

---

## Generated Agent Definitions

Code generation emits one immutable `AgentDefinition` for each agent. That
definition owns the workflow name, default task queue, tool contracts, required
labels, completion policy, and definitions of every child agent reachable
through agent-backed tools.

Generated callers and generated worker registration helpers use the same
definition. Handwritten code supplies the planner, tool executors, and activity
settings; it must not repeat the route, queue, child agent ID, child tool
contracts, or required labels. This keeps caller validation, worker
registration, and continuation validation on one generated contract. A caller
may still use `WithTaskQueue` to select another queue for one explicit start.

---

## Dynamically configured Agents {#dynamic-agent-tools}

The dynamic Agent APIs described here require Goa-AI v0.84.0 or later.

A saved Agent configuration can become a tool without registering a new worker
for each configuration. The registry stores the tool's contract, the ID of an
existing executor, and an immutable configuration reference. The runtime invokes
that executor as a child workflow, with the usual progress, cancellation, and
human-input handling.

### Register a saved configuration

Each `genregistry.ToolSchema` must have `ConsumerContract.Kind: "agent"`, a
complete input and result contract, and an `AgentToolTarget`:

```go
&genregistry.AgentToolTarget{
    Executor:      "generic.agent",
    Configuration: "support/revisions/7",
}
```

The application owns the referenced prompt, model choice, and tool policy.
Keep that revision available until accepted calls have prepared their child.
The model supplies domain arguments; it does not choose the worker or revision.

Register the complete declarations through the generated registry client:

```go
registered, err := registryClient.RegisterAgentToolset(ctx,
    &genregistry.AgentToolsetDeclaration{
        Name:  "support",
        Tools: declarations,
    },
)
if err != nil {
    return err
}
```

Repeating an identical active registration succeeds. To change it, pass the
current `registered.RegistrationToken` as `ExpectedRegistrationToken` in
`*genregistry.ReplaceAgentToolsetPayload`. A stale token returns
`admission_conflict`. `Unregister` removes the declaration from discovery;
`ReplaceAgentToolset` can reactivate it using its current token.

These native Agent tools need no Pulse provider lease or health ping.
Registration does not start or deploy the worker. `CallTool` and
`CallResolvedTool` reject native Agent tools; the consuming runtime starts the
child workflow. Service and native Agent registrations cannot overwrite one
another.

### Prepare the child on an allowed worker

An Agent definition permits its own worker and its generated child workers.
To permit an additional executor:

```go
executor := genspecialist.Definition()
consumer := genassistant.Definition().WithAgentExecutors(&executor)
if err := rt.RegisterAgentToolResolver(executor.Route().ID, prepareConfiguration); err != nil {
    return err
}
```

`WithAgentExecutors` accepts pointers, copies the supplied definitions, and
returns a new definition. Use `consumer` both in `AgentRegistration.Definition`
on the consuming worker and in `rt.ClientFor(consumer)`. Generated registration
and client helpers continue using their original definition. The declaration's
`Executor` must match an allowed worker ID; other targets fail discovery before
the model is called.

Here `prepareConfiguration` is application code with the signature
`func(context.Context, string, *runtime.ToolCall) (*runtime.AgentToolConfiguration, error)`.
Register it on the consuming runtime before sealing or starting runs. It receives
the selected configuration reference and a copy of the validated call. It returns
`Messages`, `Labels`, `Policy`, and optional `RenderedPrompts`. Labels extend or
replace inherited parent labels; the application owns authorization and scope.
The runtime owns session and run IDs and parent links.

Preparation runs in an activity whose recorded result is reused during workflow
replay. After a request for human input, continuation restores messages, labels,
policy, and the selected parent contract from the child's checkpoint rather than
loading the configuration again.

### Return the selected result

`planner.PlanInput.ParentTool` and `planner.PlanResumeInput.ParentTool` expose
the accepted parent contract. A generic planner can use `ParentTool.Result`
with `model.StructuredOutput` for its final model request and return
`planner.FinalToolResult`. Structured output and tool calls use separate model
requests. A native child that returns only conversation text is rejected.
Top-level runs have no `ParentTool`; compiled Agent tools keep their existing
result behavior.

A later planning activity discovers new registrations. Replacing a declaration
cannot change an accepted call's configuration, result schema, or pending
approval. The returned tool result retains its link to the child run.

### Upgrade together before publishing

Upgrade the registry, executor workers, and every consumer that can discover
native declarations before publishing them. Older consumers reject them during
discovery. Existing service fingerprints, provider messages, and stored service
records remain unchanged. Older registries cannot read native records, including
retired ones; older workers cannot restore native child checkpoints. Rollback
requires removing those records and finishing their runs first.

---

## Passthrough: Deterministic Tool Forwarding

For exported tools that should bypass the planner entirely and forward directly to a service method, use `Passthrough`. This is useful when:

- You want deterministic, predictable behavior (no LLM decision-making)
- The tool is a simple wrapper around an existing service method
- You need guaranteed latency without planner overhead

### When to Use Passthrough vs Normal Execution

| Scenario | Use Passthrough | Use Normal Execution |
|----------|-----------------|----------------------|
| Simple CRUD operations | ✓ | |
| Logging/audit tools | ✓ | |
| Tools requiring LLM reasoning | | ✓ |
| Multi-step workflows | | ✓ |
| Tools that may need retries with hints | | ✓ |

### DSL Declaration

```go
Export("logging-tools", func() {
    Tool("log_message", "Log a message", func() {
        Args(func() {
            Attribute("level", String, "Log level", func() {
                Enum("debug", "info", "warn", "error")
            })
            Attribute("message", String, "Message to log")
            Required("level", "message")
        })
        Return(func() {
            Attribute("logged", Boolean, "Whether the message was logged")
            Required("logged")
        })
        // Bypass planner, forward directly to LoggingService.LogMessage
        Passthrough("log_message", "LoggingService", "LogMessage")
    })
})
```

### Runtime Behavior

When a consumer agent calls a passthrough tool:

1. The runtime receives the tool call from the consumer's planner
2. Instead of invoking the provider agent's planner, it directly calls the target service method
3. The result is returned to the consumer without any LLM processing

This provides:
- **Predictable latency**: No LLM inference delay
- **Deterministic behavior**: Same input always produces same output
- **Cost efficiency**: No token usage for simple operations

---

## Run Trees and Sessions

Goa-AI models execution as a **tree of runs and tools**:

{{< figure src="/images/diagrams/RunTree.svg" alt="Hierarchical agent execution with run trees" >}}

- **Run** – one execution of an agent:
  - Identified by a `RunID`
  - Described by `run.Context` (RunID, SessionID, TurnID, labels, caps)
  - Tracked durably by the host's `storage.Store`, which keeps run state and
    records that never change after insertion together

- **Session** – a conversation or workflow spanning one or more runs:
  - `SessionID` groups related runs (e.g., multi-turn chat)
  - UIs typically render one session at a time

- **Run tree** – parent/child relationships between runs and tools:
  - Top-level agent run (e.g., `chat`)
  - Child agent runs (agent-as-tool, e.g., `ada`, `diagnostics`)
  - Service tools underneath those agents

The runtime maintains this tree using:

- `run.Handle` – a lightweight handle with `RunID`, `AgentID`, `ParentRunID`, `ParentToolCallID`
- Agent-as-tool helpers and toolset registrations that **always create real child runs** for nested agents (no hidden inline hacks)

Before a child planner runs, `storage.Store.StartChildRun` saves the parent link,
child metadata, and first child record together. For a sessionless parent,
`StartOneShotChildRun` performs the equivalent operation without inventing a
session. Its first call requires the parent to exist, be sessionless, and still
be running. An exact retry remains valid after that parent finishes because the
relationship was already stored; a changed retry or a new child after the
parent finishes is rejected. See
[Memory & Sessions](../memory-sessions/#store-lifecycle-changes-and-records-together)
for the complete storage contract.

If the parent tool registration renders a prompt for the child, the runtime
prepares that prompt in an activity before starting the child workflow. The
activity returns exactly one success or failure. Success contains only the
exact messages and prompt render events stored in workflow history. The
workflow derives child run, session, parent, tool, and label identity from the
original recorded tool call instead of accepting identity from the activity.
Replay therefore uses the original rendered text and never reads a possibly
newer prompt from storage.

Temporal child workflow IDs include the exact runtime tool-call ID. This keeps
parallel calls to the same nested agent distinct; a release that changes this
derivation is not compatible with already-running child workflows.

---

## Agent-as-Tool and RunLink

When an agent uses another agent as a tool:

1. The runtime starts a **child run** for the provider agent with its own `RunID`
2. It tracks parent/child linkage in `run.Context`
3. It executes a full plan/execute/resume loop in the child

The parent tool result (`planner.ToolResult`) carries:

```go
RunLink *run.Handle
```

This `RunLink` allows:
- Planners to reason about the child run (e.g., for audit/logging)
- UIs to create nested "agent cards" keyed by the child run ID and render child events by filtering the session stream by `run_id`
- External tooling to navigate from a parent run to its children without guessing

---

## Session-Owned Streams

Goa-AI publishes client-facing `stream.Event` values into a single **session-owned stream**:

- `session/<session_id>`

That stream contains events across all runs for the session, including nested agent runs launched as tools. Each event carries both `run_id` and `session_id` so consumers can filter/group events by run.

Two events are critical for UIs:

- `child_run_linked`: links a parent tool call (`tool_call_id`) to the spawned child run (`child_run_id`)
- `run_stream_end`: explicit boundary marker meaning “no more stream-visible events will appear for this run”

Consumers subscribe **once per session** and close SSE/WebSocket when they observe `run_stream_end` for the run they’re currently attached to.

```go
import "goa.design/goa-ai/runtime/agent/stream"

// events come from the session stream
events, errs, cancel, err := sub.Subscribe(ctx, "session/session-123")
if err != nil {
    panic(err)
}
defer cancel()

activeRunID := "run-123"
for {
    select {
    case evt := <-events:
        if evt.Type() == stream.EventRunStreamEnd && evt.RunID() == activeRunID {
            return
        }
    case err := <-errs:
        panic(err)
    }
}
```

---

## Stream Profiles

`stream.StreamProfile` describes what an audience sees. Each profile controls which event kinds are emitted by the subscriber.

### StreamProfile Structure

```go
type StreamProfile struct {
    Assistant          bool // assistant_reply
    AssistantTurns     bool // assistant_turn
    Thoughts           bool // planner_thought
    PromptRendered     bool // prompt_rendered
    ToolStart          bool // tool_start
    ToolUpdate         bool // tool_update
    ToolEnd            bool // tool_end
    AwaitClarification bool // await_clarification
    AwaitConfirmation  bool // await_confirmation
    AwaitQuestions     bool // await_questions
    AwaitExternalTools bool // await_external_tools
    ToolAuthorization  bool // tool_authorization
    Usage              bool // usage
    Workflow           bool // workflow
    ChildRuns          bool // child_run_linked (parent tool call → child run)
}
```

### Built-in Profiles

Goa-AI provides built-in profiles for common use cases:

- `stream.DefaultProfile()` emits all event kinds.
- `stream.UserChatProfile()` is suitable for end-user chat views.
- `stream.AgentDebugProfile()` is suitable for developer/debug views.
- `stream.MetricsProfile()` emits only `Usage` and `Workflow` events for telemetry pipelines.

In the session-owned streaming model, child runs do not require separate subscriptions. `child_run_linked` exists to let consumers build a run tree and attach child events to the correct UI card while still consuming a single `session/<session_id>` stream.

### Wiring Profiles to Subscribers

Apply profiles when creating stream subscribers:

```go
import "goa.design/goa-ai/runtime/agent/stream"

// Create a subscriber with the user chat profile
chatSub, err := stream.NewSubscriberWithProfile(chatSink, stream.UserChatProfile())
if err != nil {
    return err
}

// Create a subscriber with the debug profile
debugSub, err := stream.NewSubscriberWithProfile(debugSink, stream.AgentDebugProfile())
if err != nil {
    return err
}

// Create a subscriber with the metrics profile
metricsSub, err := stream.NewSubscriberWithProfile(metricsSink, stream.MetricsProfile())
if err != nil {
    return err
}
```

### Creating Custom Profiles

For specialized needs, create custom profiles by setting individual fields:

```go
// Custom profile: tools and workflow only, no thoughts or assistant replies
toolsOnlyProfile := stream.StreamProfile{
    ToolStart:   true,
    ToolUpdate:  true,
    ToolEnd:     true,
    Workflow:    true,
    ChildRuns:   true,
}

// Custom profile: everything except usage (for privacy-sensitive contexts)
noUsageProfile := stream.DefaultProfile()
noUsageProfile.Usage = false

sub, err := stream.NewSubscriberWithProfile(sink, toolsOnlyProfile)
```

### Profile Selection Guidelines

| Audience | Recommended Profile | Rationale |
|----------|---------------------|-----------|
| End-user chat UI | `UserChatProfile()` | Clean structure with expandable agent cards |
| Admin/debug console | `AgentDebugProfile()` | Full visibility into tools, awaits, and workflow phases |
| Metrics/billing | `MetricsProfile()` | Minimal events for aggregation |
| Audit logging | `DefaultProfile()` | Complete record with run-scoped correlation fields |
| Real-time dashboards | Custom (workflow + usage) | Status and cost tracking only |

---

## Validation Errors and Recovery

Schema-invalid model tool calls do not enter recovery. The validated model
client rejects them as `model.OutputValidationError`, and the planner/runtime
surfaces `planner.OutputContractError` before executor or service code runs.
Planner-authored calls built with `planner.NewToolRequest` return encoding
failures directly to planner code.

### Where Recovery Evidence Comes From

`ToolFailure` begins only after a model-authored call passes validation and the
runtime admits it. Its executor or domain boundary may then return a recoverable
failure with structured field issues. When that failure selects
`RecoveryCorrectCall`, the runtime supplies the original model-authored input
and generated example to the next planner turn. Calls without model provenance
cannot request same-call correction.

### Practical Effect

- UIs can render field issues and examples from admitted recoverable failures.
- Planners can ask a targeted question and make a corrected call when
  `RecoveryCorrectCall` permits it.

Applications choose the profile when wiring sinks and bridges (e.g., Pulse, SSE, WebSocket) so:
- Chat UIs stay clean and structured (nested agent cards driven by `child_run_linked`)
- Debug consoles can see full event detail with the same session stream
- Metrics pipelines see just enough to aggregate usage and statuses

---

## Designing UIs with Run Trees

Given the run tree + streaming model, a typical chat UI can:

1. Subscribe to the session stream (`session/<session_id>`) using a user chat profile.
2. Track the active run you’re attached to (`active_run_id`) and render:
   - Assistant replies (`assistant_reply`)
   - Tool lifecycle (`tool_start`/`tool_update`/`tool_end`)
   - Child run links (`child_run_linked`) as nested **Agent Cards** keyed by `child_run_id`
3. For each card, render the child run’s own timeline by filtering the same session stream by `run_id == child_run_id` (no additional subscriptions).
4. Close SSE/WebSocket when you observe `run_stream_end` for `active_run_id`.

The key idea: **execution topology (run tree) is preserved by IDs and link events**, and streaming is a single ordered log per session that you project into UI lanes/cards by filtering on `run_id`.

---

## Next Steps

- **[MCP Integration](./mcp-integration.md)** - Connect to external tool servers
- **[Memory & Sessions](./memory-sessions.md)** - Manage state with transcripts and memory stores
- **[Production](./production.md)** - Deploy with Temporal and streaming UI

