Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions internal/engine/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) == "" {
Expand Down
43 changes: 41 additions & 2 deletions internal/engine/stream_prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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{})
Expand Down
Loading