feat(core,web,cli): add file tools and per-tool call descriptions (#62)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+246
-34
@@ -1,22 +1,52 @@
|
||||
/**
|
||||
* Streaming tool-call rendering (CLI side).
|
||||
*
|
||||
* The CLI only consumes `partial_tool_call` for visible rendering. exec_command is shown as
|
||||
* `$ <cmd>` as early as possible; input_command / input_subagent show the target session id,
|
||||
* with a non-empty payload (chars / prompt) appended as `<< <content>` — the payload is
|
||||
* critical for approval and later audit (writing to stdin is equivalent to running a command),
|
||||
* so the session id alone is not enough; run_subagent shows the prompt; other tools fall back
|
||||
* to `name(args-prefix)`.
|
||||
* The CLI only consumes `partial_tool_call` for visible rendering. Formats (the `<-` marker
|
||||
* reads "input to the tool", paired with the `->` output gutter in render.ts):
|
||||
* - exec_command: `exec_command <- $ {cmd}`, or with a model-written description
|
||||
* `exec_command <- {description} ($ {cmd})`;
|
||||
* - input_command: `input_command <- {process_id} << {chars}` (`<< …` only when writing;
|
||||
* an empty payload just polls), or `input_command <- {description} ({process_id} << {chars})`;
|
||||
* - run_subagent: `run_subagent <- {prompt}` or `run_subagent <- {description} ({prompt})`;
|
||||
* - input_subagent: `input_subagent <- {subagent_id} << {prompt}` or
|
||||
* `input_subagent <- {description} ({subagent_id} << {prompt})`;
|
||||
* - file tools (read_file / edit_file / write_file): `{name} {shortened path}` — the path is
|
||||
* shortened to at most one parent directory (`…/parent/file.ts`); the full path stays in
|
||||
* the arguments.
|
||||
* The payload (chars / prompt) is critical for approval and later audit (writing to stdin
|
||||
* is equivalent to running a command), so the session id alone is never enough; other tools
|
||||
* fall back to `name(args-prefix)`.
|
||||
*
|
||||
* The render layer streams by appending to the preview (see render.ts), so the preview format
|
||||
* must stay append-only: rendering only starts once the target id has fully appeared, the
|
||||
* payload is only appended at the end, and the preview stops growing once it hits the
|
||||
* truncation limit.
|
||||
* The render layer streams by appending to the preview (see render.ts), so the preview must
|
||||
* stay append-only — a preview that is not an extension of the previous one costs a fresh
|
||||
* line, leaving the superseded one on screen. Which of the two forms a call will take is
|
||||
* therefore decided **before** its arguments stream, from the tool's assembled schema
|
||||
* (`expectDescription`, taken from `session_meta.tools` — the per-tool `call_description`
|
||||
* switch decides whether the argument exists at all; see the docs on tool configuration):
|
||||
* - schema without the argument (and the unknown case) → the plain form streams immediately,
|
||||
* character by character, and any stray `description` is ignored;
|
||||
* - schema with it → the description is awaited: it streams live as it grows
|
||||
* (`name <- desc…`) and the payload is appended once its value completes
|
||||
* (`name <- desc ({payload…` → `)`), so the plain form never reaches the screen whichever
|
||||
* order the model emits its arguments in. The wait is bounded: the argument is declared
|
||||
* **required**, so a schema-abiding model always sends one, and it is asked to send it
|
||||
* first.
|
||||
* A `final` fragment (stream ended, or arguments from a complete message) is settled by
|
||||
* definition and renders whichever form the arguments actually carry — which is also what
|
||||
* catches a model that violates the schema and omits the required argument.
|
||||
* File-tool paths render only once complete — shortening a still-growing path would rewrite
|
||||
* the line.
|
||||
*/
|
||||
|
||||
/** Max length of the single-line preview for a payload (chars / prompt); truncated with an ellipsis beyond this, after which the preview stops growing. */
|
||||
/** Max length of the single-line preview for a payload (chars / prompt / description); truncated with an ellipsis beyond this, after which the preview stops growing. */
|
||||
const MAX_PAYLOAD_PREVIEW = 120;
|
||||
|
||||
/** Max lines of a file-tool payload printed before the approval prompt; the rest is elided with a count. */
|
||||
const MAX_APPROVAL_PAYLOAD_LINES = 24;
|
||||
|
||||
/** Max characters per printed approval-payload line (the full text stays in the trace). */
|
||||
const MAX_APPROVAL_PAYLOAD_LINE = 200;
|
||||
|
||||
/** Collapse to a single line: newlines/runs of whitespace become a single space, and leading/trailing whitespace is trimmed. */
|
||||
function toSingleLine(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
@@ -44,8 +74,14 @@ function capPreview(text: string): string {
|
||||
return text.length > MAX_PAYLOAD_PREVIEW ? `${text.slice(0, MAX_PAYLOAD_PREVIEW)}…` : text;
|
||||
}
|
||||
|
||||
/** Extract the current value of a string field from a possibly-incomplete JSON object string. */
|
||||
function extractPartialStringField(argsJson: string, field: string): string | null {
|
||||
/** A string field extracted from possibly-incomplete JSON: its value so far, and whether the closing quote was seen. */
|
||||
interface PartialField {
|
||||
value: string;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/** Extract a string field from a possibly-incomplete JSON object string, reporting completeness. */
|
||||
function extractField(argsJson: string, field: string): PartialField | null {
|
||||
const key = `"${field}"`;
|
||||
const keyIndex = argsJson.indexOf(key);
|
||||
if (keyIndex === -1) return null;
|
||||
@@ -89,7 +125,7 @@ function extractPartialStringField(argsJson: string, field: string): string | nu
|
||||
// emitting the incomplete hex as a literal would cause a rollback once the next
|
||||
// increment completes it (breaking append-only preview); the render layer falls
|
||||
// back to a new line in that case.
|
||||
if (i + 5 > argsJson.length) return out;
|
||||
if (i + 5 > argsJson.length) return { value: out, complete: false };
|
||||
const hex = argsJson.slice(i + 1, i + 5);
|
||||
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
|
||||
out += String.fromCharCode(Number.parseInt(hex, 16));
|
||||
@@ -108,44 +144,220 @@ function extractPartialStringField(argsJson: string, field: string): string | nu
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') return out;
|
||||
if (ch === '"') return { value: out, complete: true };
|
||||
out += ch;
|
||||
}
|
||||
return out;
|
||||
return { value: out, complete: false };
|
||||
}
|
||||
|
||||
/** Extract the current value of a string field from a possibly-incomplete JSON object string. */
|
||||
function extractPartialStringField(argsJson: string, field: string): string | null {
|
||||
return extractField(argsJson, field)?.value ?? null;
|
||||
}
|
||||
|
||||
/** The three file tools: previewed as `<name> <shortened file_path>`. */
|
||||
const FILE_TOOL_NAMES = new Set(["read_file", "edit_file", "write_file"]);
|
||||
|
||||
/**
|
||||
* Shortens a path for one-line display: at most one parent directory plus the filename
|
||||
* (`…/parent/file.ts`); paths already within that shape are shown as-is. The full path
|
||||
* stays in the argument JSON (and the expanded web card).
|
||||
*/
|
||||
export function shortenPath(p: string): string {
|
||||
const segments = p.split("/").filter((s) => s.length > 0);
|
||||
if (segments.length <= 2) return p;
|
||||
return `…/${segments[segments.length - 2]}/${segments[segments.length - 1]}`;
|
||||
}
|
||||
|
||||
/** How a tool call is previewed while its arguments stream (see the module header). */
|
||||
export interface ToolCallPreviewOptions {
|
||||
/**
|
||||
* Whether this tool's assembled schema carries the `description` argument (from
|
||||
* `session_meta.tools`). Unknown ⇒ `false`: fall back to the plain form, matching a
|
||||
* configuration with the argument switched off.
|
||||
*/
|
||||
expectDescription?: boolean;
|
||||
/** The call's last fragment (its stream ended) or arguments from a complete message: settled, so render whichever form they carry. */
|
||||
final?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming argument preview: exec_command shows `$ <cmd>` once cmd can be read; input_command /
|
||||
* input_subagent show `⌨ <name> → <id>` once the target id is available, with a non-empty
|
||||
* chars / prompt appended as `<< <content>` (an empty payload just means polling, left as-is);
|
||||
* run_subagent shows `run_subagent << <prompt>` once prompt can be read; other tools fall back
|
||||
* to name(args-prefix).
|
||||
* State of the model-written `description` argument within a possibly-incomplete arguments
|
||||
* fragment:
|
||||
* - `pending`: it is expected but hasn't produced anything showable yet — nothing renders;
|
||||
* - `none`: no usable description (not expected, or the settled arguments carry none), so
|
||||
* the plain form is correct;
|
||||
* - `{ text, complete }`: the description's value so far, single-lined and capped. Payload
|
||||
* is appended only once `complete`, keeping the preview append-only whichever order the
|
||||
* model emits its arguments in.
|
||||
*/
|
||||
export function renderPartialToolCall(name: string, argsJson: string): string | null {
|
||||
type DescriptionState = "pending" | "none" | { text: string; complete: boolean };
|
||||
|
||||
function describedState(argsJson: string, opts: ToolCallPreviewOptions): DescriptionState {
|
||||
const settled = opts.final === true || argsComplete(argsJson);
|
||||
// Not expected and not settled: the schema has no such argument, so stream the plain form
|
||||
// right away. Settled fragments are read for real — a complete call renders what it carries.
|
||||
if (!settled && opts.expectDescription !== true) return "none";
|
||||
const field = extractField(argsJson, "description");
|
||||
if (field === null) return settled ? "none" : "pending";
|
||||
const text = toSingleLine(field.value);
|
||||
// An empty description (still opening, or explicitly "") carries nothing to show: once
|
||||
// settled it means "no description", otherwise keep waiting for its first characters.
|
||||
if (!text) return settled ? "none" : "pending";
|
||||
return { text: capPreview(text), complete: field.complete };
|
||||
}
|
||||
|
||||
/** Whether the whole argument JSON parses (i.e. argument streaming is finished). */
|
||||
function argsComplete(argsJson: string): boolean {
|
||||
try {
|
||||
JSON.parse(argsJson);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a payload preview in the description form: `{name} <- {description} ({payload…}`,
|
||||
* closing the parenthesis once `closed`. The open parenthesis mid-stream keeps the preview
|
||||
* append-only while the payload grows.
|
||||
*/
|
||||
function describedForm(name: string, desc: string, payload: string, closed: boolean): string {
|
||||
return `${name} <- ${desc} (${payload}${closed ? ")" : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming argument preview (formats documented in the module header). Returns null while
|
||||
* nothing presentable has streamed in yet — including a call whose schema carries the
|
||||
* `description` argument (`opts.expectDescription`) whose value hasn't started streaming.
|
||||
*/
|
||||
export function renderPartialToolCall(
|
||||
name: string,
|
||||
argsJson: string,
|
||||
opts: ToolCallPreviewOptions = {},
|
||||
): string | null {
|
||||
if (!argsJson) return null;
|
||||
if (name === "exec_command") {
|
||||
const cmd = extractPartialStringField(argsJson, "cmd");
|
||||
if (cmd !== null) return `$ ${toSingleLine(cmd)}`;
|
||||
const desc = describedState(argsJson, opts);
|
||||
if (desc === "pending") return null;
|
||||
const cmd = extractField(argsJson, "cmd");
|
||||
if (desc !== "none") {
|
||||
if (!desc.complete || cmd === null) return `${name} <- ${desc.text}`;
|
||||
return describedForm(name, desc.text, `$ ${toSingleLine(cmd.value)}`, cmd.complete);
|
||||
}
|
||||
if (cmd !== null) return `${name} <- $ ${toSingleLine(cmd.value)}`;
|
||||
return null;
|
||||
}
|
||||
if (name === "run_subagent") {
|
||||
const prompt = extractPartialStringField(argsJson, "prompt");
|
||||
if (prompt !== null) return `run_subagent << ${capPreview(toSingleLine(prompt))}`;
|
||||
const desc = describedState(argsJson, opts);
|
||||
if (desc === "pending") return null;
|
||||
const prompt = extractField(argsJson, "prompt");
|
||||
if (desc !== "none") {
|
||||
if (!desc.complete || prompt === null) return `${name} <- ${desc.text}`;
|
||||
return describedForm(
|
||||
name,
|
||||
desc.text,
|
||||
capPreview(toSingleLine(prompt.value)),
|
||||
prompt.complete,
|
||||
);
|
||||
}
|
||||
if (prompt !== null) return `${name} <- ${capPreview(toSingleLine(prompt.value))}`;
|
||||
return null;
|
||||
}
|
||||
if (name === "input_command") {
|
||||
const pid = extractPartialStringField(argsJson, "process_id");
|
||||
if (pid === null) return null;
|
||||
const desc = describedState(argsJson, opts);
|
||||
if (desc === "pending") return null;
|
||||
const pid = extractField(argsJson, "process_id");
|
||||
if (desc === "none" && pid === null) return null;
|
||||
const chars = extractPartialStringField(argsJson, "chars");
|
||||
const payload = chars ? ` << ${capPreview(visualizeControlChars(chars))}` : "";
|
||||
return `⌨ input_command → ${toSingleLine(pid)}${payload}`;
|
||||
const payloadSuffix = chars ? ` << ${capPreview(visualizeControlChars(chars))}` : "";
|
||||
if (desc !== "none") {
|
||||
if (!desc.complete || pid === null) return `${name} <- ${desc.text}`;
|
||||
return describedForm(
|
||||
name,
|
||||
desc.text,
|
||||
`${toSingleLine(pid.value)}${payloadSuffix}`,
|
||||
argsComplete(argsJson),
|
||||
);
|
||||
}
|
||||
return `${name} <- ${toSingleLine(pid!.value)}${payloadSuffix}`;
|
||||
}
|
||||
if (name === "input_subagent") {
|
||||
const sid = extractPartialStringField(argsJson, "subagent_id");
|
||||
if (sid === null) return null;
|
||||
const desc = describedState(argsJson, opts);
|
||||
if (desc === "pending") return null;
|
||||
const sid = extractField(argsJson, "subagent_id");
|
||||
if (desc === "none" && sid === null) return null;
|
||||
const prompt = extractPartialStringField(argsJson, "prompt");
|
||||
const payload = prompt ? ` << ${capPreview(toSingleLine(prompt))}` : "";
|
||||
return `⌨ input_subagent → ${toSingleLine(sid)}${payload}`;
|
||||
const payloadSuffix = prompt ? ` << ${capPreview(toSingleLine(prompt))}` : "";
|
||||
if (desc !== "none") {
|
||||
if (!desc.complete || sid === null) return `${name} <- ${desc.text}`;
|
||||
return describedForm(
|
||||
name,
|
||||
desc.text,
|
||||
`${toSingleLine(sid.value)}${payloadSuffix}`,
|
||||
argsComplete(argsJson),
|
||||
);
|
||||
}
|
||||
return `${name} <- ${toSingleLine(sid!.value)}${payloadSuffix}`;
|
||||
}
|
||||
if (FILE_TOOL_NAMES.has(name)) {
|
||||
// Path rendered only once complete: shortening a still-growing path would rewrite the line.
|
||||
const filePath = extractField(argsJson, "file_path");
|
||||
if (filePath !== null && filePath.complete) {
|
||||
return `${name} ${shortenPath(toSingleLine(filePath.value))}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return `${name || "tool_call"}(${toSingleLine(argsJson)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* File-tool payload for the interactive approval prompt: the full decoded arguments
|
||||
* (old_string / new_string / content …), bounded to MAX_APPROVAL_PAYLOAD_LINES lines with
|
||||
* an explicit elision note — under always-ask/read-only approval the user must see what
|
||||
* they are approving, not just the file path. Returns null for other tools or unparseable
|
||||
* arguments (arguments are complete by approval time).
|
||||
*/
|
||||
export function renderFileToolApprovalPayload(name: string, argsJson: string): string | null {
|
||||
if (!FILE_TOOL_NAMES.has(name)) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(argsJson);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object") return null;
|
||||
const args = parsed as Record<string, unknown>;
|
||||
const lines: string[] = [];
|
||||
const pushField = (label: string, value: unknown): void => {
|
||||
if (value === undefined) return;
|
||||
if (typeof value === "string" && value.includes("\n")) {
|
||||
lines.push(`${label}:`);
|
||||
for (const line of value.split("\n")) lines.push(` | ${line}`);
|
||||
} else {
|
||||
lines.push(`${label}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
|
||||
}
|
||||
};
|
||||
pushField("file_path", args["file_path"]);
|
||||
if (name === "read_file") {
|
||||
pushField("offset", args["offset"]);
|
||||
pushField("limit", args["limit"]);
|
||||
} else if (name === "edit_file") {
|
||||
pushField("old_string", args["old_string"]);
|
||||
pushField("new_string", args["new_string"]);
|
||||
if (args["replace_all"] === true) pushField("replace_all", true);
|
||||
} else if (name === "write_file") {
|
||||
pushField("content", args["content"]);
|
||||
}
|
||||
let shown = lines;
|
||||
let elided = 0;
|
||||
if (shown.length > MAX_APPROVAL_PAYLOAD_LINES) {
|
||||
elided = shown.length - MAX_APPROVAL_PAYLOAD_LINES;
|
||||
shown = shown.slice(0, MAX_APPROVAL_PAYLOAD_LINES);
|
||||
}
|
||||
const capped = shown.map((l) =>
|
||||
l.length > MAX_APPROVAL_PAYLOAD_LINE ? `${l.slice(0, MAX_APPROVAL_PAYLOAD_LINE)}…` : l,
|
||||
);
|
||||
if (elided > 0) capped.push(`[… ${elided} more line${elided === 1 ? "" : "s"} not shown]`);
|
||||
return capped.join("\n");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user