fix: name each tool once, and stream the reasoning live (#18)
This commit was merged in pull request #18.
This commit is contained in:
+105
-34
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user