feat: auto-delete old conversations, and stop a tool call that repeats (#15)

This commit was merged in pull request #15.
This commit is contained in:
2026-10-05 09:05:15 +00:00
parent 576c5946f7
commit 05fd9ff13b
25 changed files with 2372 additions and 59 deletions
+109 -7
View File
@@ -1,4 +1,4 @@
import { MAX_TOOL_ITERATIONS } from "./constants";
import { DEFAULT_MAX_TOOL_ROUNDS, MAX_REPEATED_TOOL_CALLS } from "./constants";
import { streamChat } from "./ollama";
import { executeTool, toolLabel, type ToolContext } from "./tools";
import type { ApiMessage, ToolCall, ToolCallRecord, ToolDefinition } from "./types";
@@ -14,6 +14,11 @@ export interface AgentEvents {
onContentDelta?: (delta: string) => void;
onThinkingDelta?: (delta: string) => void;
onToolCall?: (record: ToolCallRecord) => void;
/**
* A tool is about to run, with how many times this turn has already made the
* identical call, so the UI can tell progress from a rut.
*/
onToolStart?: (name: string, label: string, count: number) => void;
/** A short line for the UI's activity indicator, e.g. "Searching the web". */
onStatus?: (status: string) => void;
}
@@ -26,6 +31,16 @@ export interface AgentOptions {
think?: string;
messages: ApiMessage[];
tools: ToolDefinition[];
/**
* Rounds of tool calls this turn may spend before it has to answer.
*
* Omitted means {@link DEFAULT_MAX_TOOL_ROUNDS}, and `null` means no cap at
* all. Unlimited is spelled as an explicit `null` rather than as an omitted
* option because the opposite arrangement makes "no cap" the default for a
* function whose whole job is to stop looping, and a caller that forgets to
* pass a preference should not be the one that removes the guard.
*/
maxToolRounds?: number | null;
toolContext: Omit<ToolContext, "signal" | "now"> & { now?: Date };
signal?: AbortSignal;
events?: AgentEvents;
@@ -59,6 +74,31 @@ function normalizeToolCall(call: ToolCall): { name: string; arguments: Record<st
return { name, arguments: args as Record<string, unknown> };
}
/**
* A stable key for "the same call".
*
* Keys are sorted so that a model which writes the same arguments in a different
* order still counts as repeating itself, which is the shape the rut usually
* takes. Values are compared as JSON, so two searches for the same query count as
* one call, which is the point: the second one will not return anything the first
* did not.
*/
function callSignature(name: string, args: Record<string, unknown>): string {
const sorted = Object.keys(args)
.sort()
.map((key) => [key, args[key]] as const);
return `${name}(${JSON.stringify(sorted)})`;
}
/** Why the tool loop stopped short: the budget ran out, or the model got stuck. */
type StopReason = { kind: "budget"; rounds: number } | { kind: "repeat"; name: string; count: number };
/** Sent to the model in place of a tool result, so the protocol stays valid and it learns why. */
function repeatedNotice(name: string, count: number): string {
return `Refused: this turn has now asked for \`${name}\` with identical arguments ${count} times, so running it again will not return anything new. No more tool calls are available. Answer with what you have already gathered.`;
}
/**
* The last word, when the tool budget runs out.
*
@@ -72,14 +112,36 @@ function normalizeToolCall(call: ToolCall): { name: string; arguments: Record<st
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.";
/**
* The closing instruction when the loop stopped because the model was repeating itself.
*
* Worth saying outright rather than reusing the budget wording: the model has no
* way to know it was caught going in circles, and naming the call is what turns
* "I ran out of tools" into something it can act on.
*/
function closingInstruction(reason: StopReason): string {
if (reason.kind === "budget") return CLOSING_INSTRUCTION;
return `This turn asked for \`${reason.name}\` with identical arguments ${reason.count} times, 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._`;
function exhaustedNotice(reason: StopReason): string {
if (reason.kind === "repeat") {
return `_Stopped because \`${reason.name}\` kept repeating the same call, and could not write an answer from what it had. Try asking something narrower._`;
}
return `_Ran out of tool calls after ${reason.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[] = [];
/** `Infinity` needs no special case: the loop simply never reaches its own end. */
const rounds =
options.maxToolRounds === null ? Infinity : (options.maxToolRounds ?? DEFAULT_MAX_TOOL_ROUNDS);
/** How many times this turn has asked for each identical call. */
const repeats = new Map<string, number>();
let thinking = "";
/**
* The most recent preamble the model wrote.
@@ -89,8 +151,9 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
* something to fall back on if the closing round produces nothing.
*/
let lastPreamble = "";
let stopped: StopReason | undefined;
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
for (let iteration = 0; iteration < rounds; iteration++) {
let content = "";
let streamedThinking = "";
let pending: ToolCall[] | undefined;
@@ -127,6 +190,8 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
conversation.push({ role: "assistant", content, tool_calls: pending });
let repeated: { name: string; count: number } | undefined;
for (const rawCall of pending) {
const { name, arguments: args } = normalizeToolCall(rawCall);
@@ -140,6 +205,32 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
continue;
}
const signature = callSignature(name, args);
const count = (repeats.get(signature) ?? 0) + 1;
repeats.set(signature, count);
// Every tool call in a round needs a result, so a refused one is answered
// with the refusal rather than skipped: dropping it would leave the next
// request carrying a tool call the protocol says must be answered.
if (count >= MAX_REPEATED_TOOL_CALLS) {
repeated = { name, count };
const refusal = repeatedNotice(name, count);
const record: ToolCallRecord = {
name,
arguments: args,
result: refusal,
isError: true,
repeated: true,
};
conversation.push({ role: "tool", content: refusal, tool_name: name });
toolCalls.push(record);
options.events?.onToolCall?.(record);
continue;
}
options.events?.onToolStart?.(name, toolLabel(name), count);
options.events?.onStatus?.(toolLabel(name));
const result = await executeTool(name, args, {
@@ -161,15 +252,26 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
conversation.push({ role: "tool", content: result.content, tool_name: name });
}
// The rest of the round still ran, because every call in it needs an answer,
// but the turn is over: the model is going in circles and one more round
// would only add one more circle.
if (repeated) {
stopped = { kind: "repeat", name: repeated.name, count: repeated.count };
break;
}
}
// Budget spent, so the turn gets one closing round with the tools taken away.
// The loop is done, either because the budget is spent or because the model got
// stuck. Either way 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.
const reason: StopReason = stopped ?? { kind: "budget", rounds };
options.events?.onStatus?.("Writing the answer");
conversation.push({ role: "user", content: CLOSING_INSTRUCTION });
conversation.push({ role: "user", content: closingInstruction(reason) });
let closing = "";
@@ -194,7 +296,7 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
}
return {
content: closing.trim() || lastPreamble.trim() || exhaustedNotice(MAX_TOOL_ITERATIONS),
content: closing.trim() || lastPreamble.trim() || exhaustedNotice(reason),
thinking,
toolCalls,
};