Skip to content

Commit 6093a8c

Browse files
committed
Add agent handoffs
These are different than sub agents because they are not "smart tools" but rather a list of agents that the current agent can hand the conversation to, the new agent can continue with hte conversation and has access to the whole history. Signed-off-by: Djordje Lukic <djordje.lukic@docker.com>
1 parent 1718974 commit 6093a8c

9 files changed

Lines changed: 179 additions & 10 deletions

File tree

cagent-schema.json

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@
9292
"type": "string"
9393
}
9494
},
95+
"handoffs": {
96+
"type": "array",
97+
"description": "List of agents this agent can hand off the conversation to",
98+
"items": {
99+
"type": "string"
100+
}
101+
},
95102
"add_date": {
96103
"type": "boolean",
97104
"description": "Whether to add date information"
@@ -662,17 +669,26 @@
662669
"items": {
663670
"type": "object",
664671
"description": "Retrieval strategy configuration with type-specific parameters. Structured fields are limited; additional parameters are passed through as-is for strategy-specific use.",
665-
"required": ["type"],
672+
"required": [
673+
"type"
674+
],
666675
"properties": {
667676
"type": {
668677
"type": "string",
669678
"description": "Retrieval strategy type",
670-
"enum": ["chunked-embeddings", "bm25"]
679+
"enum": [
680+
"chunked-embeddings",
681+
"bm25"
682+
]
671683
},
672684
"model": {
673685
"type": "string",
674686
"description": "Embedding model reference for chunked-embeddings strategies (looked up in models map, or 'auto' for automatic selection)",
675-
"examples": ["openai/text-embedding-3-small", "dmr/embeddinggemma", "auto"]
687+
"examples": [
688+
"openai/text-embedding-3-small",
689+
"dmr/embeddinggemma",
690+
"auto"
691+
]
676692
},
677693
"docs": {
678694
"type": "array",
@@ -688,13 +704,20 @@
688704
"similarity_metric": {
689705
"type": "string",
690706
"description": "Similarity metric (chunked-embeddings only). Currently only 'cosine_similarity' is implemented.",
691-
"enum": ["cosine_similarity"]
707+
"enum": [
708+
"cosine_similarity"
709+
]
692710
},
693711
"vector_dimensions": {
694712
"type": "integer",
695713
"description": "Vector dimensions for embeddings (chunked-embeddings only). Must match your embedding model's output dimensions and is required for chunked-embeddings strategies.",
696714
"minimum": 1,
697-
"examples": [1536, 3072, 1024, 768]
715+
"examples": [
716+
1536,
717+
3072,
718+
1024,
719+
768
720+
]
698721
},
699722
"k1": {
700723
"type": "number",

examples/handoff.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
agents:
2+
root:
3+
model: anthropic/claude-sonnet-4-5
4+
description: The best agent
5+
instruction: |
6+
You are a helpful assistant that can hand off tasks to other agents.
7+
Look at the descriptions ofthe avaiable agents, if any of them is better suited to answer the user question, you must call the handoff tool to transfer the task to that agent.
8+
handoffs:
9+
- web_search
10+
11+
web_search:
12+
model: openai/gpt-4o
13+
description: An agent specialized in web searching and information retrieval.
14+
instruction: You are an agent specialized in web searching and information retrieval.
15+
toolsets:
16+
- type: mcp
17+
ref: docker:duckduckgo

pkg/agent/agent.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type Agent struct {
1919
toolsets []*StartableToolSet
2020
models []provider.Provider
2121
subAgents []*Agent
22+
handoffs []*Agent
2223
parents []*Agent
2324
addDate bool
2425
addEnvironmentInfo bool
@@ -83,11 +84,16 @@ func (a *Agent) WelcomeMessage() string {
8384
return a.welcomeMessage
8485
}
8586

86-
// SubAgents returns the list of sub-agent names
87+
// SubAgents returns the list of sub-agents
8788
func (a *Agent) SubAgents() []*Agent {
8889
return a.subAgents
8990
}
9091

92+
// Handoffs returns the list of handoff agents
93+
func (a *Agent) Handoffs() []*Agent {
94+
return a.handoffs
95+
}
96+
9197
// Parents returns the list of parent agent names
9298
func (a *Agent) Parents() []*Agent {
9399
return a.parents

pkg/agent/opts.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ func WithSubAgents(subAgents ...*Agent) Opt {
6767
}
6868
}
6969

70+
func WithHandoffs(handoffs ...*Agent) Opt {
71+
return func(a *Agent) {
72+
a.handoffs = handoffs
73+
}
74+
}
75+
7076
func WithAddDate(addDate bool) Opt {
7177
return func(a *Agent) {
7278
a.addDate = addDate

pkg/config/v2/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type AgentConfig struct {
2525
Toolsets []Toolset `json:"toolsets,omitempty"`
2626
Instruction string `json:"instruction,omitempty"`
2727
SubAgents []string `json:"sub_agents,omitempty"`
28+
Handoffs []string `json:"handoffs,omitempty"`
2829
RAG []string `json:"rag,omitempty"`
2930
AddDate bool `json:"add_date,omitempty"`
3031
AddEnvironmentInfo bool `json:"add_environment_info,omitempty"`

pkg/runtime/runtime.go

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ func (r *LocalRuntime) EmitStartupInfo(ctx context.Context, events chan Event) {
370370
func (r *LocalRuntime) registerDefaultTools() {
371371
slog.Debug("Registering default tools")
372372
r.toolMap[builtin.ToolNameTransferTask] = r.handleTaskTransfer
373+
r.toolMap[builtin.ToolNameHandoff] = r.handleHandoff
373374
slog.Debug("Registered default tools", "count", len(r.toolMap))
374375
}
375376

@@ -455,6 +456,24 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
455456
runtimeMaxIterations := sess.MaxIterations
456457

457458
for {
459+
// Set elicitation handler on all MCP toolsets before getting tools
460+
a := r.CurrentAgent()
461+
462+
r.emitAgentWarnings(a, events)
463+
464+
for _, toolset := range a.ToolSets() {
465+
toolset.SetElicitationHandler(r.elicitationHandler)
466+
toolset.SetOAuthSuccessHandler(func() {
467+
events <- Authorization("confirmed", r.currentAgent)
468+
})
469+
}
470+
471+
agentTools, err := r.getTools(ctx, a, sessionSpan, events)
472+
if err != nil {
473+
events <- Error(fmt.Sprintf("failed to get tools: %v", err))
474+
return
475+
}
476+
458477
// Check iteration limit
459478
if runtimeMaxIterations > 0 && iteration >= runtimeMaxIterations {
460479
slog.Debug("Maximum iterations reached", "agent", a.Name(), "iterations", iteration, "max", runtimeMaxIterations)
@@ -890,7 +909,8 @@ func (r *LocalRuntime) processToolCalls(ctx context.Context, sess *session.Sessi
890909
},
891910
}
892911
slog.Debug("Using runtime tool handler", "tool", toolCall.Function.Name, "session_id", sess.ID)
893-
if sess.ToolsApproved || toolCall.Function.Name == builtin.ToolNameTransferTask {
912+
// TODO: make this better, these tols define themselves as read-only
913+
if sess.ToolsApproved || toolCall.Function.Name == builtin.ToolNameTransferTask || toolCall.Function.Name == builtin.ToolNameHandoff {
894914
r.runAgentTool(callCtx, handler, sess, toolCall, tool, events, a)
895915
} else {
896916
slog.Debug("Tools not approved, waiting for resume", "tool", toolCall.Function.Name, "session_id", sess.ID)
@@ -1234,6 +1254,24 @@ func (r *LocalRuntime) handleTaskTransfer(ctx context.Context, sess *session.Ses
12341254
}, nil
12351255
}
12361256

1257+
func (r *LocalRuntime) handleHandoff(ctx context.Context, sess *session.Session, toolCall tools.ToolCall, evts chan Event) (*tools.ToolCallResult, error) {
1258+
var params builtin.HandoffArgs
1259+
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &params); err != nil {
1260+
return nil, fmt.Errorf("invalid arguments: %w", err)
1261+
}
1262+
1263+
ca := r.currentAgent
1264+
next, err := r.team.Agent(params.Agent)
1265+
if err != nil {
1266+
return nil, err
1267+
}
1268+
1269+
r.currentAgent = next.Name()
1270+
return &tools.ToolCallResult{
1271+
Output: fmt.Sprintf("The agent %s handed off the conversation to you, look at the history of the conversation and continue where it left off. Once you are done with your task or if the user asks you, handoff the conversation back to %s.", ca, ca),
1272+
}, nil
1273+
}
1274+
12371275
// generateSessionTitle generates a title for the session based on the conversation history
12381276
func (r *LocalRuntime) generateSessionTitle(ctx context.Context, sess *session.Session, events chan Event) {
12391277
slog.Debug("Generating title for session", "session_id", sess.ID)

pkg/session/session.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,21 @@ func (s *Session) GetMessages(a *agent.Agent) []chat.Message {
286286
})
287287
}
288288

289+
handoffs := a.Handoffs()
290+
if len(handoffs) > 0 {
291+
agentsInfo := ""
292+
var validAgentIDs []string
293+
for _, agent := range handoffs {
294+
agentsInfo += "ID: " + agent.Name() + " | Name: " + agent.Name() + " | Description: " + agent.Description() + "\n"
295+
validAgentIDs = append(validAgentIDs, agent.Name())
296+
}
297+
298+
messages = append(messages, chat.Message{
299+
Role: chat.MessageRoleSystem,
300+
Content: "You are part of a multi-agent team. Your goal is to answer the user query in the most helpful way possible.\n\nAvailable agents in your team:\n" + agentsInfo + "\nYou can hand off the conversation to any of these agents at any time by using the `handoff` function with their ID. The valid agent IDs are: " + strings.Join(validAgentIDs, ", ") + ".\n\nWhen to hand off:\n- If another agent's description indicates they are better suited for the current task or question\n\n- If any of the tools of the agent indicate that this agent is able to respond correctly- If the user explicitly asks for a specific agent\n- If you need specialized capabilities that another agent provides\n\nIf you are the best agent to handle the current request based on your capabilities, respond directly. When handing off to another agent, only handoff without talking about the handoff.",
301+
})
302+
}
303+
289304
content := a.Instruction()
290305

291306
if a.AddDate() {

pkg/teamloader/teamloader.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,9 @@ func LoadFrom(ctx context.Context, source AgentSource, runtimeConfig *config.Run
208208
agentsByName[name] = ag
209209
}
210210

211+
// Connect sub-agents and handoff agents
211212
for name := range cfg.Agents {
212213
agentConfig := cfg.Agents[name]
213-
if len(agentConfig.SubAgents) == 0 {
214-
continue
215-
}
216214

217215
subAgents := make([]*agent.Agent, 0, len(agentConfig.SubAgents))
218216
for _, subName := range agentConfig.SubAgents {
@@ -224,6 +222,17 @@ func LoadFrom(ctx context.Context, source AgentSource, runtimeConfig *config.Run
224222
if a, exists := agentsByName[name]; exists && len(subAgents) > 0 {
225223
agent.WithSubAgents(subAgents...)(a)
226224
}
225+
226+
handoffs := make([]*agent.Agent, 0, len(agentConfig.Handoffs))
227+
for _, handoffName := range agentConfig.Handoffs {
228+
if handoffAgent, exists := agentsByName[handoffName]; exists {
229+
handoffs = append(handoffs, handoffAgent)
230+
}
231+
}
232+
233+
if a, exists := agentsByName[name]; exists && len(handoffs) > 0 {
234+
agent.WithHandoffs(handoffs...)(a)
235+
}
227236
}
228237

229238
id := loadOpts.id
@@ -295,6 +304,9 @@ func getToolsForAgent(ctx context.Context, a *latest.AgentConfig, parentDir stri
295304
if len(a.SubAgents) > 0 {
296305
toolSets = append(toolSets, builtin.NewTransferTaskTool())
297306
}
307+
if len(a.Handoffs) > 0 {
308+
toolSets = append(toolSets, builtin.NewHandoffTool())
309+
}
298310

299311
// Wrap all tools in a single Code Mode toolset.
300312
// This allows the agent to call multiple tools in a single response.

pkg/tools/builtin/handoff.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package builtin
2+
3+
import (
4+
"context"
5+
6+
"github.com/docker/cagent/pkg/tools"
7+
)
8+
9+
const ToolNameHandoff = "handoff"
10+
11+
type HandoffTool struct {
12+
tools.ElicitationTool
13+
}
14+
15+
// Make sure Handoff Tool implements the ToolSet Interface
16+
var _ tools.ToolSet = (*HandoffTool)(nil)
17+
18+
type HandoffArgs struct {
19+
Agent string `json:"agent" jsonschema:"The name of the agent to hand off the conversation to."`
20+
}
21+
22+
func NewHandoffTool() *HandoffTool {
23+
return &HandoffTool{}
24+
}
25+
26+
func (t *HandoffTool) Instructions() string {
27+
return ""
28+
}
29+
30+
func (t *HandoffTool) Tools(context.Context) ([]tools.Tool, error) {
31+
return []tools.Tool{
32+
{
33+
Name: ToolNameHandoff,
34+
Category: "handoff",
35+
Description: "Use this function to hand off the conversation to the selected agent.",
36+
Parameters: tools.MustSchemaFor[HandoffArgs](),
37+
Annotations: tools.ToolAnnotations{
38+
ReadOnlyHint: true,
39+
Title: "Handoff Conversation",
40+
},
41+
},
42+
}, nil
43+
}
44+
45+
func (t *HandoffTool) Start(context.Context) error {
46+
return nil
47+
}
48+
49+
func (t *HandoffTool) Stop(context.Context) error {
50+
return nil
51+
}

0 commit comments

Comments
 (0)