fix(assistant): send a blind write back with its schema, pair unrun calls with their answers, and give the wordless-answer recovery its own nudge

A write from a group the turn had not loaded was held as a change card with
schema-less arguments and failed on apply (#2967); it now goes back to the
model with the group loaded. Calls answered as not run are now listed on
the assistant turn too, so a thread replayed on another provider has no
orphan tool results. The blind-call hint only goes with the blind call.
This commit is contained in:
Maurice
2026-11-05 13:02:25 +01:00
parent 91f6430762
commit c3357ebea4
6 changed files with 222 additions and 20 deletions
+14 -4
View File
@@ -264,8 +264,14 @@ interface Turn {
history: AiChatMessage[];
usage: { inputTokens: number; cachedInputTokens: number; outputTokens: number };
knownPlaceIds: Set<string>;
/** Whether the turn used its one push (an empty, cut-off, announcing or wordless answer). */
/** Whether the turn used its one push (an empty, cut-off or announcing answer). */
nudged: boolean;
/**
* Whether the turn already asked for the answer after a step that only
* offered follow-ups. Apart from the push, so a turn that was pushed once
* still gets its answer written (#2789); each is asked at most once.
*/
askedForAnswer: boolean;
/** The chips of follow-ups offered before any answer, shown under the answer once it is written. */
followUps: AssistantFollowUps | null;
}
@@ -528,7 +534,7 @@ function answeredAsk(call: AiToolCall): AssistantMessagePart {
*/
async function askForTheAnswer(turn: Turn, rowId: number, step: StepRecord & { asks: AiToolCall[] }): Promise<null> {
const { asks, providerState } = step;
turn.nudged = true;
turn.askedForAnswer = 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');
@@ -560,12 +566,15 @@ async function runStep(turn: Turn, step: number): Promise<AgentOutcome | null> {
...searched.tail,
];
const empty = received.length === 0 && !text.trim();
if (empty && asks.length > 0 && !turn.nudged) return askForTheAnswer(turn, rowId, { parts, providerState, asks });
if (empty && asks.length > 0 && !turn.askedForAnswer)
return askForTheAnswer(turn, rowId, { parts, providerState, asks });
// An answer that offered follow-ups is finished, however it ends.
const settled = asks.length > 0 && !empty;
const announced = !settled && received.length === 0 && step === 0 && ANNOUNCED.test(text.trimEnd());
const push = empty || (!settled && received.length === 0 && cutOff) ? GO_ON : announced ? MAKE_THE_CALLS : null;
if (push && !turn.nudged) {
// Follow-ups with no answer a second time end the turn: the model was already asked for the answer.
const wordlessAgain = empty && asks.length > 0;
if (push && !turn.nudged && !wordlessAgain) {
turn.nudged = true;
await deps.store.saveStep(rowId, { parts, providerState }, 'complete');
w.finishStep();
@@ -663,6 +672,7 @@ export async function runAgentLoop(
usage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
knownPlaceIds,
nudged: false,
askedForAnswer: false,
followUps: null,
};
return stepsFrom(turn, 0);
+26 -13
View File
@@ -4,6 +4,7 @@ import type { AssistantToolCallRow } from '../../db/repositories/AssistantToolCa
import { parseDbTimestamp, utcSuffix } from '../../db/types';
import { decodeJson } from '../../utils/json-column';
import type { AiChatMessage, AiProviderState, AiToolCall } from '../ai-connections/ai-chat-client.interface';
import { asRecord, asText, parseToolArgs } from '../ai-connections/providers/provider-http';
import { tourFileOutcome, tourFileReadNote, tourStopOutcome } from './assistant-history-tours';
import { fitToSize } from './assistant-output';
import {
@@ -48,17 +49,23 @@ const NOT_RUN = 'This call did not run: the answer was stopped before it.';
* The calls a provider's own record of a step holds, which it replays as they
* came and wants answered: Anthropic's tool uses, the Responses API's function
* calls. A step stopped while its calls ran (the user left the page) keeps the
* whole record but answers only the calls that ran.
* whole record but answers only the calls that ran. The arguments come along,
* so an adapter that rebuilds the step from its calls (another provider than
* the one that recorded it) still finds every call an answer names.
*/
function recordedCalls(state: AiProviderState | null): Array<{ id: string; name: string }> {
const entries =
function recordedCalls(state: AiProviderState | null): AiToolCall[] {
const calls =
state?.adapter === 'anthropic-messages'
? state.blocks.filter((block) => block.type === 'tool_use').map((block) => [block.id, block.name])
? state.blocks
.filter((block) => block.type === 'tool_use')
.map((block) => ({ id: block.id, name: block.name, args: asRecord(block.input) }))
: state?.adapter === 'openai-responses'
? state.items.filter((item) => item.type === 'function_call').map((item) => [item.call_id, item.name])
? state.items
.filter((item) => item.type === 'function_call')
.map((item) => ({ id: item.call_id, name: item.name, args: parseToolArgs(asText(item.arguments)) }))
: [];
return entries.flatMap(([id, name]) =>
typeof id === 'string' ? [{ id, name: typeof name === 'string' ? name : '' }] : [],
return calls.flatMap(({ id, name, args }) =>
typeof id === 'string' ? [{ id, name: typeof name === 'string' ? name : '', args }] : [],
);
}
@@ -314,7 +321,16 @@ export function toChatHistory(
const text = textOf(parts);
if (!text && toolCalls.length === 0) return;
const providerState = decodeJson(ASSISTANT_PROVIDER_STATE, row.provider_state, `message ${row.id}`);
history.push({ role: 'assistant', text, toolCalls, providerState });
// The recorded calls that never ran go into the step's calls as well: an adapter that does not
// replay this record rebuilds the step from them, and would otherwise find answers to calls it never made.
const answered = new Set(toolCalls.map((call) => call.id));
const unrun: AiToolCall[] = [];
for (const recorded of recordedCalls(providerState)) {
if (answered.has(recorded.id)) continue;
answered.add(recorded.id);
unrun.push(recorded);
}
history.push({ role: 'assistant', text, toolCalls: [...toolCalls, ...unrun], providerState });
calls.forEach((part, index) => {
history.push({
role: 'tool',
@@ -324,11 +340,8 @@ export function toChatHistory(
...(part.isError ? { isError: true } : {}),
});
});
const answered = new Set(toolCalls.map((call) => call.id));
for (const recorded of recordedCalls(providerState)) {
if (answered.has(recorded.id)) continue;
answered.add(recorded.id);
history.push({ role: 'tool', toolCallId: recorded.id, name: recorded.name, output: NOT_RUN, isError: true });
for (const call of unrun) {
history.push({ role: 'tool', toolCallId: call.id, name: call.name, output: NOT_RUN, isError: true });
}
});
return history;
@@ -266,9 +266,12 @@ export class AssistantToolSession {
/** The groups this turn loaded; the core is always there. */
private readonly loaded = new Set<string>();
/** Tools the model called before their group was loaded in this turn, by name, with that group. */
/** Reads the model called before their group was loaded in this turn, by name, with that group; told once. */
private readonly calledBlind = new Map<string, string>();
/** Groups a blind call loaded whose schemas no step has been offered yet. */
private readonly unoffered = new Set<string>();
/** Whether the session offers its tools by group: a trip's turn and a home turn do. */
private get grouped(): boolean {
return this.mode === 'turn' || this.mode === 'home';
@@ -280,6 +283,8 @@ export class AssistantToolSession {
* journey's session offers all it holds and no more.
*/
specs(): AiToolSpec[] {
// The step these specs go to sees every loaded group's schema.
this.unoffered.clear();
const listed = [...this.tools.values()]
.filter((tool) => tool.offered)
.filter((tool) => !this.grouped || tool.hint.group === null || this.loaded.has(tool.hint.group))
@@ -338,9 +343,25 @@ export class AssistantToolSession {
// The model called a tool it was not offered in this turn (it knows the
// name from an earlier turn), so it wrote the arguments without the
// schema (#2967). Its group is loaded here, so the next step offers the
// schema, and a refusal of these arguments says so (see call()).
// schema.
this.loaded.add(unloaded);
this.calledBlind.set(name, unloaded);
this.unoffered.add(unloaded);
}
const group = this.tools.get(name)?.hint.group;
if (group && this.unoffered.has(group)) {
// A change is never run in the turn but held as a card, and the turn
// ends there: its arguments would only fail once the user applies it,
// with the model long gone. It is sent back for the next step instead.
if (this.tools.get(name)?.kind === 'write') {
return {
error: `${name} belongs to the "${group}" tools, which were not loaded in this turn, so it did not run. They are loaded now: send the call again with the arguments exactly as its schema describes them.`,
};
}
// A read runs at once; a refusal of its arguments says why (see call()).
this.calledBlind.set(name, group);
} else {
// A blind read refused before it ran (another trip, an unknown place) must not hint a later call.
this.calledBlind.delete(name);
}
const given = pinNamesOf(this.pinArg).filter((key) => args[key] !== undefined);
if (given.some((key) => Number(args[key]) !== this.pinnedId)) {
@@ -361,7 +382,10 @@ export class AssistantToolSession {
const final = name === TRANSIT_CREATE_TOOL ? await this.withFoundItinerary(args) : args;
const run = await this.invoke(name, final);
this.audit(name);
// Only the blind call itself is told about the schema: a later failure of the
// same tool, written with the schema in hand, has another reason.
const blind = this.calledBlind.get(name);
this.calledBlind.delete(name);
if (!run.ok && blind) {
const hint = `${name} was called before the "${blind}" tools were loaded in this turn, so its arguments were written without its schema. They are loaded now and the schema is in your tools: send the call again with the arguments exactly as it describes them.`;
return {