fix(assistant): the step after the ask for the answer may only write text, so Claude Sonnet 5.5 can no longer offer its follow-ups a second time instead (#3016)

This commit is contained in:
Maurice
2026-11-08 20:47:13 +01:00
parent 3be87343b8
commit 5e7d778740
7 changed files with 82 additions and 2 deletions
@@ -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. */
@@ -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' } } : {}),
}
: {}),
};
@@ -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 } : {}),
@@ -111,7 +111,7 @@ function toolsOf(request: AiChatRequest, kind: AiConnectionKind): Record<string,
});
}
if (request.webSearch) tools.push({ type: 'web_search' });
return tools.length > 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. */
@@ -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<StepAnswer | AgentOutcome> {
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<StepAnswer | Age
tools: deps.tools.specs(),
cacheKey: input.cacheKey,
webSearch: deps.webSearch === true && deps.client.webSearch,
...(textOnly ? { toolChoice: 'none' as const } : {}),
},
signal,
);
@@ -535,6 +545,7 @@ function answeredAsk(call: AiToolCall): AssistantMessagePart {
async function askForTheAnswer(turn: Turn, rowId: number, step: StepRecord & { asks: AiToolCall[] }): Promise<null> {
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);
@@ -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(
@@ -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:' }],