Skip to content

Commit 2770347

Browse files
authored
Merge pull request #891 from rumpl/feat-handoff
Add agent handoffs
2 parents e3a9563 + 2eb21d2 commit 2770347

9 files changed

Lines changed: 216 additions & 30 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: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,13 @@ const (
5353
ResumeTypeReject ResumeType = "reject"
5454
)
5555

56-
// ToolHandler is a function type for handling tool calls
57-
type ToolHandler func(ctx context.Context, sess *session.Session, toolCall tools.ToolCall, events chan Event) (*tools.ToolCallResult, error)
56+
// ToolHandlerFunc is a function type for handling tool calls
57+
type ToolHandlerFunc func(ctx context.Context, sess *session.Session, toolCall tools.ToolCall, events chan Event) (*tools.ToolCallResult, error)
58+
59+
type ToolHandler struct {
60+
handler ToolHandlerFunc
61+
tool tools.Tool
62+
}
5863

5964
// ElicitationRequestHandler is a function type for handling elicitation requests
6065
type ElicitationRequestHandler func(ctx context.Context, message string, schema map[string]any) (map[string]any, error)
@@ -369,7 +374,26 @@ func (r *LocalRuntime) EmitStartupInfo(ctx context.Context, events chan Event) {
369374
// registerDefaultTools registers the default tool handlers
370375
func (r *LocalRuntime) registerDefaultTools() {
371376
slog.Debug("Registering default tools")
372-
r.toolMap[builtin.ToolNameTransferTask] = r.handleTaskTransfer
377+
378+
tt := builtin.NewTransferTaskTool()
379+
ht := builtin.NewHandoffTool()
380+
ttTools, _ := tt.Tools(context.TODO())
381+
htTools, _ := ht.Tools(context.TODO())
382+
allTools := append(ttTools, htTools...)
383+
384+
handlers := map[string]ToolHandlerFunc{
385+
builtin.ToolNameTransferTask: r.handleTaskTransfer,
386+
builtin.ToolNameHandoff: r.handleHandoff,
387+
}
388+
389+
for _, t := range allTools {
390+
if h, exists := handlers[t.Name]; exists {
391+
r.toolMap[t.Name] = ToolHandler{handler: h, tool: t}
392+
} else {
393+
slog.Warn("No handler found for default tool", "tool", t.Name)
394+
}
395+
}
396+
373397
slog.Debug("Registered default tools", "count", len(r.toolMap))
374398
}
375399

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

457481
for {
482+
// Set elicitation handler on all MCP toolsets before getting tools
483+
a := r.CurrentAgent()
484+
485+
r.emitAgentWarnings(a, events)
486+
487+
for _, toolset := range a.ToolSets() {
488+
toolset.SetElicitationHandler(r.elicitationHandler)
489+
toolset.SetOAuthSuccessHandler(func() {
490+
events <- Authorization("confirmed", r.currentAgent)
491+
})
492+
}
493+
494+
agentTools, err := r.getTools(ctx, a, sessionSpan, events)
495+
if err != nil {
496+
events <- Error(fmt.Sprintf("failed to get tools: %v", err))
497+
return
498+
}
499+
458500
// Check iteration limit
459501
if runtimeMaxIterations > 0 && iteration >= runtimeMaxIterations {
460502
slog.Debug("Maximum iterations reached", "agent", a.Name(), "iterations", iteration, "max", runtimeMaxIterations)
@@ -881,42 +923,37 @@ func (r *LocalRuntime) processToolCalls(ctx context.Context, sess *session.Sessi
881923
))
882924

883925
slog.Debug("Processing tool call", "agent", a.Name(), "tool", toolCall.Function.Name, "session_id", sess.ID)
884-
handler, exists := r.toolMap[toolCall.Function.Name]
926+
def, exists := r.toolMap[toolCall.Function.Name]
885927
if exists {
886-
tool := tools.Tool{
887-
Annotations: tools.ToolAnnotations{
888-
// TODO: We need to handle the transfer task tool better
889-
Title: "Transfer Task",
890-
},
891-
}
892928
slog.Debug("Using runtime tool handler", "tool", toolCall.Function.Name, "session_id", sess.ID)
893-
if sess.ToolsApproved || toolCall.Function.Name == builtin.ToolNameTransferTask {
894-
r.runAgentTool(callCtx, handler, sess, toolCall, tool, events, a)
929+
// TODO: make this better, these tools define themselves as read-only
930+
if sess.ToolsApproved || def.tool.Annotations.ReadOnlyHint {
931+
r.runAgentTool(callCtx, def.handler, sess, toolCall, def.tool, events, a)
895932
} else {
896933
slog.Debug("Tools not approved, waiting for resume", "tool", toolCall.Function.Name, "session_id", sess.ID)
897934

898-
events <- ToolCallConfirmation(toolCall, tool, a.Name())
935+
events <- ToolCallConfirmation(toolCall, def.tool, a.Name())
899936

900937
select {
901938
case cType := <-r.resumeChan:
902939
switch cType {
903940
case ResumeTypeApprove:
904941
slog.Debug("Resume signal received, approving tool handler", "tool", toolCall.Function.Name, "session_id", sess.ID)
905-
r.runAgentTool(callCtx, handler, sess, toolCall, tool, events, a)
942+
r.runAgentTool(callCtx, def.handler, sess, toolCall, def.tool, events, a)
906943
case ResumeTypeApproveSession:
907944
slog.Debug("Resume signal received, approving session", "tool", toolCall.Function.Name, "session_id", sess.ID)
908945
sess.ToolsApproved = true
909-
r.runAgentTool(callCtx, handler, sess, toolCall, tool, events, a)
946+
r.runAgentTool(callCtx, def.handler, sess, toolCall, def.tool, events, a)
910947
case ResumeTypeReject:
911948
slog.Debug("Resume signal received, rejecting tool handler", "tool", toolCall.Function.Name, "session_id", sess.ID)
912-
r.addToolRejectedResponse(sess, toolCall, tool, events)
949+
r.addToolRejectedResponse(sess, toolCall, def.tool, events)
913950
}
914951
case <-callCtx.Done():
915952
slog.Debug("Context cancelled while waiting for resume", "tool", toolCall.Function.Name, "session_id", sess.ID)
916953
// Synthesize cancellation responses for the current and any remaining tool calls
917-
r.addToolCancelledResponse(sess, toolCall, tool, events)
954+
r.addToolCancelledResponse(sess, toolCall, def.tool, events)
918955
for j := i + 1; j < len(calls); j++ {
919-
r.addToolCancelledResponse(sess, calls[j], tool, events)
956+
r.addToolCancelledResponse(sess, calls[j], def.tool, events)
920957
}
921958
callSpan.SetStatus(codes.Ok, "tool call canceled by user")
922959
return
@@ -1043,7 +1080,7 @@ func (r *LocalRuntime) runTool(ctx context.Context, tool tools.Tool, toolCall to
10431080
sess.AddMessage(session.NewAgentMessage(a, &toolResponseMsg))
10441081
}
10451082

1046-
func (r *LocalRuntime) runAgentTool(ctx context.Context, handler ToolHandler, sess *session.Session, toolCall tools.ToolCall, tool tools.Tool, events chan Event, a *agent.Agent) {
1083+
func (r *LocalRuntime) runAgentTool(ctx context.Context, handler ToolHandlerFunc, sess *session.Session, toolCall tools.ToolCall, tool tools.Tool, events chan Event, a *agent.Agent) {
10471084
// Start a child span for runtime-provided tool handler execution
10481085
ctx, span := r.startSpan(ctx, "runtime.tool.handler.runtime", trace.WithAttributes(
10491086
attribute.String("tool.name", toolCall.Function.Name),
@@ -1234,6 +1271,24 @@ func (r *LocalRuntime) handleTaskTransfer(ctx context.Context, sess *session.Ses
12341271
}, nil
12351272
}
12361273

1274+
func (r *LocalRuntime) handleHandoff(ctx context.Context, sess *session.Session, toolCall tools.ToolCall, evts chan Event) (*tools.ToolCallResult, error) {
1275+
var params builtin.HandoffArgs
1276+
if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &params); err != nil {
1277+
return nil, fmt.Errorf("invalid arguments: %w", err)
1278+
}
1279+
1280+
ca := r.currentAgent
1281+
next, err := r.team.Agent(params.Agent)
1282+
if err != nil {
1283+
return nil, err
1284+
}
1285+
1286+
r.currentAgent = next.Name()
1287+
return &tools.ToolCallResult{
1288+
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),
1289+
}, nil
1290+
}
1291+
12371292
// generateSessionTitle generates a title for the session based on the conversation history
12381293
func (r *LocalRuntime) generateSessionTitle(ctx context.Context, sess *session.Session, events chan Event) {
12391294
slog.Debug("Generating title for session", "session_id", sess.ID)

pkg/session/session.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,13 +276,28 @@ func (s *Session) GetMessages(a *agent.Agent) []chat.Message {
276276
subAgentsStr := ""
277277
var validAgentIDs []string
278278
for _, subAgent := range subAgents {
279-
subAgentsStr += "ID: " + subAgent.Name() + " | Name: " + subAgent.Name() + " | Description: " + subAgent.Description() + "\n"
279+
subAgentsStr += "Name: " + subAgent.Name() + " | Description: " + subAgent.Description() + "\n"
280280
validAgentIDs = append(validAgentIDs, subAgent.Name())
281281
}
282282

283283
messages = append(messages, chat.Message{
284284
Role: chat.MessageRoleSystem,
285-
Content: "You are a multi-agent system, make sure to answer the user query in the most helpful way possible. You have access to these sub-agents:\n" + subAgentsStr + "\nIMPORTANT: You can ONLY transfer tasks to the agents listed above using their ID. The valid agent IDs are: " + strings.Join(validAgentIDs, ", ") + ". You MUST NOT attempt to transfer to any other agent IDs - doing so will cause system errors.\n\nIf you are the best to answer the question according to your description, you can answer it.\n\nIf another agent is better for answering the question according to its description, call `transfer_task` function to transfer the question to that agent using the agent's ID. When transferring, do not generate any text other than the function call.\n\n",
285+
Content: "You are a multi-agent system, make sure to answer the user query in the most helpful way possible. You have access to these sub-agents:\n" + subAgentsStr + "\nIMPORTANT: You can ONLY transfer tasks to the agents listed above using their ID. The valid agent names are: " + strings.Join(validAgentIDs, ", ") + ". You MUST NOT attempt to transfer to any other agent IDs - doing so will cause system errors.\n\nIf you are the best to answer the question according to your description, you can answer it.\n\nIf another agent is better for answering the question according to its description, call `transfer_task` function to transfer the question to that agent using the agent's ID. When transferring, do not generate any text other than the function call.\n\n",
286+
})
287+
}
288+
289+
handoffs := a.Handoffs()
290+
if len(handoffs) > 0 {
291+
agentsInfo := ""
292+
var validAgentIDs []string
293+
for _, agent := range handoffs {
294+
agentsInfo += "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 names 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.",
286301
})
287302
}
288303

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.

0 commit comments

Comments
 (0)