fix: name each tool once, and stream the reasoning live (#18)

This commit was merged in pull request #18.
This commit is contained in:
2026-10-06 14:18:50 +00:00
parent ff99778e5d
commit 550a2bbefe
7 changed files with 426 additions and 58 deletions
+24 -8
View File
@@ -7,6 +7,7 @@ import { isSelfHosted } from "../ollama";
import { OllamaError } from "../ollama";
import type { ResolvedPreferences } from "../preferences";
import { createSession, createTurn, saveSession, withTitleFromFirstTurn } from "../sessions";
import { EMPTY_TRACE, afterToolCall, withFinalTrace, withThinking, type ThinkingTrace } from "../thinking";
import { toolsFor } from "../tools";
import type { PendingAttachment, Session, VisionSupport } from "../types";
import { errorMessage, isAbortError } from "../utils";
@@ -61,6 +62,13 @@ export interface UseChat {
session: Session;
isStreaming: boolean;
streamingResponse: string;
/**
* The reasoning to show right now: only the part since the last tool call.
*
* Deliberately not the whole trace. The full one is carried on the turn for
* storage; this is the live view, and it is cleared each time a tool starts so
* a long research turn does not stack every round's thinking on screen.
*/
streamingThinking: string;
/** Short activity line such as "Searching the web", shown while a turn is in flight. */
status?: string;
@@ -102,7 +110,7 @@ export function useChat(options: UseChatOptions): UseChat {
const abortRef = useRef<AbortController | null>(null);
const liveRef = useRef(false);
const responseRef = useRef("");
const thinkingRef = useRef("");
const thinkingRef = useRef<ThinkingTrace>(EMPTY_TRACE);
const statusRef = useRef<string | undefined>(undefined);
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -131,14 +139,14 @@ export function useChat(options: UseChatOptions): UseChat {
flushTimerRef.current = null;
if (!liveRef.current) return;
setStreamingResponse(responseRef.current);
setStreamingThinking(thinkingRef.current);
setStreamingThinking(thinkingRef.current.current);
}, FLUSH_INTERVAL_MS);
}, []);
const flushNow = useCallback(() => {
clearFlushTimer();
setStreamingResponse(responseRef.current);
setStreamingThinking(thinkingRef.current);
setStreamingThinking(thinkingRef.current.current);
}, [clearFlushTimer]);
const setStatusValue = useCallback((value: string | undefined) => {
@@ -185,7 +193,7 @@ export function useChat(options: UseChatOptions): UseChat {
commit(started);
responseRef.current = "";
thinkingRef.current = "";
thinkingRef.current = EMPTY_TRACE;
setStreamingResponse("");
setStreamingThinking("");
setError(undefined);
@@ -242,11 +250,18 @@ export function useChat(options: UseChatOptions): UseChat {
scheduleFlush();
},
onThinkingDelta: (delta) => {
thinkingRef.current += delta;
thinkingRef.current = withThinking(thinkingRef.current, delta);
scheduleFlush();
},
onStatus: (value) => setStatusValue(value),
onToolStart: (_name, label, count) => setActivity({ label, count }),
onToolStart: (_name, label, count) => {
// The reasoning that produced this call is spent, so the live
// segment is cleared and the tool line takes its place. The full
// trace is untouched, because the stored turn keeps all of it.
thinkingRef.current = afterToolCall(thinkingRef.current);
setActivity({ label, count });
flushNow();
},
onToolCall: (record) => {
toolCalls = [...toolCalls, record];
},
@@ -254,7 +269,7 @@ export function useChat(options: UseChatOptions): UseChat {
});
responseRef.current = result.content;
thinkingRef.current = result.thinking;
thinkingRef.current = withFinalTrace(result.thinking);
toolCalls = result.toolCalls;
} catch (thrown) {
if (isAbortError(thrown)) {
@@ -268,7 +283,7 @@ export function useChat(options: UseChatOptions): UseChat {
const finished = {
...turn,
response: responseRef.current,
thinking: thinkingRef.current,
thinking: thinkingRef.current.full,
toolCalls,
error: failure,
};
@@ -306,6 +321,7 @@ export function useChat(options: UseChatOptions): UseChat {
setError(undefined);
setStreamingResponse("");
setStreamingThinking("");
thinkingRef.current = EMPTY_TRACE;
commit(next);
},
[commit, setStatusValue],
+105 -34
View File
@@ -1,7 +1,7 @@
import { imageSource } from "./attachments";
import { repairCodeBlocks } from "./code";
import { describeActivity } from "./thread";
import type { Attachment, ChatTurn, ThreadMessage } from "./types";
import type { Attachment, ChatTurn, ThreadMessage, ToolCallRecord } from "./types";
/**
* Markdown rendering for the detail panel.
@@ -58,19 +58,62 @@ export function quote(text: string): string {
.join("\n");
}
/** One italic line, well below the weight of the answer it belongs to. */
function renderTools(message: ThreadMessage): string {
const names = message.toolCalls
.map((call) => {
const name = `\`${call.name}\``;
// A refused repeat did not fail, so it is not called a failure: it is the
// loop noticing it was going in circles, which is worth saying plainly.
if (call.repeated) return `${name} (repeated, stopped)`;
return call.isError ? `${name} (failed)` : name;
})
.join(", ");
/** How many calls failed for a reason other than being refused as a repeat. */
function countFailures(calls: ToolCallRecord[]): number {
// A refused repeat is an `isError` too, but it did not fail: it is the loop
// guard noticing a rut, which is a different thing and is said differently.
return calls.filter((call) => call.isError && !call.repeated).length;
}
return `_Tools: ${names}_`;
/** Whether the turn was cut short because a call kept repeating itself. */
function wasStopped(calls: ToolCallRecord[]): boolean {
return calls.some((call) => call.repeated);
}
/**
* One italic line naming the tools a turn used, each mentioned once.
*
* Listing every call in order said nothing a name does not, and on a research
* turn it said it twenty times: `_Tools: web_search, web_search, web_search,
* web_fetch (failed), web_search…_` ran to three lines of the same two words.
* Names in order of first use, then a count of what went wrong, which is the
* only part of that repetition that carries information.
*/
function renderTools(message: ThreadMessage): string {
const names: string[] = [];
for (const call of message.toolCalls) {
if (!names.includes(call.name)) names.push(call.name);
}
const listed = names.map((name) => `\`${name}\``).join(", ");
const failed = countFailures(message.toolCalls);
const notes: string[] = [];
if (failed > 0) notes.push(`${failed} failed`);
if (wasStopped(message.toolCalls)) notes.push("stopped: the same call kept repeating");
const suffix = notes.length > 0 ? ` (${notes.join(", ")})` : "";
return `_Tools: ${listed}${suffix}_`;
}
/**
* The one line shown when tool calls are hidden but something went wrong.
*
* Hiding the list is the user's choice, and a list of names is what they turned
* off. A turn where a call failed or was refused as a repeat is different: the
* answer alone would not account for it, so it is said in a sentence rather than
* dropped, and the names stay hidden.
*/
function renderToolFailureNote(message: ThreadMessage): string | undefined {
const failed = countFailures(message.toolCalls);
const stopped = wasStopped(message.toolCalls);
if (failed === 0 && !stopped) return undefined;
const parts: string[] = [];
if (failed > 0) parts.push(`${failed} tool call${failed === 1 ? "" : "s"} failed`);
if (stopped) parts.push("a call kept repeating, so the turn was cut short");
return `_${parts.join(" and ")}._`;
}
function renderMessage(message: ThreadMessage, options: TranscriptOptions): string {
@@ -82,6 +125,19 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
return images ? `${asked}\n\n${images}` : asked;
}
const thinking = message.thinking.trim();
/**
* Live reasoning is shown while a turn is in flight whether or not the
* preference is on: it is the only sign of progress when a model is thinking
* rather than writing, and waiting on a spinner is worse than reading the
* thought. Once the turn ends it stays only if the user asked for it, so
* `Show Reasoning` still governs the finished transcript.
*/
const liveThinking = message.isStreaming && thinking.length > 0;
/** The stored trace, kept as an appendix under the answer it produced. */
const storedThinking = !message.isStreaming && options.showReasoning && thinking.length > 0;
const showThinking = liveThinking || storedThinking;
// While a tool is running there is no answer text yet, so the activity line
// stands in and the user can see what Lam is actually doing. It is shown
// whether or not tokens have arrived, because a tool that starts after the
@@ -103,38 +159,53 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
// printing the placeholder as well put the same sentence on screen
// twice: once as a note above the transcript and once as the stand-in.
undefined
: message.status
? `_${message.status}…_`
: "_Thinking…_";
: liveThinking
? // The reasoning below is already saying the model is working, so
// "Thinking…" on top of it would only repeat it.
undefined
: message.status
? `_${message.status}…_`
: "_Thinking…_";
// The cursor trails whichever of the two is written last, so it always sits at
// the end of the text arriving rather than on a line of its own.
const written = [activityLine, body].filter((line): line is string => Boolean(line));
const last = written.length - 1;
const sections = written.map((line, index) =>
message.isStreaming && index === last ? `${line} ${STREAMING_CURSOR}` : line,
);
const sections: string[] = [];
const activityIndex = activityLine ? sections.push(activityLine) - 1 : -1;
const bodyIndex = body ? sections.push(body) - 1 : -1;
// The one thing allowed to break the plain-text rule, because it means
// something went wrong and should not be skimmed past.
if (message.error) sections.push("", `**Error:** ${message.error}`);
/**
* Tool calls are a display preference, with one exception: a call that failed,
* or that was refused because it kept repeating, is always listed. Those are
* the two cases where hiding the line would leave the user with an answer they
* cannot account for, and a model stuck in a loop is exactly the thing they
* need to see.
* Tool calls are a display preference. When they are on, every tool is named
* once. When they are off, nothing is listed; a failure or a refused repeat
* still gets one quiet sentence, because those are the cases where hiding the
* line would leave the user with an answer they cannot account for.
*/
const showTools =
options.showToolCalls || message.toolCalls.some((call) => call.isError || call.repeated);
if (showTools && message.toolCalls.length > 0) {
if (options.showToolCalls && message.toolCalls.length > 0) {
sections.push("", renderTools(message));
} else if (!options.showToolCalls) {
const note = renderToolFailureNote(message);
if (note) sections.push("", note);
}
if (options.showReasoning && message.thinking.trim().length > 0) {
sections.push("", "_Reasoning_", "", quote(message.thinking.trim()));
if (showThinking) {
// Only live reasoning can be the first thing in a message, so the blank
// separator is conditional: otherwise the transcript opens on an empty line.
if (sections.length > 0) sections.push("");
sections.push("_Reasoning_", "", quote(thinking));
}
/**
* The cursor marks what is arriving. The answer wins when there is one, then
* the live reasoning, and only then the activity line: reasoning on screen
* means the model is thinking, which is newer than the tool it called to get
* there. It is placed before the quiet sections so it never lands on a tool
* list or an appendix.
*/
if (message.isStreaming) {
const reasoningIndex = showThinking && liveThinking ? sections.length - 1 : -1;
const target = bodyIndex >= 0 ? bodyIndex : reasoningIndex >= 0 ? reasoningIndex : activityIndex;
if (target >= 0) sections[target] = `${sections[target]} ${STREAMING_CURSOR}`;
}
return sections.join("\n");
+53
View File
@@ -0,0 +1,53 @@
/**
* The live reasoning trace for a turn in flight.
*
* Ollama streams reasoning a round at a time: the model thinks, calls a tool,
* thinks again, and so on. Two views of that stream are needed at once, which is
* why this is two strings rather than one.
*
* - `full` is every token the model has thought this turn, across every round.
* It is what gets stored on the turn, so `Show Reasoning` can replay the whole
* trace when the conversation is reopened.
* - `current` is only the reasoning since the last tool call. The live transcript
* shows this one. A research turn that thinks before each of a dozen calls
* would otherwise pile every round's reasoning on screen at once, and the tool
* it just asked for would run underneath a wall of text that is no longer
* about anything.
*
* Kept pure and free of React so the reset is testable without mounting the hook.
*/
export interface ThinkingTrace {
/** Every token this turn, across every round. What gets stored on the turn. */
full: string;
/** Just the reasoning since the last tool call. What the live transcript shows. */
current: string;
}
export const EMPTY_TRACE: ThinkingTrace = { full: "", current: "" };
/** Appends one streamed reasoning delta to both views. */
export function withThinking(trace: ThinkingTrace, delta: string): ThinkingTrace {
return { full: trace.full + delta, current: trace.current + delta };
}
/**
* Clears the current view, leaving the full trace alone.
*
* Called when a tool starts: the reasoning that produced the call has served its
* purpose, and whatever the model thinks next is a new segment rather than a
* continuation of the last one.
*/
export function afterToolCall(trace: ThinkingTrace): ThinkingTrace {
return { full: trace.full, current: "" };
}
/**
* Adopts the authoritative trace the turn ended with.
*
* The agent owns the final value, so this is not a reconstruction: it is the
* one the stream produced, handed back whole.
*/
export function withFinalTrace(full: string): ThinkingTrace {
return { full, current: "" };
}
+8
View File
@@ -12,6 +12,14 @@ 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;