fix: end a spent tool budget in an answer, not a notice about the loop
Asked for the fish in every Valheim biome, the turn researched for five rounds and then displayed "Stopped after 5 rounds of tool calls without a final answer." Three things compounded: - MAX_TOOL_ITERATIONS of 5 is too few for a question that needs several searches and a few pages. It is a safety net against looping, not a budget anyone chose. - The loop discarded everything. `content` was scoped to the iteration, so every preamble the model wrote was thrown away and the turn ended with an app-generated message about its own control flow. - The model was never told it was running out, so it kept searching until the hard stop instead of answering with what it had. The budget is now spent on a closing round with the tools taken away and an instruction to answer from what was gathered, naming anything it could not confirm. So the worst case is a partial answer that says what is missing. If the closing round produces nothing, the last preamble is used, and only then the notice. The cap is 8 because the extra round is not more searching, it is the answer. Verified: 349 tests, tsc, eslint, prettier, and vici build.
This commit is contained in:
+72
-6
@@ -59,10 +59,36 @@ function normalizeToolCall(call: ToolCall): { name: string; arguments: Record<st
|
||||
return { name, arguments: args as Record<string, unknown> };
|
||||
}
|
||||
|
||||
/**
|
||||
* The last word, when the tool budget runs out.
|
||||
*
|
||||
* The loop used to end by throwing away every preamble the model had written and
|
||||
* returning a notice about itself, so a long research question produced no answer
|
||||
* at all. One closing round, with the tools taken away, turns that into an answer
|
||||
* built from whatever was already gathered, with the gaps named. The instruction
|
||||
* asks for the gaps because an answer that knows what it is missing is worth more
|
||||
* than a confident half of one.
|
||||
*/
|
||||
const CLOSING_INSTRUCTION =
|
||||
"You have reached the limit on tool calls for this turn, so no more tools are available. Answer now using what you have already gathered. If something is missing, say plainly which parts you could not confirm.";
|
||||
|
||||
/** Only reachable if the closing round comes back empty too. */
|
||||
function exhaustedNotice(rounds: number): string {
|
||||
return `_Ran out of tool calls after ${rounds} rounds and could not write an answer. Try asking something narrower._`;
|
||||
}
|
||||
|
||||
export async function runAgent(options: AgentOptions): Promise<AgentResult> {
|
||||
const conversation: ApiMessage[] = [...options.messages];
|
||||
const toolCalls: ToolCallRecord[] = [];
|
||||
let thinking = "";
|
||||
/**
|
||||
* The most recent preamble the model wrote.
|
||||
*
|
||||
* Not normally shown, because a preamble is what it says before finding out
|
||||
* anything. Kept anyway, so that a turn which runs out of budget still has
|
||||
* something to fall back on if the closing round produces nothing.
|
||||
*/
|
||||
let lastPreamble = "";
|
||||
|
||||
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
|
||||
let content = "";
|
||||
@@ -91,20 +117,25 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
|
||||
|
||||
thinking += streamedThinking;
|
||||
|
||||
// No tool calls means this was the model's answer. Earlier iterations only
|
||||
// produced a preamble, which is intentionally discarded rather than shown
|
||||
// as a half-answer.
|
||||
// No tool calls means this was the model's answer, rather than something it
|
||||
// said on the way to finding out.
|
||||
if (!pending || pending.length === 0) {
|
||||
return { content, thinking, toolCalls };
|
||||
}
|
||||
|
||||
if (content.trim()) lastPreamble = content;
|
||||
|
||||
conversation.push({ role: "assistant", content, tool_calls: pending });
|
||||
|
||||
for (const rawCall of pending) {
|
||||
const { name, arguments: args } = normalizeToolCall(rawCall);
|
||||
|
||||
if (!name) {
|
||||
conversation.push({ role: "tool", content: "Malformed tool call: missing function name.", tool_name: "unknown" });
|
||||
conversation.push({
|
||||
role: "tool",
|
||||
content: "Malformed tool call: missing function name.",
|
||||
tool_name: "unknown",
|
||||
});
|
||||
toolCalls.push({ name: "unknown", arguments: {}, result: "Malformed tool call.", isError: true });
|
||||
continue;
|
||||
}
|
||||
@@ -119,7 +150,12 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
const record: ToolCallRecord = { name, arguments: args, result: result.content, isError: result.isError };
|
||||
const record: ToolCallRecord = {
|
||||
name,
|
||||
arguments: args,
|
||||
result: result.content,
|
||||
isError: result.isError,
|
||||
};
|
||||
toolCalls.push(record);
|
||||
options.events?.onToolCall?.(record);
|
||||
|
||||
@@ -127,8 +163,38 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// Budget spent, so the turn gets one closing round with the tools taken away.
|
||||
// Ending on an answer drawn from what was gathered beats ending on a notice
|
||||
// about the loop itself, and the model is the only one that can say what it
|
||||
// did and did not manage to confirm.
|
||||
options.events?.onStatus?.("Writing the answer");
|
||||
|
||||
conversation.push({ role: "user", content: CLOSING_INSTRUCTION });
|
||||
|
||||
let closing = "";
|
||||
|
||||
for await (const chunk of streamChat({
|
||||
host: options.host,
|
||||
apiKey: options.apiKey,
|
||||
model: options.model,
|
||||
messages: conversation,
|
||||
// Deliberately no tools: the answer has to be written from what is already here.
|
||||
tools: undefined,
|
||||
think: options.think,
|
||||
signal: options.signal,
|
||||
})) {
|
||||
if (chunk.thinking) {
|
||||
thinking += chunk.thinking;
|
||||
options.events?.onThinkingDelta?.(chunk.thinking);
|
||||
}
|
||||
if (chunk.content) {
|
||||
closing += chunk.content;
|
||||
options.events?.onContentDelta?.(chunk.content);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: `_Stopped after ${MAX_TOOL_ITERATIONS} rounds of tool calls without a final answer._`,
|
||||
content: closing.trim() || lastPreamble.trim() || exhaustedNotice(MAX_TOOL_ITERATIONS),
|
||||
thinking,
|
||||
toolCalls,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user