Files
lam-agent-vicinae/src/lib/thread.ts
T
adrianbonpin bf2b5f40a2 fix: name each tool once, and stream the reasoning live
The transcript listed every tool call in order, so a research turn read
`_Tools: web_search, web_search, web_search, web_fetch (failed), ..._` over
three lines of the same two words. Each tool is now named once, in the order
it was first used, with the failures counted: `_Tools: web_search, web_fetch
(4 failed)_`.

`Show Tool Calls` now actually hides the list. The old rule kept the whole
list on screen whenever any call failed or was refused as a repeat, which is
what left the setting looking broken. Failures are still mentioned, in one
sentence and without the names: `_2 tool calls failed._`, or `_a call kept
repeating, so the turn was cut short._`.

Reasoning now streams into the transcript while a turn is in flight whatever
`Show Reasoning` says, because a model that is thinking rather than writing
has nothing else on screen; the preference governs only where the trace
settles once the turn ends. The live view is the segment since the last tool
call, cleared at each tool start and kept whole for storage, so a dozen
rounds of thinking no longer stack on screen at once.
2026-10-06 22:18:13 +08:00

90 lines
3.3 KiB
TypeScript

import type { ChatTurn, ThreadMessage, ToolActivity } from "./types";
/**
* Flattens stored turns into the flat message list the transcript renders.
*
* Storage keeps a question and its answer together as a turn, because that is
* what gets replayed to the model. The transcript needs a flat sequence, newest
* message first. This is the one place that translation happens.
*/
export interface LiveState {
isStreaming: boolean;
/** Partial answer for the turn currently in flight. */
response: string;
/**
* Reasoning to show for the turn in flight.
*
* Only the part since the last tool call, not the whole trace: the live
* transcript clears it each time a tool starts, so a research turn does not
* stack every round's thinking on screen. The stored turn keeps the whole
* trace separately.
*/
thinking: string;
/** Activity line, e.g. "Searching the web". Only meaningful mid-turn. */
status?: string;
/** The tool being waited on right now, which supersedes the plain status when present. */
activity?: ToolActivity;
}
/**
* One line describing a tool that is running.
*
* Says "the same call again" rather than showing a bare count, because a number
* on its own reads as progress. This is the visible half of the loop guard in
* `agent.ts`, and the only warning the user gets before a turn is cut short.
*/
export function describeActivity(activity: ToolActivity): string {
if (activity.count <= 1) return activity.label;
return `${activity.label} · the same call again (${activity.count})`;
}
export function buildThread(turns: ChatTurn[], live: LiveState): ThreadMessage[] {
const messages: ThreadMessage[] = [];
// Strictly newest message first. The search bar sits at the top of the window,
// so the most recent thing, an answer once one has been written, should be
// the first line under it. A consequence is that within an exchange the answer
// comes before its question, which is the intended reading order here.
for (let index = turns.length - 1; index >= 0; index--) {
const turn = turns[index];
// Only the newest turn can be mid-flight.
const isLive = index === turns.length - 1 && live.isStreaming;
const text = isLive ? live.response : turn.response;
messages.push({
id: `${turn.id}:assistant`,
role: "assistant",
text,
createdAt: turn.createdAt,
isStreaming: isLive,
thinking: isLive ? live.thinking : turn.thinking,
toolCalls: turn.toolCalls,
error: isLive ? undefined : turn.error,
// Only while there is nothing to read yet. Once tokens land, the
// placeholder it feeds is gone anyway.
status: isLive && text.length === 0 ? live.status : undefined,
// The tool, on the other hand, is worth naming even after the answer has
// started arriving: a tool that begins mid-answer would otherwise be
// invisible, which was the original complaint about hidden tool calls.
activity: isLive ? live.activity : undefined,
// Only a question carries attachments, so the answer never has one.
attachments: [],
});
messages.push({
id: `${turn.id}:user`,
role: "user",
text: turn.prompt,
createdAt: turn.createdAt,
isStreaming: false,
thinking: "",
toolCalls: [],
attachments: turn.attachments,
});
}
return messages;
}