From a4d5bfe0896972ea2b3d939aecbbd6fa44da857b Mon Sep 17 00:00:00 2001 From: David Gageot Date: Tue, 10 Feb 2026 19:28:59 +0100 Subject: [PATCH] Return error response for unknown tool calls instead of silently skipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two improvements: 1. Bug fix - unknown tool calls get an error response instead of being silently dropped. On main, when the model calls a tool that doesn't exist (the else branch), the call is skipped with no tool response message added to the session. This leaves a tool_call without a matching tool_response, which violates the LLM API contract and can cause errors or confused model behavior on the next turn. This commonly happens after a handoff: agent A sees tool calls from agent B in the conversation history and tries to use them. Now it gets a clear error telling it the tool isn't available. 2. Simpler structure - one availability check instead of two separate rejection paths. On main, the availability logic has three branches with two different rejection paths. The new code separates this into two sequential steps: check agentToolMap once (reject if missing), then pick the handler. This removes the nested if, deduplicates the rejection logic, and makes the flow read top-down: is it available ‚Üí how do we run it ‚Üí run it. Assisted-By: cagent --- pkg/runtime/runtime.go | 38 ++++++++++++++++++------------------- pkg/runtime/runtime_test.go | 19 ++++++------------- 2 files changed, 24 insertions(+), 33 deletions(-) diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index e8610106a..bc09fa732 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -1437,29 +1437,27 @@ func (r *LocalRuntime) processToolCalls(ctx context.Context, sess *session.Sessi slog.Debug("Processing tool call", "agent", a.Name(), "tool", toolCall.Function.Name, "session_id", sess.ID) - // Find the tool - first check runtime tools, then agent tools - var tool tools.Tool - var runTool func() + // Resolve the tool: it must be in the agent's tool set to be callable. + // After a handoff the model may hallucinate tools it saw in the + // conversation history from a previous agent; rejecting unknown + // tools with an error response lets it self-correct. + tool, available := agentToolMap[toolCall.Function.Name] + if !available { + slog.Warn("Tool call for unavailable tool", "agent", a.Name(), "tool", toolCall.Function.Name, "session_id", sess.ID) + errTool := tools.Tool{Name: toolCall.Function.Name} + 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)) + callSpan.SetStatus(codes.Error, "tool not available") + callSpan.End() + continue + } + // Pick the handler: runtime-managed tools (transfer_task, handoff) + // have dedicated handlers; everything else goes through the toolset. + var runTool func() if def, exists := r.toolMap[toolCall.Function.Name]; exists { - // Validate that the tool is actually available to this agent - if _, available := agentToolMap[toolCall.Function.Name]; !available { - slog.Warn("Tool call rejected: tool not available to agent", "agent", a.Name(), "tool", toolCall.Function.Name, "session_id", sess.ID) - 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())) - callSpan.SetStatus(codes.Error, "tool not available to agent") - callSpan.End() - continue - } - tool = def.tool - runTool = func() { r.runAgentTool(callCtx, def.handler, sess, toolCall, def.tool, events, a) } - } else if t, exists := agentToolMap[toolCall.Function.Name]; exists { - tool = t - runTool = func() { r.runTool(callCtx, t, toolCall, events, sess, a) } + runTool = func() { r.runAgentTool(callCtx, def.handler, sess, toolCall, tool, events, a) } } else { - // Tool not found - skip - callSpan.SetStatus(codes.Ok, "tool not found") - callSpan.End() - continue + runTool = func() { r.runTool(callCtx, tool, toolCall, events, sess, a) } } // Execute tool with approval check diff --git a/pkg/runtime/runtime_test.go b/pkg/runtime/runtime_test.go index 15ae30567..a1c1250ca 100644 --- a/pkg/runtime/runtime_test.go +++ b/pkg/runtime/runtime_test.go @@ -862,20 +862,16 @@ func TestSummarize_EmptySession(t *testing.T) { require.Contains(t, warningMsg, "empty", "warning message should mention empty session") } -func TestProcessToolCalls_UnknownTool_NoToolResultMessage(t *testing.T) { - // Build a runtime with a simple agent but no tools registered matching the call +func TestProcessToolCalls_UnknownTool_ReturnsErrorResponse(t *testing.T) { root := agent.New("root", "You are a test agent", agent.WithModel(&mockProvider{})) tm := team.New(team.WithAgents(root)) rt, err := NewLocalRuntime(tm, WithSessionCompaction(false), WithModelStore(mockModelStore{})) require.NoError(t, err) - - // Register default tools (contains only transfer_task) to ensure unknown tool isn't matched rt.registerDefaultTools() sess := session.New(session.WithUserMessage("Start")) - // Simulate a model-issued tool call to a non-existent tool calls := []tools.ToolCall{{ ID: "tool-unknown-1", Type: "function", @@ -883,23 +879,20 @@ func TestProcessToolCalls_UnknownTool_NoToolResultMessage(t *testing.T) { }} events := make(chan Event, 10) - - // No agentTools provided and runtime toolMap doesn't have this tool name rt.processToolCalls(t.Context(), sess, calls, nil, events) - - // Drain events channel close(events) for range events { } - var sawToolMsg bool + // The model must receive an error tool response so it can self-correct. + var toolContent string for _, it := range sess.Messages { if it.IsMessage() && it.Message.Message.Role == chat.MessageRoleTool && it.Message.Message.ToolCallID == "tool-unknown-1" { - sawToolMsg = true - break + toolContent = it.Message.Message.Content } } - require.False(t, sawToolMsg, "no tool result should be added for unknown tool; this reproduces invalid sequencing state") + require.NotEmpty(t, toolContent, "expected an error tool response for unknown tools") + assert.Contains(t, toolContent, "not available") } func TestEmitStartupInfo(t *testing.T) {