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:
@@ -16,7 +16,7 @@ Hit a global shortcut, type a question, get a streamed answer without leaving wh
|
|||||||
- **Persistent conversations.** Every chat is saved locally. The sidebar lists them by recency, and reopening the command picks up where you left off, unless it has been idle longer than you allow.
|
- **Persistent conversations.** Every chat is saved locally. The sidebar lists them by recency, and reopening the command picks up where you left off, unless it has been idle longer than you allow.
|
||||||
- **Readable code.** Fenced blocks render in a dark panel with the language named in a header, syntax colouring, and a Copy button.
|
- **Readable code.** Fenced blocks render in a dark panel with the language named in a header, syntax colouring, and a Copy button.
|
||||||
- **Real markdown.** Tables, bulleted and numbered lists, headings, blockquotes, bold, italics and links all render natively, and the default prompt asks for the shape that fits the answer rather than flattening everything into prose.
|
- **Real markdown.** Tables, bulleted and numbered lists, headings, blockquotes, bold, italics and links all render natively, and the default prompt asks for the shape that fits the answer rather than flattening everything into prose.
|
||||||
- **Tools.** Lam decides when to call them: current time, location context, web search, and page fetching.
|
- **Tools.** Lam decides when to call them: current time, location context, web search, and page fetching. A turn that uses up its tool budget still ends in an answer, written from what it gathered, rather than a notice that it stopped.
|
||||||
- **Screenshots, two ways.** Snip a region into the conversation you are already in from the action panel, or run **Snip to Lam** to start a new chat about it. The launcher window gets out of the way first, so it never ends up in the shot, and the capture appears in the transcript as a thumbnail with Open Screenshot for the full image. Neither is offered unless the current model can actually see images.
|
- **Screenshots, two ways.** Snip a region into the conversation you are already in from the action panel, or run **Snip to Lam** to start a new chat about it. The launcher window gets out of the way first, so it never ends up in the shot, and the capture appears in the transcript as a thumbnail with Open Screenshot for the full image. Neither is offered unless the current model can actually see images.
|
||||||
- **Chat only.** Lam answers questions. It does not touch your files or run commands. The one exception is capture, and it only ever happens because you pressed the key for it.
|
- **Chat only.** Lam answers questions. It does not touch your files or run commands. The one exception is capture, and it only ever happens because you pressed the key for it.
|
||||||
|
|
||||||
@@ -158,7 +158,9 @@ This extension does not use GPS. That would mean CoreLocation on macOS, which ha
|
|||||||
|
|
||||||
**The provider has to be one that answers an unauthenticated caller.** The first choice was ipapi.co, which no longer does: a request with no User-Agent now returns `HTTP 429` with `"Please sign up for a paid plan"`, and adding a browser User-Agent gets an `HTTP 403` Cloudflare bot challenge instead. ipwho.is answers without a key and signals failures with `success: false` on an `HTTP 200`, so the body is checked as well as the status. `GEO_PROVIDER` is a single constant, and swapping it means changing the parser beside it.
|
**The provider has to be one that answers an unauthenticated caller.** The first choice was ipapi.co, which no longer does: a request with no User-Agent now returns `HTTP 429` with `"Please sign up for a paid plan"`, and adding a browser User-Agent gets an `HTTP 403` Cloudflare bot challenge instead. ipwho.is answers without a key and signals failures with `success: false` on an `HTTP 200`, so the body is checked as well as the status. `GEO_PROVIDER` is a single constant, and swapping it means changing the parser beside it.
|
||||||
|
|
||||||
Web search results and fetched pages can be large. Lam truncates them before they reach the model, and gives up after 5 rounds of tool calls rather than looping.
|
Web search results and fetched pages can be large. Lam truncates them before they reach the model.
|
||||||
|
|
||||||
|
**A turn that runs out of tool calls still ends in an answer.** The loop stops after 8 rounds, and the first version of it stopped by throwing away every preamble the model had written and returning a notice about the loop: asked for the fish in every Valheim biome, it researched for five rounds and then said nothing useful at all. The budget is now spent on a *closing round* with the tools taken away and an instruction to answer from what was gathered and to name what it could not confirm. So the worst case is a partial answer that says what is missing, rather than an app-generated message about its own control flow. If the closing round produces nothing either, the last preamble is used, and only then a notice. The closing round is why the cap is 8 rather than 5: the extra round is not more searching, it is the answer.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { pickToolCalls, runAgent } from "../lib/agent";
|
import { pickToolCalls, runAgent } from "../lib/agent";
|
||||||
|
import { MAX_TOOL_ITERATIONS } from "../lib/constants";
|
||||||
import { OllamaError } from "../lib/ollama";
|
import { OllamaError } from "../lib/ollama";
|
||||||
import { toolsFor } from "../lib/tools";
|
import { toolsFor } from "../lib/tools";
|
||||||
import type { ApiMessage, ToolCall } from "../lib/types";
|
import type { ApiMessage, ToolCall } from "../lib/types";
|
||||||
@@ -136,15 +137,102 @@ describe("runAgent", () => {
|
|||||||
|
|
||||||
it("stops after the iteration cap instead of looping forever", async () => {
|
it("stops after the iteration cap instead of looping forever", async () => {
|
||||||
// Every response asks for another tool call, so only the cap can end this.
|
// Every response asks for another tool call, so only the cap can end this.
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < MAX_TOOL_ITERATIONS + 2; i++) {
|
||||||
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime")]));
|
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime")]));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||||
|
|
||||||
expect(result.content).toContain("Stopped after");
|
expect(result.toolCalls).toHaveLength(MAX_TOOL_ITERATIONS);
|
||||||
expect(result.toolCalls).toHaveLength(5);
|
// Every tool round, plus one closing round with the tools taken away.
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
expect(fetchMock).toHaveBeenCalledTimes(MAX_TOOL_ITERATIONS + 1);
|
||||||
|
// The closing round produced no text either, so the notice is the last resort.
|
||||||
|
expect(result.content).toContain("Ran out of tool calls");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the model a closing round so a spent budget still ends in an answer", async () => {
|
||||||
|
// The reported failure: a broad research question used up the budget and the
|
||||||
|
// turn ended with a notice about the loop instead of an answer.
|
||||||
|
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
||||||
|
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("web_search")]));
|
||||||
|
}
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
ndjsonResponse(answer("Meadows have Perch, at 2 fish each. I could not confirm the others.")),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runAgent(baseOptions([{ role: "user", content: "what fish are in valheim" }]));
|
||||||
|
|
||||||
|
expect(result.content).toContain("Meadows have Perch");
|
||||||
|
expect(result.content).not.toContain("Ran out of tool calls");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the tools away for the closing round, so it writes rather than searches again", async () => {
|
||||||
|
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
||||||
|
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("web_search")]));
|
||||||
|
}
|
||||||
|
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("Here is what I found.")));
|
||||||
|
|
||||||
|
await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||||
|
|
||||||
|
const first = JSON.parse(fetchMock.mock.calls[0][1].body as string) as { tools?: unknown };
|
||||||
|
const last = JSON.parse(fetchMock.mock.calls[MAX_TOOL_ITERATIONS][1].body as string) as {
|
||||||
|
tools?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(first.tools).toBeDefined();
|
||||||
|
expect(last.tools).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks the closing round to name what it could not confirm", async () => {
|
||||||
|
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
||||||
|
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("web_search")]));
|
||||||
|
}
|
||||||
|
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("ok")));
|
||||||
|
|
||||||
|
await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||||
|
|
||||||
|
const last = JSON.parse(fetchMock.mock.calls[MAX_TOOL_ITERATIONS][1].body as string) as {
|
||||||
|
messages: ApiMessage[];
|
||||||
|
};
|
||||||
|
const nudge = last.messages[last.messages.length - 1];
|
||||||
|
|
||||||
|
expect(nudge.role).toBe("user");
|
||||||
|
expect(nudge.content).toContain("no more tools are available");
|
||||||
|
expect(nudge.content).toContain("could not confirm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the last preamble when the closing round comes back empty", async () => {
|
||||||
|
// A preamble is not normally the answer, but a turn that gathered things
|
||||||
|
// should still say more than a notice about its own loop.
|
||||||
|
const preamble = {
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: "Let me look that up.",
|
||||||
|
tool_calls: [{ function: { name: "web_search", arguments: {} } }],
|
||||||
|
},
|
||||||
|
done: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
||||||
|
fetchMock.mockResolvedValueOnce(ndjsonResponse([preamble]));
|
||||||
|
}
|
||||||
|
fetchMock.mockResolvedValueOnce(ndjsonResponse([{ message: { role: "assistant", content: "" }, done: true }]));
|
||||||
|
|
||||||
|
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||||
|
|
||||||
|
expect(result.content).toBe("Let me look that up.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reports the tools it used when the budget runs out", async () => {
|
||||||
|
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
||||||
|
fetchMock.mockResolvedValueOnce(ndjsonResponse([toolCall("get_datetime")]));
|
||||||
|
}
|
||||||
|
fetchMock.mockResolvedValueOnce(ndjsonResponse(answer("done")));
|
||||||
|
|
||||||
|
const result = await runAgent(baseOptions([{ role: "user", content: "hi" }]));
|
||||||
|
|
||||||
|
expect(result.toolCalls).toHaveLength(MAX_TOOL_ITERATIONS);
|
||||||
|
expect(result.toolCalls.every((call) => call.name === "get_datetime")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces an actionable hint when the API key is rejected", async () => {
|
it("surfaces an actionable hint when the API key is rejected", async () => {
|
||||||
|
|||||||
+72
-6
@@ -59,10 +59,36 @@ function normalizeToolCall(call: ToolCall): { name: string; arguments: Record<st
|
|||||||
return { name, arguments: args as Record<string, unknown> };
|
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> {
|
export async function runAgent(options: AgentOptions): Promise<AgentResult> {
|
||||||
const conversation: ApiMessage[] = [...options.messages];
|
const conversation: ApiMessage[] = [...options.messages];
|
||||||
const toolCalls: ToolCallRecord[] = [];
|
const toolCalls: ToolCallRecord[] = [];
|
||||||
let thinking = "";
|
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++) {
|
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
|
||||||
let content = "";
|
let content = "";
|
||||||
@@ -91,20 +117,25 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
|
|||||||
|
|
||||||
thinking += streamedThinking;
|
thinking += streamedThinking;
|
||||||
|
|
||||||
// No tool calls means this was the model's answer. Earlier iterations only
|
// No tool calls means this was the model's answer, rather than something it
|
||||||
// produced a preamble, which is intentionally discarded rather than shown
|
// said on the way to finding out.
|
||||||
// as a half-answer.
|
|
||||||
if (!pending || pending.length === 0) {
|
if (!pending || pending.length === 0) {
|
||||||
return { content, thinking, toolCalls };
|
return { content, thinking, toolCalls };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (content.trim()) lastPreamble = content;
|
||||||
|
|
||||||
conversation.push({ role: "assistant", content, tool_calls: pending });
|
conversation.push({ role: "assistant", content, tool_calls: pending });
|
||||||
|
|
||||||
for (const rawCall of pending) {
|
for (const rawCall of pending) {
|
||||||
const { name, arguments: args } = normalizeToolCall(rawCall);
|
const { name, arguments: args } = normalizeToolCall(rawCall);
|
||||||
|
|
||||||
if (!name) {
|
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 });
|
toolCalls.push({ name: "unknown", arguments: {}, result: "Malformed tool call.", isError: true });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -119,7 +150,12 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
|
|||||||
signal: options.signal,
|
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);
|
toolCalls.push(record);
|
||||||
options.events?.onToolCall?.(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 {
|
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,
|
thinking,
|
||||||
toolCalls,
|
toolCalls,
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-2
@@ -7,8 +7,17 @@ export const DEFAULT_MODEL = "gpt-oss:120b";
|
|||||||
export const STORAGE_INDEX_KEY = "lam.sessions.index";
|
export const STORAGE_INDEX_KEY = "lam.sessions.index";
|
||||||
export const STORAGE_SESSION_PREFIX = "lam.session.";
|
export const STORAGE_SESSION_PREFIX = "lam.session.";
|
||||||
|
|
||||||
/** How many times the model may call tools before Lam gives up and reports back. */
|
/**
|
||||||
export const MAX_TOOL_ITERATIONS = 5;
|
* How many rounds of tool calls one turn may spend before the model is told to
|
||||||
|
* answer with what it has.
|
||||||
|
*
|
||||||
|
* Five was too few for a question that needs several searches and a few pages,
|
||||||
|
* and the failure mode was the worst one available: the budget ran out, every
|
||||||
|
* preamble the model had written was discarded, and the turn ended with a notice
|
||||||
|
* about the limit rather than an answer. The closing round in `agent.ts` now
|
||||||
|
* turns that into an answer, and this is the room it gets to gather first.
|
||||||
|
*/
|
||||||
|
export const MAX_TOOL_ITERATIONS = 8;
|
||||||
|
|
||||||
/** Oldest sessions past this count are evicted, along with their storage keys. */
|
/** Oldest sessions past this count are evicted, along with their storage keys. */
|
||||||
export const MAX_SESSIONS = 100;
|
export const MAX_SESSIONS = 100;
|
||||||
|
|||||||
Reference in New Issue
Block a user