From b79463272d0dfc012000c36c10d375fd6d65ccbf Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Tue, 3 Nov 2026 21:23:11 +0530 Subject: [PATCH] fix(chat): omit tool choice on tool-free turns --- internal/engine/stream.go | 8 ++--- internal/engine/stream_prompt_test.go | 43 +++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/internal/engine/stream.go b/internal/engine/stream.go index c7d15cd5..9b753651 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -58,9 +58,9 @@ func (s *Session) buildTurnOptions(tc turnContext) types.ChatOptions { if smallTalk { // Keep this path free of workspace/memory/workflow nudges as well as // schemas. Those instructions can turn a greeting into repository work. - opts := s.ChatLLM().BuildOptions(prompt.System(), activeModel, maxTok, nil) - opts.ToolChoice = &types.ToolChoiceOption{Type: "none"} - return opts + // Omit tool choice along with tools: strict gateways reject even + // "none" when no tool definitions are supplied. + return s.ChatLLM().BuildOptions(prompt.System(), activeModel, maxTok, nil) } baseOpts := s.ChatLLM().BuildOptions(baseSystem, activeModel, maxTok, nil) opts := baseOpts @@ -727,7 +727,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } // Tool-free turns are authoritative even if a provider emits unsolicited // tool calls. Do not inspect the repository or render tool activity just - // because a model ignored tool_choice=none. + // because a model returned tools that were not offered for this turn. if smallTalk && len(toolCalls) > 0 { toolCalls = nil if strings.TrimSpace(textContent.String()) == "" { diff --git a/internal/engine/stream_prompt_test.go b/internal/engine/stream_prompt_test.go index 74fe4861..2e7e2437 100644 --- a/internal/engine/stream_prompt_test.go +++ b/internal/engine/stream_prompt_test.go @@ -20,6 +20,7 @@ func TestIsSmallTalkPrompt(t *testing.T) { }{ {prompt: "Hi", skip: true}, {prompt: "Hello!", skip: true}, + {prompt: "Who are you?", skip: true}, {prompt: "what can you do?", skip: true}, {prompt: "how's it going", skip: true}, {prompt: "good morning", skip: true}, @@ -63,7 +64,7 @@ func TestConversationStaysToolFreeAfterTerminalContextAndHistory(t *testing.T) { read := &countedReadTool{} client := &conversationalStreamClient{resilienceBoundaryClient: &resilienceBoundaryClient{streams: [][]types.RelayStreamEvent{{ {Type: "content", Content: tc.content}, - // A provider may ignore tool_choice=none. The host must still + // A provider may emit unsolicited tool calls. The host must still // avoid repository inspection and tool activity in the UI. {Type: "tool_call", ToolCall: &types.ToolCall{ID: "unexpected", Name: "Read", Arguments: map[string]interface{}{"id": 1}}}, {Type: "done", StopReason: "tool_use"}, @@ -103,13 +104,51 @@ func TestConversationStaysToolFreeAfterTerminalContextAndHistory(t *testing.T) { t.Fatalf("unexpected greeting: %q, requests=%d", reply.String(), len(client.options)) } opts := client.options[0] - if len(opts.Tools) != 0 || opts.ToolChoice == nil || opts.ToolChoice.Type != "none" || strings.Contains(opts.System, "WORKSPACE_INSTRUCTIONS") { + if len(opts.Tools) != 0 || opts.ToolChoice != nil || strings.Contains(opts.System, "WORKSPACE_INSTRUCTIONS") { t.Fatalf("greeting exposed project tools or instructions: %+v", opts) } }) } } +func TestIdentityFollowingGreetingStaysToolFree(t *testing.T) { + client := &conversationalStreamClient{resilienceBoundaryClient: &resilienceBoundaryClient{streams: [][]types.RelayStreamEvent{ + {{Type: "content", Content: "Hi!"}, {Type: "done", StopReason: "end_turn"}}, + {{Type: "content", Content: "I'm Reflect, developed by GrayCodeAI."}, {Type: "done", StopReason: "end_turn"}}, + }}} + s := NewSessionWithClient(client, "test", "test-model", "WORKSPACE_INSTRUCTIONS", tool.NewRegistry(&countedReadTool{}), false) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + for _, text := range []string{"Hi", "Who are you?"} { + s.AddUser(sessioncapture.NewTerminalContext().BuildContext(text)) + events, err := s.Stream(ctx) + if err != nil { + t.Fatal(err) + } + var reply strings.Builder + for event := range events { + switch event.Type { + case "tool_use", "tool_result", "error": + t.Errorf("%q emitted %s: %s", text, event.Type, event.Content) + case "content": + reply.WriteString(event.Content) + } + } + if reply.Len() == 0 { + t.Fatalf("%q returned no reply", text) + } + } + if len(client.options) != 2 { + t.Fatalf("requests = %d, want 2", len(client.options)) + } + for i, opts := range client.options { + // Strict gateways reject even tool_choice=none without tools. + if len(opts.Tools) != 0 || opts.ToolChoice != nil { + t.Errorf("request %d must omit tools and tool choice: %+v", i+1, opts) + } + } +} + func TestWorkFollowingGreetingKeepsTools(t *testing.T) { s := newMockSession(newMockClient()) s.Tools().registry = tool.NewRegistry(&countedReadTool{})