diff --git a/server/src/nest/ai-connections/ai-chat-client.interface.ts b/server/src/nest/ai-connections/ai-chat-client.interface.ts index b8eb766f4..c3fde730b 100644 --- a/server/src/nest/ai-connections/ai-chat-client.interface.ts +++ b/server/src/nest/ai-connections/ai-chat-client.interface.ts @@ -54,6 +54,12 @@ export interface AiChatRequest { cacheKey?: string; /** Lets the model search the web with its provider's own search; a client without one ignores it. */ webSearch?: boolean; + /** + * `none` asks for text only: the tools are still sent, so the tool calls + * already in the history stay valid, but the model may not call one. + * Absent means the model chooses (#3016). + */ + toolChoice?: 'auto' | 'none'; } /** A page the model's answer draws on, from its web search. */ diff --git a/server/src/nest/ai-connections/providers/anthropic-messages.client.ts b/server/src/nest/ai-connections/providers/anthropic-messages.client.ts index 9f991828d..c4c002e8c 100644 --- a/server/src/nest/ai-connections/providers/anthropic-messages.client.ts +++ b/server/src/nest/ai-connections/providers/anthropic-messages.client.ts @@ -168,6 +168,8 @@ export class AnthropicMessagesClient implements AiChatClient { })), ...(request.webSearch ? [WEB_SEARCH_TOOL] : []), ], + // `none` is allowed with extended thinking, unlike forcing a tool. + ...(request.toolChoice === 'none' ? { tool_choice: { type: 'none' } } : {}), } : {}), }; diff --git a/server/src/nest/ai-connections/providers/openai-chat.client.ts b/server/src/nest/ai-connections/providers/openai-chat.client.ts index fe44aebed..1c3a988b4 100644 --- a/server/src/nest/ai-connections/providers/openai-chat.client.ts +++ b/server/src/nest/ai-connections/providers/openai-chat.client.ts @@ -143,7 +143,7 @@ export class OpenAiChatClient implements AiChatClient { parameters: toProviderSchema(tool.parameters, this.schemaKind), }, })), - tool_choice: 'auto', + tool_choice: request.toolChoice ?? 'auto', } : {}), ...(this.kind === 'openai' && request.cacheKey ? { prompt_cache_key: request.cacheKey } : {}), diff --git a/server/src/nest/ai-connections/providers/openai-responses.client.ts b/server/src/nest/ai-connections/providers/openai-responses.client.ts index b0652f200..45ba97e16 100644 --- a/server/src/nest/ai-connections/providers/openai-responses.client.ts +++ b/server/src/nest/ai-connections/providers/openai-responses.client.ts @@ -111,7 +111,7 @@ function toolsOf(request: AiChatRequest, kind: AiConnectionKind): Record 0 ? { tools, tool_choice: 'auto', parallel_tool_calls: true } : {}; + return tools.length > 0 ? { tools, tool_choice: request.toolChoice ?? 'auto', parallel_tool_calls: true } : {}; } /** The pages an output message cites, as its text's URL annotations name them. */ diff --git a/server/src/nest/assistant/assistant-agent.ts b/server/src/nest/assistant/assistant-agent.ts index caec30b6a..83e7f53c4 100644 --- a/server/src/nest/assistant/assistant-agent.ts +++ b/server/src/nest/assistant/assistant-agent.ts @@ -272,6 +272,12 @@ interface Turn { * still gets its answer written (#2789); each is asked at most once. */ askedForAnswer: boolean; + /** + * The next request may only be answered with text: the step after the ask + * for the answer. Claude Sonnet 5.5 offered its follow-ups again instead of + * writing, and the turn ended without an answer (#3016). + */ + textOnlyNext: boolean; /** The chips of follow-ups offered before any answer, shown under the answer once it is written. */ followUps: AssistantFollowUps | null; } @@ -360,6 +366,9 @@ function searchParts( /** Streams one answer of the model; a failure or a stop ends the turn here, saved as far as it got. */ async function streamAnswer(turn: Turn, rowId: number): Promise { const { deps, input, w, signal } = turn; + // Taken once: only the step right after the ask for the answer is held to text. + const textOnly = turn.textOnlyNext; + turn.textOnlyNext = false; const answer: StepAnswer = { text: '', received: [], @@ -377,6 +386,7 @@ async function streamAnswer(turn: Turn, rowId: number): Promise { const { asks, providerState } = step; turn.askedForAnswer = true; + turn.textOnlyNext = true; turn.followUps = followUpsOf(asks[asks.length - 1].args); const parts = [...step.parts, ...asks.map((call) => ({ ...answeredAsk(call), result: WRITE_THE_ANSWER }))]; await turn.deps.store.saveStep(rowId, { parts, providerState }, 'complete'); @@ -673,6 +684,7 @@ export async function runAgentLoop( knownPlaceIds, nudged: false, askedForAnswer: false, + textOnlyNext: false, followUps: null, }; return stepsFrom(turn, 0); diff --git a/server/tests/unit/nest/ai-connections/provider-adapters.test.ts b/server/tests/unit/nest/ai-connections/provider-adapters.test.ts index 1fa197cea..8a36d1c53 100644 --- a/server/tests/unit/nest/ai-connections/provider-adapters.test.ts +++ b/server/tests/unit/nest/ai-connections/provider-adapters.test.ts @@ -238,6 +238,23 @@ describe('AnthropicMessagesClient', () => { expect([0, 1, 2, 3].map((i) => dig(body, 'messages', i, 'role'))).toEqual(['user', 'assistant', 'user', undefined]); }); + it('ANTH-009: a text-only request keeps the tools for the history and sets tool_choice none (#3016)', async () => { + const http = fetchAnswering(streamResponse(STREAM)); + await run( + new AnthropicMessagesClient('claude-sonnet', 'sk-ant-key', http.fetcher).stream( + { ...REQUEST, toolChoice: 'none' }, + signal(), + ), + ); + const body = http.body(); + expect(body.tool_choice).toEqual({ type: 'none' }); + expect(dig(body, 'tools', 0, 'name')).toBe('create_place'); + + const plain = fetchAnswering(streamResponse(STREAM)); + await run(new AnthropicMessagesClient('claude-sonnet', 'sk-ant-key', plain.fetcher).stream(REQUEST, signal())); + expect(plain.body().tool_choice).toBeUndefined(); + }); + it('ANTH-006: marks the end of the history and the end of what came before the question for the cache', async () => { const http = fetchAnswering(streamResponse(STREAM)); await run(new AnthropicMessagesClient('m', 'k', http.fetcher).stream(REQUEST, signal())); @@ -458,6 +475,17 @@ describe('OpenAiChatClient', () => { ]); }); + it('OACH-009: tool_choice is auto unless the request asks for text only (#3016)', async () => { + const client = (fetcher: AiFetch) => + new OpenAiChatClient('openai', 'gpt-5-mini', 'https://api.openai.com/v1', 'sk-key', fetcher); + const auto = fetchAnswering(streamResponse(STREAM)); + await run(client(auto.fetcher).stream(REQUEST, signal())); + expect(auto.body().tool_choice).toBe('auto'); + const none = fetchAnswering(streamResponse(STREAM)); + await run(client(none.fetcher).stream({ ...REQUEST, toolChoice: 'none' }, signal())); + expect(none.body().tool_choice).toBe('none'); + }); + it('OACH-002: streams the text and assembles the tool call by its index', async () => { const events = await run( new OpenAiChatClient('openai', 'm', 'https://x', 'k', fetchAnswering(streamResponse(STREAM)).fetcher).stream( @@ -689,6 +717,20 @@ describe('OpenAiResponsesClient', () => { expect(dig(body, 'input', 0)).toEqual({ type: 'message', role: 'developer', content: 'Trip: Japan, 3 days' }); }); + it('OARS-006: tool_choice is auto unless the request asks for text only (#3016)', async () => { + const auto = fetchAnswering(streamResponse(STREAM)); + await run(new OpenAiResponsesClient('gpt-6.1-sol', tokens('at-1'), auto.fetcher).stream(REQUEST, signal())); + expect(auto.body().tool_choice).toBe('auto'); + const none = fetchAnswering(streamResponse(STREAM)); + await run( + new OpenAiResponsesClient('gpt-6.1-sol', tokens('at-1'), none.fetcher).stream( + { ...REQUEST, toolChoice: 'none' }, + signal(), + ), + ); + expect(none.body().tool_choice).toBe('none'); + }); + it('OARS-002: streams text, yields the call, then usage, the items to replay and the finish', async () => { const events = await run( new OpenAiResponsesClient('m', tokens('at'), fetchAnswering(streamResponse(STREAM)).fetcher).stream( diff --git a/server/tests/unit/nest/assistant/assistant-agent.test.ts b/server/tests/unit/nest/assistant/assistant-agent.test.ts index 4326f873d..0d89d910a 100644 --- a/server/tests/unit/nest/assistant/assistant-agent.test.ts +++ b/server/tests/unit/nest/assistant/assistant-agent.test.ts @@ -830,6 +830,10 @@ describe('runAgentLoop', () => { ]); const outcome = await runAgentLoop(t.deps, t.input, t.writer, new AbortController().signal); expect(outcome.end).toBe('stop'); + // Only the step after the ask may not call a tool: a model offering follow-ups again has to write (#3016). + expect(t.requests[0].toolChoice).toBeUndefined(); + expect(t.requests[1].toolChoice).toBe('none'); + expect(t.requests[1].tools.length).toBeGreaterThan(0); const second = t.requests[1].messages; expect(second.at(-2)).toEqual({ role: 'assistant', @@ -860,6 +864,20 @@ describe('runAgentLoop', () => { expect(t.requests).toHaveLength(2); }); + it('ASTAGT-047: the text-only request is the one step after the ask, never the ones before or after it (#3016)', async () => { + const t = setup([ + [{ type: 'tool-call', call: { id: 'l1', name: 'list_places', args: {} } }], + [ask('toolu_9', ['More stops']), { type: 'finish', reason: 'tool-calls' }], + [ + { type: 'text-delta', delta: 'Two stops so far.' }, + { type: 'finish', reason: 'stop' }, + ], + ]); + const outcome = await runAgentLoop(t.deps, t.input, t.writer, new AbortController().signal); + expect(outcome.end).toBe('stop'); + expect(t.requests.map((request) => request.toolChoice)).toEqual([undefined, undefined, 'none']); + }); + it('ASTAGT-045: follow-ups with no answer after an announcing first answer are still asked for the answer', async () => { const t = setup([ [{ type: 'text-delta', delta: 'Let me check your places:' }],