mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 08:11:03 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user