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) {