Skip to content

Commit cb7cf3e

Browse files
authored
Merge pull request #1689 from dgageot/tool_calls_unknown
Return error response for unknown tool calls instead of silently skipping
2 parents 9ed1daa + a4d5bfe commit cb7cf3e

File tree

2 files changed

+24
-33
lines changed

2 files changed

+24
-33
lines changed

pkg/runtime/runtime.go

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,29 +1448,27 @@ func (r *LocalRuntime) processToolCalls(ctx context.Context, sess *session.Sessi
14481448

14491449
slog.Debug("Processing tool call", "agent", a.Name(), "tool", toolCall.Function.Name, "session_id", sess.ID)
14501450

1451-
// Find the tool - first check runtime tools, then agent tools
1452-
var tool tools.Tool
1453-
var runTool func()
1451+
// Resolve the tool: it must be in the agent's tool set to be callable.
1452+
// After a handoff the model may hallucinate tools it saw in the
1453+
// conversation history from a previous agent; rejecting unknown
1454+
// tools with an error response lets it self-correct.
1455+
tool, available := agentToolMap[toolCall.Function.Name]
1456+
if !available {
1457+
slog.Warn("Tool call for unavailable tool", "agent", a.Name(), "tool", toolCall.Function.Name, "session_id", sess.ID)
1458+
errTool := tools.Tool{Name: toolCall.Function.Name}
1459+
r.addToolErrorResponse(ctx, sess, toolCall, errTool, events, a, fmt.Sprintf("Tool '%s' is not available. You can only use the tools provided to you.", toolCall.Function.Name))
1460+
callSpan.SetStatus(codes.Error, "tool not available")
1461+
callSpan.End()
1462+
continue
1463+
}
14541464

1465+
// Pick the handler: runtime-managed tools (transfer_task, handoff)
1466+
// have dedicated handlers; everything else goes through the toolset.
1467+
var runTool func()
14551468
if def, exists := r.toolMap[toolCall.Function.Name]; exists {
1456-
// Validate that the tool is actually available to this agent
1457-
if _, available := agentToolMap[toolCall.Function.Name]; !available {
1458-
slog.Warn("Tool call rejected: tool not available to agent", "agent", a.Name(), "tool", toolCall.Function.Name, "session_id", sess.ID)
1459-
r.addToolErrorResponse(ctx, sess, toolCall, def.tool, events, a, fmt.Sprintf("Tool '%s' is not available to this agent (%s).", toolCall.Function.Name, a.Name()))
1460-
callSpan.SetStatus(codes.Error, "tool not available to agent")
1461-
callSpan.End()
1462-
continue
1463-
}
1464-
tool = def.tool
1465-
runTool = func() { r.runAgentTool(callCtx, def.handler, sess, toolCall, def.tool, events, a) }
1466-
} else if t, exists := agentToolMap[toolCall.Function.Name]; exists {
1467-
tool = t
1468-
runTool = func() { r.runTool(callCtx, t, toolCall, events, sess, a) }
1469+
runTool = func() { r.runAgentTool(callCtx, def.handler, sess, toolCall, tool, events, a) }
14691470
} else {
1470-
// Tool not found - skip
1471-
callSpan.SetStatus(codes.Ok, "tool not found")
1472-
callSpan.End()
1473-
continue
1471+
runTool = func() { r.runTool(callCtx, tool, toolCall, events, sess, a) }
14741472
}
14751473

14761474
// Execute tool with approval check

pkg/runtime/runtime_test.go

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -862,44 +862,37 @@ func TestSummarize_EmptySession(t *testing.T) {
862862
require.Contains(t, warningMsg, "empty", "warning message should mention empty session")
863863
}
864864

865-
func TestProcessToolCalls_UnknownTool_NoToolResultMessage(t *testing.T) {
866-
// Build a runtime with a simple agent but no tools registered matching the call
865+
func TestProcessToolCalls_UnknownTool_ReturnsErrorResponse(t *testing.T) {
867866
root := agent.New("root", "You are a test agent", agent.WithModel(&mockProvider{}))
868867
tm := team.New(team.WithAgents(root))
869868

870869
rt, err := NewLocalRuntime(tm, WithSessionCompaction(false), WithModelStore(mockModelStore{}))
871870
require.NoError(t, err)
872-
873-
// Register default tools (contains only transfer_task) to ensure unknown tool isn't matched
874871
rt.registerDefaultTools()
875872

876873
sess := session.New(session.WithUserMessage("Start"))
877874

878-
// Simulate a model-issued tool call to a non-existent tool
879875
calls := []tools.ToolCall{{
880876
ID: "tool-unknown-1",
881877
Type: "function",
882878
Function: tools.FunctionCall{Name: "non_existent_tool", Arguments: "{}"},
883879
}}
884880

885881
events := make(chan Event, 10)
886-
887-
// No agentTools provided and runtime toolMap doesn't have this tool name
888882
rt.processToolCalls(t.Context(), sess, calls, nil, events)
889-
890-
// Drain events channel
891883
close(events)
892884
for range events {
893885
}
894886

895-
var sawToolMsg bool
887+
// The model must receive an error tool response so it can self-correct.
888+
var toolContent string
896889
for _, it := range sess.Messages {
897890
if it.IsMessage() && it.Message.Message.Role == chat.MessageRoleTool && it.Message.Message.ToolCallID == "tool-unknown-1" {
898-
sawToolMsg = true
899-
break
891+
toolContent = it.Message.Message.Content
900892
}
901893
}
902-
require.False(t, sawToolMsg, "no tool result should be added for unknown tool; this reproduces invalid sequencing state")
894+
require.NotEmpty(t, toolContent, "expected an error tool response for unknown tools")
895+
assert.Contains(t, toolContent, "not available")
903896
}
904897

905898
func TestEmitStartupInfo(t *testing.T) {

0 commit comments

Comments
 (0)