Agent Composition

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

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 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

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:

&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:

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:

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

ScenarioUse PassthroughUse 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:

  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:

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 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.


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:

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 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:

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

AudienceRecommended ProfileRationale
End-user chat UIUserChatProfile()Clean structure with expandable agent cards
Admin/debug consoleAgentDebugProfile()Full visibility into tools, awaits, and workflow phases
Metrics/billingMetricsProfile()Minimal events for aggregation
Audit loggingDefaultProfile()Complete record with run-scoped correlation fields
Real-time dashboardsCustom (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