Emit tool-result for post-handoff tool calls

When the LLM returns tool calls after a handoff in the same
assistant message, they were silently dropped. This left
orphaned tool_call entries without matching tool-result
messages, causing protocol errors on the next LLM turn.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-13 18:28:37 +01:00
parent 3b9a13b5d9
commit 9f92a27b51
2 changed files with 97 additions and 0 deletions

View File

@@ -2746,6 +2746,90 @@ func TestRun_HandoffWithPreHandoffTools(t *testing.T) {
},
)
t.Run(
"tools after handoff get tool-result messages",
func(t *testing.T) {
t.Parallel()
type Params struct{}
tool1 := agent.FunctionTool[Params](
"prepare",
"Prepare data",
func(_ context.Context, _ Params) (agent.ToolResult, error) {
return agent.ToolResult{Content: "prepared"}, nil
},
)
tool2 := agent.FunctionTool[Params](
"finalize",
"Finalize data",
func(_ context.Context, _ Params) (agent.ToolResult, error) {
t.Fatal("tool after handoff should not be executed")
return agent.ToolResult{}, nil
},
)
provider := &mockProvider{
responses: []*llm.ChatCompletionResponse{
toolCallResponse(
llm.ToolCall{
ID: "tc_1",
Function: llm.FunctionCall{Name: "prepare", Arguments: `{}`},
},
llm.ToolCall{
ID: "tc_2",
Function: llm.FunctionCall{Name: "transfer_to_specialist", Arguments: `{}`},
},
llm.ToolCall{
ID: "tc_3",
Function: llm.FunctionCall{Name: "finalize", Arguments: `{}`},
},
),
stopResponse("Specialist here."),
},
}
client := newTestClient(provider)
specialist := agent.New(
"specialist",
client,
agent.WithModel("test-model"),
)
router := agent.New(
"router",
client,
agent.WithModel("test-model"),
agent.WithTools(tool1, tool2),
agent.WithHandoffs(specialist),
)
result, err := router.Run(
context.Background(),
[]llm.Message{userMessage("prepare, transfer, and finalize")},
)
require.NoError(t, err)
assert.Equal(t, "Specialist here.", result.FinalMessage().Text())
assert.Equal(t, "specialist", result.LastAgent.Name())
var toolMsgs []llm.Message
for _, m := range result.Messages {
if m.Role == llm.RoleTool {
toolMsgs = append(toolMsgs, m)
}
}
require.Len(t, toolMsgs, 3)
assert.Equal(t, "tc_1", toolMsgs[0].ToolCallID)
assert.Equal(t, "prepared", toolMsgs[0].Text())
assert.Equal(t, "tc_2", toolMsgs[1].ToolCallID)
assert.Contains(t, toolMsgs[1].Text(), "Transferred to specialist")
assert.Equal(t, "tc_3", toolMsgs[2].ToolCallID)
assert.Contains(t, toolMsgs[2].Text(), "not executed")
},
)
t.Run(
"pre-handoff tool error aborts handoff",
func(t *testing.T) {

View File

@@ -645,6 +645,19 @@ func executeWithHandoff(
},
)
for i := handoffIdx + 1; i < len(toolCalls); i++ {
msgs = append(
msgs,
llm.Message{
Role: llm.RoleTool,
ToolCallID: toolCalls[i].ID,
Parts: []llm.Part{
llm.TextPart{Text: "Tool call was not executed because a handoff occurred."},
},
},
)
}
return ht.handoff, results, msgs, nil
}