Agent Composition
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:
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:
goa gen example.com/tutorial/design
Implementing Planners
The generated code provides helpers for both agents. Wire them together:
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 aRunLinkback to the child run) - Cross-Process: Agents can execute on different workers while maintaining a coherent run tree;
child_run_linkedstream 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.
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
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:
- The runtime receives the tool call from the consumer’s planner
- Instead of invoking the provider agent’s planner, it directly calls the target service method
- 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:
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
- Identified by a
Session – a conversation or workflow spanning one or more runs:
SessionIDgroups 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
- Top-level agent run (e.g.,
The runtime maintains this tree using:
run.Handle– a lightweight handle withRunID,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
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:
- The runtime starts a child run for the provider agent with its own
RunID - It tracks parent/child linkage in
run.Context - It executes a full plan/execute/resume loop in the child
The parent tool result (planner.ToolResult) carries:
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.
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
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 onlyUsageandWorkflowevents 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:
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:
// 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
RecoveryCorrectCallpermits 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:
- Subscribe to the session stream (
session/<session_id>) using a user chat profile. - 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 bychild_run_id
- Assistant replies (
- 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). - Close SSE/WebSocket when you observe
run_stream_endforactive_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 - Connect to external tool servers
- Memory & Sessions - Manage state with transcripts and memory stores
- Production - Deploy with Temporal and streaming UI