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:
@@ -26,9 +26,9 @@
|
||||
*/
|
||||
import { createInterface, type Interface } from "node:readline";
|
||||
import type { Command } from "commander";
|
||||
import { createAgent, userText } from "@prismshadow/penguin-core";
|
||||
import { createAgent, userText, VERSION } from "@prismshadow/penguin-core";
|
||||
import type { ApprovalDecision, OmniMessage, ToolCallPayload } from "@prismshadow/penguin-core";
|
||||
import { StreamRenderer, dim, renderHistory } from "../render.js";
|
||||
import { StreamRenderer, dim, renderHistory, sessionMetaTools } from "../render.js";
|
||||
import { runTask } from "../task-loop.js";
|
||||
import { parseApprovalAnswer, resolveApprovalMode } from "../approval.js";
|
||||
import { LineComposer, PasteFilter } from "../input.js";
|
||||
@@ -113,9 +113,11 @@ export function registerChatCommand(program: Command, t: Messages): void {
|
||||
}
|
||||
|
||||
const renderer = new StreamRenderer(out, t);
|
||||
// The assembled tool schemas decide each tool's call-line preview path (see render.ts).
|
||||
renderer.useToolSchemas(sessionMetaTools(session));
|
||||
|
||||
out.write(
|
||||
`${t.header("chat", agent.state.agentId, session.workspaceDir, session.modelId)}\n` +
|
||||
`${t.header("chat", VERSION, agent.state.agentId, session.workspaceDir, session.modelId)}\n` +
|
||||
`${t.chatHints()}\n`,
|
||||
);
|
||||
// On resume, first render the history messages of the current context per Trace
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
* Docs: /docs/cli § "penguin run".
|
||||
*/
|
||||
import type { Command } from "commander";
|
||||
import { createAgent, userText } from "@prismshadow/penguin-core";
|
||||
import { StreamRenderer } from "../render.js";
|
||||
import { createAgent, userText, VERSION } from "@prismshadow/penguin-core";
|
||||
import { StreamRenderer, sessionMetaTools } from "../render.js";
|
||||
import { runTask } from "../task-loop.js";
|
||||
import { denyActivePrompt, resolveApprovalMode } from "../approval.js";
|
||||
import type { Messages } from "../i18n.js";
|
||||
@@ -53,7 +53,9 @@ export function registerRunCommand(program: Command, t: Messages): void {
|
||||
});
|
||||
|
||||
const out = process.stdout;
|
||||
out.write(`${t.header("run", agent.state.agentId, session.workspaceDir, session.modelId)}\n`);
|
||||
out.write(
|
||||
`${t.header("run", VERSION, agent.state.agentId, session.workspaceDir, session.modelId)}\n`,
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const onSigint = () => {
|
||||
@@ -65,6 +67,8 @@ export function registerRunCommand(program: Command, t: Messages): void {
|
||||
process.on("SIGINT", onSigint);
|
||||
|
||||
const renderer = new StreamRenderer(out, t);
|
||||
// The assembled tool schemas decide each tool's call-line preview path (see render.ts).
|
||||
renderer.useToolSchemas(sessionMetaTools(session));
|
||||
try {
|
||||
const result = await runTask(session, [userText(opts.message)], {
|
||||
mode,
|
||||
|
||||
@@ -114,7 +114,14 @@ export interface Messages {
|
||||
};
|
||||
|
||||
// —— Runtime output ——
|
||||
header(kind: "chat" | "run", agentId: string, workspace: string, model: string): string;
|
||||
/** Startup banner: product + subcommand + CLI version on the first line, then Agent / Workspace / Model each on its own line. */
|
||||
header(
|
||||
kind: "chat" | "run",
|
||||
version: string,
|
||||
agentId: string,
|
||||
workspace: string,
|
||||
model: string,
|
||||
): string;
|
||||
chatHints(): string;
|
||||
confirmExit(): string;
|
||||
taskInterrupted(): string;
|
||||
@@ -187,8 +194,34 @@ export interface Messages {
|
||||
webTimeout(url: string): string;
|
||||
}
|
||||
|
||||
function header(kind: "chat" | "run", agentId: string, workspace: string, model: string): string {
|
||||
return `PenguinHarness ${kind} — agent=${agentId} workspace=${workspace} model=${model}`;
|
||||
function headerEn(
|
||||
kind: "chat" | "run",
|
||||
version: string,
|
||||
agentId: string,
|
||||
workspace: string,
|
||||
model: string,
|
||||
): string {
|
||||
return [
|
||||
`PenguinHarness ${kind} v${version}`,
|
||||
`Agent: ${agentId}`,
|
||||
`Workspace: ${workspace}`,
|
||||
`Model: ${model}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function headerZh(
|
||||
kind: "chat" | "run",
|
||||
version: string,
|
||||
agentId: string,
|
||||
workspace: string,
|
||||
model: string,
|
||||
): string {
|
||||
return [
|
||||
`PenguinHarness ${kind} v${version}`,
|
||||
`Agent:${agentId}`,
|
||||
`Workspace:${workspace}`,
|
||||
`模型:${model}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const en: Messages = {
|
||||
@@ -308,7 +341,7 @@ const en: Messages = {
|
||||
installerFetchFailed: (url) => `Could not download the installer from ${url}.`,
|
||||
},
|
||||
|
||||
header,
|
||||
header: headerEn,
|
||||
chatHints: () =>
|
||||
"Type a message to start a conversation; end a line with \\; /compact to compact the context; /exit to quit; and Ctrl-C interrupts the current conversation.",
|
||||
confirmExit: () => "Exit penguin? [y/N] ",
|
||||
@@ -473,7 +506,7 @@ const zh: Messages = {
|
||||
installerFetchFailed: (url) => `无法从 ${url} 下载安装脚本。`,
|
||||
},
|
||||
|
||||
header,
|
||||
header: headerZh,
|
||||
chatHints: () =>
|
||||
"输入消息发起对话;行尾 \\ 续行;/compact 压缩上下文;/exit 退出;Ctrl-C 中断对话。",
|
||||
confirmExit: () => "确认退出 penguin?[y/N] ",
|
||||
|
||||
+178
-29
@@ -21,13 +21,15 @@
|
||||
* - when the head of the queue is held, the holder's own subsequent messages are let
|
||||
* through first (preserving in-segment order), avoiding deadlock.
|
||||
*
|
||||
* **Pairing tags**: a tool call and its output may be separated by several segments, so
|
||||
* both are tagged with a shared word for pairing: the call line reads
|
||||
* `[tool-653] $ cmd`, the output line `[tool-653] >> ...` (653 being the last 3
|
||||
* characters of tool_call_id); nested (subagent) tools use
|
||||
* `[agent-f2a-tool-653] $ cmd` (f2a being the last 3 characters of the direct child
|
||||
* Session id). Approval lines carry no tag (they immediately follow the matching call
|
||||
* line, so context makes the pairing clear): `[approved]`.
|
||||
* **Call/output pairing**: both sides carry the same `[tool-653] <toolName>` prefix
|
||||
* (653 being the last 3 characters of tool_call_id) — the call line reads
|
||||
* `[tool-653] exec_command <- $ cmd`, each output line `[tool-653] exec_command -> ...`;
|
||||
* nested (subagent) tools use `[agent-f2a-tool-653] …` (f2a being the last 3 characters
|
||||
* of the direct child Session id). The output-side tool name is resolved from the
|
||||
* preceding call via a tool_call_id → name map (the call always precedes its output; if
|
||||
* no call was seen, the bare `[tool-653]` tag remains). Approval lines carry no tag
|
||||
* (they immediately follow the matching call line, so context makes the pairing clear):
|
||||
* `[approved]`.
|
||||
*
|
||||
* **Nested sub-session messages** (those carrying an origin) are handled separately:
|
||||
* child tool calls (so the user can see what the subagent is calling before approval)
|
||||
@@ -52,14 +54,18 @@ import type {
|
||||
PartialToolCallPayload,
|
||||
PartialToolCallOutputPayload,
|
||||
RequestEndPayload,
|
||||
SessionMetaPayload,
|
||||
TokenUsagePayload,
|
||||
ToolCallPayload,
|
||||
ToolDefinition,
|
||||
} from "@prismshadow/penguin-core";
|
||||
import { renderPartialToolCall } from "./tool-render.js";
|
||||
import { renderFileToolApprovalPayload, renderPartialToolCall } from "./tool-render.js";
|
||||
import { defaultMessages } from "./i18n.js";
|
||||
import type { Messages } from "./i18n.js";
|
||||
|
||||
const DIM = "\x1b[2m";
|
||||
const GREEN = "\x1b[32m";
|
||||
const RED = "\x1b[31m";
|
||||
const CYAN = "\x1b[36m";
|
||||
const RESET = "\x1b[0m";
|
||||
|
||||
@@ -67,6 +73,17 @@ export function dim(text: string): string {
|
||||
return `${DIM}${text}${RESET}`;
|
||||
}
|
||||
|
||||
/** The two file tools whose outputs carry git-style diffs; their `+`/`-`/`@@` lines get colored. */
|
||||
const DIFF_OUTPUT_TOOLS = new Set(["edit_file", "write_file"]);
|
||||
|
||||
/** Color for one diff-output line, picked from its first character (null = plain). */
|
||||
function diffLineColor(firstChar: string | undefined): string | null {
|
||||
if (firstChar === "+") return GREEN;
|
||||
if (firstChar === "-") return RED;
|
||||
if (firstChar === "@") return DIM;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Colors a tool call line cyan, distinguishing it from body text/thinking (review comment #5). */
|
||||
function cyan(text: string): string {
|
||||
return `${CYAN}${text}${RESET}`;
|
||||
@@ -123,6 +140,15 @@ export function formatAbort(p: AbortPayload, t: Messages): string {
|
||||
return dim(t.abortLabel(p.reason ?? undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* The Session's assembled tool schemas, read off its `session_meta` — the definitions
|
||||
* actually exposed to the model, so the per-tool `call_description` switch is already
|
||||
* applied. Feeds `StreamRenderer.useToolSchemas`.
|
||||
*/
|
||||
export function sessionMetaTools(session: { metaMessage: OmniMessage }): readonly ToolDefinition[] {
|
||||
return (session.metaMessage.payload as SessionMetaPayload).tools ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Statically renders resumed history messages (`--resume`: full-message semantics, no
|
||||
* partial_*, including interrupted messages and their markers). Uses the
|
||||
@@ -135,6 +161,10 @@ export function renderHistory(
|
||||
out: NodeJS.WritableStream,
|
||||
t: Messages = defaultMessages(),
|
||||
): void {
|
||||
// tool_call_id -> tool name (keyed with the origin chain: parent/child ids may collide),
|
||||
// so output lines can be labeled with the tool name of their preceding call.
|
||||
const toolNames = new Map<string, string>();
|
||||
const nameKey = (msg: OmniMessage, id: string): string => `${msg.origin?.join("/") ?? ""}:${id}`;
|
||||
for (const msg of messages) {
|
||||
if (isEventMessage(msg)) {
|
||||
const p = msg.payload as { type?: string } & AbortPayload;
|
||||
@@ -167,19 +197,31 @@ export function renderHistory(
|
||||
out.write(`${dim(p.thinking ?? "")}${marker}\n`);
|
||||
break;
|
||||
case "tool_call": {
|
||||
if (p.name) toolNames.set(nameKey(msg, p.tool_call_id ?? ""), p.name);
|
||||
const preview =
|
||||
renderPartialToolCall(p.name ?? "", p.arguments ?? "") ?? `${p.name} ${p.arguments}`;
|
||||
renderPartialToolCall(p.name ?? "", p.arguments ?? "", { final: true }) ??
|
||||
`${p.name} ${p.arguments}`;
|
||||
out.write(`${cyan(`[${callTag(p.tool_call_id ?? "")}] ${preview}`)}${marker}\n`);
|
||||
break;
|
||||
}
|
||||
case "tool_call_output": {
|
||||
const tag = callTag(p.tool_call_id ?? "");
|
||||
// Output lines carry the pairing tag plus the tool name (the bare tag when the
|
||||
// transcript has no matching call); file-tool diff lines are colored like git's.
|
||||
const tag = `[${callTag(p.tool_call_id ?? "")}]`;
|
||||
const name = toolNames.get(nameKey(msg, p.tool_call_id ?? ""));
|
||||
const label = name ? `${tag} ${name}` : tag;
|
||||
const colorDiff = name !== undefined && DIFF_OUTPUT_TOOLS.has(name);
|
||||
for (const line of (p.output ?? "").split("\n")) {
|
||||
out.write(`${DIM}[${tag}] >> ${RESET}${line}\n`);
|
||||
const color = colorDiff ? diffLineColor(line[0]) : null;
|
||||
out.write(
|
||||
color
|
||||
? `${DIM}${label} -> ${RESET}${color}${line}${RESET}\n`
|
||||
: `${DIM}${label} -> ${RESET}${line}\n`,
|
||||
);
|
||||
}
|
||||
// Attached images aren't rendered by the terminal; print one placeholder line per image.
|
||||
for (const _ of p.images ?? []) {
|
||||
out.write(`${DIM}[${tag}] >> [image]${RESET}\n`);
|
||||
out.write(`${DIM}${label} -> [image]${RESET}\n`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -238,6 +280,22 @@ export class StreamRenderer {
|
||||
private inDim = false;
|
||||
/** Whether tool-call output is at the start of a line (decides whether the gutter needs to be written). */
|
||||
private toolOutLineStart = true;
|
||||
|
||||
/**
|
||||
* tool_call_id -> tool name for the current task's parent-session calls (nested tool
|
||||
* outputs are not gutter-rendered), so output lines can be prefixed with the tool name
|
||||
* of the call that produced them. Populated from partial/complete call messages (the
|
||||
* call always precedes its output); cleared with the other per-task registrations.
|
||||
*/
|
||||
private toolNames = new Map<string, string>();
|
||||
/**
|
||||
* Names of the tools whose assembled schema carries the `description` argument (from
|
||||
* `session_meta.tools`, i.e. after the per-tool `call_description` switch has been
|
||||
* applied). Decides the preview path before a call's arguments stream: awaiting the
|
||||
* description, or streaming the plain form right away (see tool-render.ts). An unknown
|
||||
* tool falls back to "no description".
|
||||
*/
|
||||
private describedTools = new Set<string>();
|
||||
/** Buffer for partial_tool_call; each delta streams out the newly appended suffix of the preview. */
|
||||
private partialToolCalls = new Map<
|
||||
string,
|
||||
@@ -318,6 +376,21 @@ export class StreamRenderer {
|
||||
beginUserPrompt(toolCall?: OmniMessage<ToolCallPayload>): void {
|
||||
if (toolCall) this.ensureAdjacentCallLine(toolCall);
|
||||
this.finishLine();
|
||||
// File-tool approvals: the one-line preview shows only the (shortened) path, but the
|
||||
// user is approving a concrete rewrite — print the decoded payload
|
||||
// (old_string/new_string/content), bounded with an explicit elision note, right before
|
||||
// the prompt.
|
||||
if (toolCall) {
|
||||
const payload = renderFileToolApprovalPayload(
|
||||
toolCall.payload.name,
|
||||
toolCall.payload.arguments,
|
||||
);
|
||||
if (payload !== null) {
|
||||
for (const line of payload.split("\n")) this.out.write(`${dim(line)}\n`);
|
||||
// lastLineKey stays on the call's key: the payload lines belong to this call, so
|
||||
// the later noteApprovalDecision must not re-render the call line as "not adjacent".
|
||||
}
|
||||
}
|
||||
this.promptActive = true;
|
||||
this.promptKey = toolCall
|
||||
? this.callLineKey(toolCall.payload.tool_call_id, toolCall.origin)
|
||||
@@ -384,7 +457,11 @@ export class StreamRenderer {
|
||||
key: string,
|
||||
): void {
|
||||
this.ensuredCallLines.add(key);
|
||||
const preview = renderPartialToolCall(p.name, p.arguments) ?? `${p.name} ${p.arguments}`;
|
||||
// Parent-session calls feed the output-gutter name map (nested outputs are not
|
||||
// gutter-rendered, and a child id could collide with a parent id).
|
||||
if (!origin || origin.length === 0) this.toolNames.set(p.tool_call_id, p.name);
|
||||
const preview =
|
||||
renderPartialToolCall(p.name, p.arguments, { final: true }) ?? `${p.name} ${p.arguments}`;
|
||||
this.finishLine();
|
||||
this.out.write(`${cyan(`[${callTag(p.tool_call_id, origin)}] ${preview}`)}\n`);
|
||||
this.lastLineKey = key;
|
||||
@@ -500,7 +577,14 @@ export class StreamRenderer {
|
||||
case "partial_tool_call_output":
|
||||
this.handlePartialToolOutput(payload as PartialToolCallOutputPayload);
|
||||
return;
|
||||
// Complete (non-streaming) model_msg is never rendered (including image_url/inline_*); the content has already been shown by partial_*.
|
||||
// Complete (non-streaming) model_msg is never rendered (including image_url/inline_*);
|
||||
// the content has already been shown by partial_*. A complete tool_call still feeds
|
||||
// the name map so its output gutter can carry the tool name.
|
||||
case "tool_call": {
|
||||
const p = payload as ToolCallPayload;
|
||||
this.toolNames.set(p.tool_call_id, p.name);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
@@ -603,7 +687,26 @@ export class StreamRenderer {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// session_meta: not rendered.
|
||||
// session_meta: not rendered, but its tool list settles each tool's preview path.
|
||||
if (msg.type === "session_meta") {
|
||||
this.useToolSchemas((msg.payload as SessionMetaPayload).tools);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the Session's assembled tool schemas (`session_meta.tools`), which decide each
|
||||
* tool's preview path before its arguments stream (see `describedTools`). The host calls
|
||||
* this as soon as the Session exists; a `session_meta` flowing through the stream (resume,
|
||||
* sub-sessions) registers the same way.
|
||||
*/
|
||||
useToolSchemas(tools: readonly ToolDefinition[]): void {
|
||||
for (const tool of tools) {
|
||||
const properties = (tool.parameters as { properties?: Record<string, unknown> } | undefined)
|
||||
?.properties;
|
||||
if (properties && Object.hasOwn(properties, "description"))
|
||||
this.describedTools.add(tool.name);
|
||||
else this.describedTools.delete(tool.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -677,6 +780,25 @@ export class StreamRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a call line whose preview was withheld for the whole stream (the arguments never
|
||||
* settled — e.g. the turn was interrupted mid-arguments — so a description could still have
|
||||
* arrived, see tool-render.ts). Called once at `stop` with the fragment marked final, so an
|
||||
* in-flight call is never left invisible.
|
||||
*/
|
||||
private renderWithheldCallLine(
|
||||
toolCallId: string,
|
||||
partial: { name: string; arguments: string },
|
||||
): void {
|
||||
const key = this.callLineKey(toolCallId);
|
||||
if (this.ensuredCallLines.has(key)) return;
|
||||
const preview = renderPartialToolCall(partial.name, partial.arguments, { final: true });
|
||||
if (preview === null) return;
|
||||
this.finishLine();
|
||||
this.out.write(`${cyan(`[${callTag(toolCallId)}] ${preview}`)}\n`);
|
||||
this.lastLineKey = key;
|
||||
}
|
||||
|
||||
private handlePartialToolCall(p: PartialToolCallPayload): void {
|
||||
// The call line was already rendered in place from the complete message at approval time: skip the whole late-arriving streaming copy (clean up the buffer on stop).
|
||||
if (this.ensuredCallLines.has(this.callLineKey(p.tool_call_id))) {
|
||||
@@ -689,13 +811,18 @@ export class StreamRenderer {
|
||||
partial = { name: p.name, arguments: "", lastPreview: "" };
|
||||
this.partialToolCalls.set(p.tool_call_id, partial);
|
||||
}
|
||||
if (p.name) partial.name = p.name;
|
||||
if (p.name) {
|
||||
partial.name = p.name;
|
||||
// Remember the name for this call's output gutter (`<name> -> …`).
|
||||
this.toolNames.set(p.tool_call_id, p.name);
|
||||
}
|
||||
if (p.arguments) {
|
||||
partial.arguments += p.arguments;
|
||||
}
|
||||
|
||||
if (p.event_type === "stop") {
|
||||
if (partial.lastPreview) this.finishLine();
|
||||
else this.renderWithheldCallLine(p.tool_call_id, partial);
|
||||
this.partialToolCalls.delete(p.tool_call_id);
|
||||
return;
|
||||
}
|
||||
@@ -703,7 +830,9 @@ export class StreamRenderer {
|
||||
if (!p.arguments) return;
|
||||
|
||||
if (this.inDim) this.finishLine();
|
||||
const preview = renderPartialToolCall(partial.name, partial.arguments);
|
||||
const preview = renderPartialToolCall(partial.name, partial.arguments, {
|
||||
expectDescription: this.describedTools.has(partial.name),
|
||||
});
|
||||
if (preview === null) return;
|
||||
|
||||
// The line starts with a pairing tag [tool-<last 3 chars of id>], matching the output line that follows.
|
||||
@@ -731,40 +860,59 @@ export class StreamRenderer {
|
||||
return;
|
||||
}
|
||||
if (this.inDim) this.finishLine();
|
||||
if (p.output) this.writeToolOutput(p.output, callTag(p.tool_call_id));
|
||||
const label = this.outputLabel(p.tool_call_id);
|
||||
const name = this.toolNames.get(p.tool_call_id);
|
||||
const colorDiff = name !== undefined && DIFF_OUTPUT_TOOLS.has(name);
|
||||
if (p.output) this.writeToolOutput(p.output, label, colorDiff);
|
||||
// Image delta (carried whole in a single delta): the terminal doesn't render the
|
||||
// image itself, so print one placeholder line per image, using the same pairing tag
|
||||
// as the output gutter.
|
||||
// image itself, so print one placeholder line per image, using the same gutter label
|
||||
// as the text output.
|
||||
if (p.images && p.images.length > 0) {
|
||||
this.finishLine();
|
||||
const tag = callTag(p.tool_call_id);
|
||||
for (const _ of p.images) {
|
||||
this.out.write(`${DIM}[${tag}] >> [image]${RESET}\n`);
|
||||
this.out.write(`${DIM}${label} -> [image]${RESET}\n`);
|
||||
}
|
||||
this.lastLineKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Output-gutter label: the `[tool-xxx]` pairing tag plus the tool name of the preceding call (the bare tag when no call was seen). */
|
||||
private outputLabel(toolCallId: string): string {
|
||||
const tag = `[${callTag(toolCallId)}]`;
|
||||
const name = this.toolNames.get(toolCallId);
|
||||
return name ? `${tag} ${name}` : tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes tool-call **output** line by line, each line starting with the dim gutter
|
||||
* `[tool-<last 3 chars of id>] >> `, paired with the call line (cyan `[tool-xxx] $
|
||||
* cmd`). Streaming chunks arrive incrementally; whether to write the gutter is
|
||||
* decided by the current line-start state.
|
||||
* `[tool-xxx] <toolName> -> ` (the same prefix as the call line, cyan
|
||||
* `[tool-xxx] <name> <- …`; the bare tag when no call was seen). Streaming chunks
|
||||
* arrive incrementally; whether to write the gutter is decided by the current
|
||||
* line-start state.
|
||||
*/
|
||||
private writeToolOutput(chunk: string, tag: string): void {
|
||||
private writeToolOutput(chunk: string, label: string, colorDiff: boolean): void {
|
||||
let i = 0;
|
||||
while (i < chunk.length) {
|
||||
let lineColor: string | null = null;
|
||||
if (this.toolOutLineStart) {
|
||||
this.out.write(`${DIM}[${tag}] >> ${RESET}`);
|
||||
this.out.write(`${DIM}${label} -> ${RESET}`);
|
||||
this.toolOutLineStart = false;
|
||||
this.inLine = true;
|
||||
// Diff coloring keys off the line's first character. File-tool outputs arrive as
|
||||
// one delta of whole lines, so the first character is always in this chunk; a
|
||||
// line continued from a previous chunk stays plain.
|
||||
if (colorDiff) lineColor = diffLineColor(chunk[i]);
|
||||
}
|
||||
const nl = chunk.indexOf("\n", i);
|
||||
const end = nl === -1 ? chunk.length : nl;
|
||||
const segment = chunk.slice(i, end);
|
||||
if (segment) {
|
||||
this.out.write(lineColor ? `${lineColor}${segment}${RESET}` : segment);
|
||||
}
|
||||
if (nl === -1) {
|
||||
this.out.write(chunk.slice(i));
|
||||
i = chunk.length;
|
||||
} else {
|
||||
this.out.write(chunk.slice(i, nl + 1));
|
||||
this.out.write("\n");
|
||||
this.toolOutLineStart = true;
|
||||
this.inLine = false;
|
||||
i = nl + 1;
|
||||
@@ -833,6 +981,7 @@ export class StreamRenderer {
|
||||
this.ensuredCallLines.clear();
|
||||
this.renderedDecisions.clear();
|
||||
this.partialToolCalls.clear();
|
||||
this.toolNames.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+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");
|
||||
}
|
||||
|
||||
@@ -48,10 +48,16 @@ describe("getMessages", () => {
|
||||
expect(getMessages("zh").langInvalid("fr")).toContain("fr");
|
||||
});
|
||||
|
||||
it("header order is agent → workspace → model", () => {
|
||||
const h = getMessages("en").header("run", "ag", "/ws", "mod");
|
||||
expect(h.indexOf("agent=ag")).toBeLessThan(h.indexOf("workspace=/ws"));
|
||||
expect(h.indexOf("workspace=/ws")).toBeLessThan(h.indexOf("model=mod"));
|
||||
it("header shows the version and Agent / Workspace / Model on their own lines", () => {
|
||||
for (const lang of ["en", "zh"] as const) {
|
||||
const lines = getMessages(lang).header("run", "1.2.3", "ag", "/ws", "mod").split("\n");
|
||||
expect(lines).toHaveLength(4);
|
||||
expect(lines[0]).toContain("run");
|
||||
expect(lines[0]).toContain("v1.2.3");
|
||||
expect(lines[1]).toContain("ag");
|
||||
expect(lines[2]).toContain("/ws");
|
||||
expect(lines[3]).toContain("mod");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -117,19 +117,165 @@ describe("StreamRenderer", () => {
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c4" }));
|
||||
r.handle(toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "c4" }));
|
||||
// The call line carries a [tool-<last-3-chars-of-id>] pairing tag matching the output line.
|
||||
expect(stripAnsi(text())).toBe("[tool-c4] $ ls\n");
|
||||
expect(stripAnsi(text())).toBe("[tool-c4] exec_command <- $ ls\n");
|
||||
});
|
||||
|
||||
it("streams partial_tool_call_output with a tagged gutter and skips the complete tool_call_output", () => {
|
||||
it("renders one call line when the description arrives after the command", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
// The assembled schema carries the description argument, so the preview waits for it:
|
||||
// with payload-first emission (models don't always honour schema order) the plain form
|
||||
// must never reach the screen, or it would be stranded above the described one.
|
||||
r.useToolSchemas([
|
||||
{
|
||||
name: "exec_command",
|
||||
description: "run a command",
|
||||
parameters: { type: "object", properties: { description: {}, cmd: {} } },
|
||||
},
|
||||
]);
|
||||
r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c9" }));
|
||||
r.handle(
|
||||
partialToolCall({
|
||||
eventType: "delta",
|
||||
name: "",
|
||||
arguments: '{"cmd":"ls -la",',
|
||||
toolCallId: "c9",
|
||||
}),
|
||||
);
|
||||
r.handle(
|
||||
partialToolCall({
|
||||
eventType: "delta",
|
||||
name: "",
|
||||
arguments: '"description":"列出当前目录的文件"}',
|
||||
toolCallId: "c9",
|
||||
}),
|
||||
);
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c9" }));
|
||||
expect(stripAnsi(text())).toBe("[tool-c9] exec_command <- 列出当前目录的文件 ($ ls -la)\n");
|
||||
});
|
||||
|
||||
it("streams the command live when the schema has no description argument", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
// call_description switched off for this tool: nothing can supersede the plain form, so
|
||||
// it streams as the arguments arrive rather than waiting for them to settle.
|
||||
r.useToolSchemas([
|
||||
{
|
||||
name: "exec_command",
|
||||
description: "run a command",
|
||||
parameters: { type: "object", properties: { cmd: {} } },
|
||||
},
|
||||
]);
|
||||
r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c7" }));
|
||||
r.handle(
|
||||
partialToolCall({ eventType: "delta", name: "", arguments: '{"cmd":"ls', toolCallId: "c7" }),
|
||||
);
|
||||
expect(stripAnsi(text())).toBe("[tool-c7] exec_command <- $ ls");
|
||||
r.handle(
|
||||
partialToolCall({ eventType: "delta", name: "", arguments: ' -la"}', toolCallId: "c7" }),
|
||||
);
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c7" }));
|
||||
expect(stripAnsi(text())).toBe("[tool-c7] exec_command <- $ ls -la\n");
|
||||
});
|
||||
|
||||
it("still renders a call line whose arguments never settled", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
// Interrupted mid-arguments while awaiting a description: the call must not vanish.
|
||||
r.useToolSchemas([
|
||||
{
|
||||
name: "exec_command",
|
||||
description: "run a command",
|
||||
parameters: { type: "object", properties: { description: {}, cmd: {} } },
|
||||
},
|
||||
]);
|
||||
r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c8" }));
|
||||
r.handle(
|
||||
partialToolCall({ eventType: "delta", name: "", arguments: '{"cmd":"sle', toolCallId: "c8" }),
|
||||
);
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c8" }));
|
||||
expect(stripAnsi(text())).toBe("[tool-c8] exec_command <- $ sle\n");
|
||||
});
|
||||
|
||||
it("prefixes streamed tool output with the tool name and skips the complete tool_call_output", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
// The call precedes its output and supplies the gutter's tool name.
|
||||
r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "c3" }));
|
||||
r.handle(
|
||||
partialToolCall({
|
||||
eventType: "delta",
|
||||
name: "",
|
||||
arguments: '{"cmd":"ls"}',
|
||||
toolCallId: "c3",
|
||||
}),
|
||||
);
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "c3" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "c3" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "delta", output: "line1\n", toolCallId: "c3" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "delta", output: "line2", toolCallId: "c3" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "c3" }));
|
||||
r.handle(toolCallOutput({ output: "line1\nline2", toolCallId: "c3" })); // must not be re-rendered
|
||||
// Each line starts with a tagged gutter (no indent) matching the call line.
|
||||
expect(stripAnsi(text())).toBe("[tool-c3] >> line1\n[tool-c3] >> line2\n");
|
||||
// Call line first, then each output line repeats the `[tool-xxx] <toolName>` prefix.
|
||||
expect(stripAnsi(text())).toBe(
|
||||
"[tool-c3] exec_command <- $ ls\n[tool-c3] exec_command -> line1\n[tool-c3] exec_command -> line2\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("colors edit_file diff output lines green/red and dims hunk headers", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
r.handle(partialToolCall({ eventType: "start", name: "edit_file", toolCallId: "d1" }));
|
||||
r.handle(
|
||||
partialToolCall({
|
||||
eventType: "delta",
|
||||
name: "",
|
||||
arguments: '{"file_path":"x.ts","old_string":"old","new_string":"new"}',
|
||||
toolCallId: "d1",
|
||||
}),
|
||||
);
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "d1" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "d1" }));
|
||||
r.handle(
|
||||
partialToolCallOutput({
|
||||
eventType: "delta",
|
||||
output: 'Replaced 1 occurrence in "x.ts".\n@@ -1,1 +1,1 @@\n-old\n+new\n',
|
||||
toolCallId: "d1",
|
||||
}),
|
||||
);
|
||||
r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "d1" }));
|
||||
const raw = text();
|
||||
// Diff lines are wrapped in green/red; the hunk header is dimmed; the summary line stays plain.
|
||||
expect(raw).toContain("\x1b[32m+new\x1b[0m");
|
||||
expect(raw).toContain("\x1b[31m-old\x1b[0m");
|
||||
expect(raw).toContain("\x1b[2m@@ -1,1 +1,1 @@\x1b[0m");
|
||||
// The stripped view still reads as labeled gutter lines.
|
||||
const plain = stripAnsi(raw);
|
||||
expect(plain).toContain("[tool-d1] edit_file -> -old");
|
||||
expect(plain).toContain("[tool-d1] edit_file -> +new");
|
||||
});
|
||||
|
||||
it("does not diff-color non-file-tool output", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
r.handle(partialToolCall({ eventType: "start", name: "exec_command", toolCallId: "d2" }));
|
||||
r.handle(
|
||||
partialToolCall({ eventType: "delta", name: "", arguments: '{"cmd":"x"}', toolCallId: "d2" }),
|
||||
);
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "d2" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "d2" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "delta", output: "+plus\n", toolCallId: "d2" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "d2" }));
|
||||
expect(text()).not.toContain("\x1b[32m");
|
||||
});
|
||||
|
||||
it("falls back to the pairing tag on output whose call was never seen", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
r.handle(partialToolCallOutput({ eventType: "start", toolCallId: "c3" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "delta", output: "line1", toolCallId: "c3" }));
|
||||
r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "c3" }));
|
||||
expect(stripAnsi(text())).toBe("[tool-c3] -> line1\n");
|
||||
});
|
||||
|
||||
it("prints the retry line only when the retry request actually begins", () => {
|
||||
@@ -165,10 +311,11 @@ describe("StreamRenderer", () => {
|
||||
r.handle(partialText("start", ""));
|
||||
r.handle(partialText("delta", "hello"));
|
||||
r.handle(partialToolCallOutput({ eventType: "delta", output: "a2\n", toolCallId: "tA" }));
|
||||
expect(stripAnsi(text())).toBe("[tool-tA] >> a1\n[tool-tA] >> a2\n"); // hello is still queued
|
||||
// No call preceded tA in this stream: the gutter falls back to the pairing tag.
|
||||
expect(stripAnsi(text())).toBe("[tool-tA] -> a1\n[tool-tA] -> a2\n"); // hello is still queued
|
||||
r.handle(partialToolCallOutput({ eventType: "stop", toolCallId: "tA" }));
|
||||
r.handle(partialText("stop", "", "completed"));
|
||||
expect(stripAnsi(text())).toBe("[tool-tA] >> a1\n[tool-tA] >> a2\nhello\n");
|
||||
expect(stripAnsi(text())).toBe("[tool-tA] -> a1\n[tool-tA] -> a2\nhello\n");
|
||||
});
|
||||
|
||||
it("queues everything while a user prompt is active and flushes after it ends", () => {
|
||||
@@ -411,10 +558,30 @@ describe("StreamRenderer", () => {
|
||||
r.beginUserPrompt(tc);
|
||||
r.noteApprovalDecision(tc, "allow");
|
||||
r.endUserPrompt();
|
||||
expect(stripAnsi(text())).toBe("[tool-p8] $ pwd\n✓ [approved]\n");
|
||||
expect(stripAnsi(text())).toBe("[tool-p8] exec_command <- $ pwd\n✓ [approved]\n");
|
||||
// A late approval_decision event is deduped by key and not re-rendered.
|
||||
r.handle(approvalDecision("allow", "p8"));
|
||||
expect(stripAnsi(text())).toBe("[tool-p8] $ pwd\n✓ [approved]\n");
|
||||
expect(stripAnsi(text())).toBe("[tool-p8] exec_command <- $ pwd\n✓ [approved]\n");
|
||||
});
|
||||
|
||||
it("prints the decoded file-tool payload before the approval prompt, without duplicating the call line", () => {
|
||||
const { stream, text } = collector();
|
||||
const r = new StreamRenderer(stream, t);
|
||||
const tc = toolCall({
|
||||
name: "edit_file",
|
||||
arguments: JSON.stringify({ file_path: "src/x.ts", old_string: "a", new_string: "b" }),
|
||||
toolCallId: "fp1",
|
||||
});
|
||||
r.beginUserPrompt(tc);
|
||||
r.noteApprovalDecision(tc, "allow");
|
||||
r.endUserPrompt();
|
||||
// Call line, payload lines (what the user is approving), then the result — with no
|
||||
// duplicated call line after the payload.
|
||||
expect(stripAnsi(text())).toBe(
|
||||
"[tool-fp1] edit_file src/x.ts\n" +
|
||||
"file_path: src/x.ts\nold_string: a\nnew_string: b\n" +
|
||||
"✓ [approved]\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-renders a half-streamed call line at approval and suppresses its late tail deltas", () => {
|
||||
@@ -447,7 +614,7 @@ describe("StreamRenderer", () => {
|
||||
// At approval time, the full call line is re-rendered in place from the complete message, right next to
|
||||
// the result; after unlocking, the late tail is deduped and must not start a duplicate call line after
|
||||
// the result line.
|
||||
expect(s).toContain("[tool-h7] $ git status\n✓ [approved]\n");
|
||||
expect(s).toContain("[tool-h7] exec_command <- $ git status\n✓ [approved]\n");
|
||||
expect(s.slice(s.indexOf("[approved]"))).not.toContain("[tool-h7]");
|
||||
});
|
||||
|
||||
@@ -531,7 +698,9 @@ describe("StreamRenderer", () => {
|
||||
toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "c5" }),
|
||||
"allow",
|
||||
);
|
||||
expect(stripAnsi(text())).toBe("[tool-c5] $ ls\nhi\n[tool-c5] $ ls\n✓ [approved]\n");
|
||||
expect(stripAnsi(text())).toBe(
|
||||
"[tool-c5] exec_command <- $ ls\nhi\n[tool-c5] exec_command <- $ ls\n✓ [approved]\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not re-render the call line when it is already adjacent to the decision", () => {
|
||||
@@ -551,7 +720,7 @@ describe("StreamRenderer", () => {
|
||||
toolCall({ name: "exec_command", arguments: '{"cmd":"ls"}', toolCallId: "c6" }),
|
||||
"deny",
|
||||
);
|
||||
expect(stripAnsi(text())).toBe("[tool-c6] $ ls\n× [denied]\n");
|
||||
expect(stripAnsi(text())).toBe("[tool-c6] exec_command <- $ ls\n× [denied]\n");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -574,7 +743,7 @@ describe("StreamRenderer — nested (origin-tagged) subagent messages", () => {
|
||||
),
|
||||
);
|
||||
r.handle(withOrigin(approvalDecision("allow", "cc1"), hop));
|
||||
expect(stripAnsi(text())).toBe("[agent-ild-tool-cc1] $ ls\n✓ [approved]\n");
|
||||
expect(stripAnsi(text())).toBe("[agent-ild-tool-cc1] exec_command <- $ ls\n✓ [approved]\n");
|
||||
});
|
||||
|
||||
it("renders the pending nested tool call at approval time when its stream copy has not arrived; dedupes the late copy", () => {
|
||||
@@ -586,11 +755,11 @@ describe("StreamRenderer — nested (origin-tagged) subagent messages", () => {
|
||||
);
|
||||
// The approval callback arrives before the forwarded message: beginUserPrompt renders the call line directly from the complete message.
|
||||
r.beginUserPrompt(tc);
|
||||
expect(stripAnsi(text())).toBe("[agent-ild-tool-cc9] $ ls\n");
|
||||
expect(stripAnsi(text())).toBe("[agent-ild-tool-cc9] exec_command <- $ ls\n");
|
||||
r.endUserPrompt();
|
||||
// The late forwarded copy is deduped by key and not re-rendered.
|
||||
r.handle(tc);
|
||||
expect(stripAnsi(text())).toBe("[agent-ild-tool-cc9] $ ls\n");
|
||||
expect(stripAnsi(text())).toBe("[agent-ild-tool-cc9] exec_command <- $ ls\n");
|
||||
});
|
||||
|
||||
it("renders the pending parent tool call at approval time and suppresses its late partial stream", () => {
|
||||
@@ -611,7 +780,7 @@ describe("StreamRenderer — nested (origin-tagged) subagent messages", () => {
|
||||
}),
|
||||
);
|
||||
r.handle(partialToolCall({ eventType: "stop", name: "", toolCallId: "p7" }));
|
||||
expect(stripAnsi(text())).toBe("[tool-p7] $ pwd\n");
|
||||
expect(stripAnsi(text())).toBe("[tool-p7] exec_command <- $ pwd\n");
|
||||
});
|
||||
|
||||
it("adds nested token_usage request totals to the task delta and the session total", () => {
|
||||
@@ -691,9 +860,9 @@ describe("renderHistory (resume)", () => {
|
||||
expect(s).toContain("> hello");
|
||||
expect(s).toContain("pondering");
|
||||
expect(s).toContain("hi there");
|
||||
expect(s).toContain("[tool-653] $ ls");
|
||||
expect(s).toContain("[tool-653] >> a.txt");
|
||||
expect(s).toContain("[tool-653] >> b.txt");
|
||||
expect(s).toContain("[tool-653] exec_command <- $ ls");
|
||||
expect(s).toContain("[tool-653] exec_command -> a.txt");
|
||||
expect(s).toContain("[tool-653] exec_command -> b.txt");
|
||||
// An interrupted message carries a marker (rendering includes the interrupted turn).
|
||||
expect(s).toContain("half answer [aborted]");
|
||||
});
|
||||
|
||||
@@ -1,72 +1,174 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderPartialToolCall } from "../src/tool-render.js";
|
||||
import {
|
||||
renderFileToolApprovalPayload,
|
||||
renderPartialToolCall,
|
||||
shortenPath,
|
||||
} from "../src/tool-render.js";
|
||||
|
||||
describe("renderPartialToolCall", () => {
|
||||
it("renders partial exec_command args as $ <cmd-so-far>", () => {
|
||||
describe("renderPartialToolCall — exec_command", () => {
|
||||
it("streams `exec_command <- $ {cmd}` when the schema has no description argument", () => {
|
||||
// The default path: the switch is off (or the tool is unknown), so nothing can supersede
|
||||
// the plain form and it streams character by character.
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":')).toBeNull();
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"l')).toBe("$ l");
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"ls"}')).toBe("$ ls");
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"echo \\"hi\\"')).toBe('$ echo "hi"');
|
||||
});
|
||||
|
||||
it("renders run_subagent as run_subagent << <prompt>, folded to one line", () => {
|
||||
expect(renderPartialToolCall("run_subagent", '{"prompt":')).toBeNull();
|
||||
expect(renderPartialToolCall("run_subagent", '{"prompt":"analy')).toBe("run_subagent << analy");
|
||||
expect(renderPartialToolCall("run_subagent", '{"prompt":"line1\\nline2"}')).toBe(
|
||||
"run_subagent << line1 line2",
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"l')).toBe("exec_command <- $ l");
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"ls"}')).toBe("exec_command <- $ ls");
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"echo \\"hi\\"')).toBe(
|
||||
'exec_command <- $ echo "hi"',
|
||||
);
|
||||
});
|
||||
|
||||
it("renders input_command polls (empty chars) without a payload", () => {
|
||||
expect(renderPartialToolCall("input_command", '{"process_id":')).toBeNull();
|
||||
expect(renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d"}')).toBe(
|
||||
"⌨ input_command → proc-1a2b3c4d",
|
||||
it("waits for the description when the schema carries the argument", () => {
|
||||
const described = { expectDescription: true };
|
||||
// Nothing renders while the description could still be the first thing shown...
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"ls -la', described)).toBeNull();
|
||||
// ...until the arguments settle without one, or the fragment is final.
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"ls -la"}', described)).toBe(
|
||||
"exec_command <- $ ls -la",
|
||||
);
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":""}'),
|
||||
).toBe("⌨ input_command → proc-1a2b3c4d");
|
||||
renderPartialToolCall("exec_command", '{"cmd":"ls -la', { ...described, final: true }),
|
||||
).toBe("exec_command <- $ ls -la");
|
||||
});
|
||||
|
||||
it("renders non-empty input_command chars with visible control characters", () => {
|
||||
it("renders `exec_command <- {description} ($ {cmd})` when a description is present", () => {
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"y\\n"}'),
|
||||
).toBe("⌨ input_command → proc-1a2b3c4d << y\\n");
|
||||
// U+0003 (Ctrl-C) is rendered in caret notation.
|
||||
renderPartialToolCall("exec_command", '{"description":"List files","cmd":"ls -la"}'),
|
||||
).toBe("exec_command <- List files ($ ls -la)");
|
||||
// Same final form regardless of the model's property order.
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"\\u0003"}'),
|
||||
).toBe("⌨ input_command → proc-1a2b3c4d << ^C");
|
||||
// Disambiguates literal backslash escapes: chars "a", "\", "n" render as a\\n, distinct from a real newline \n.
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"a\\\\n"}'),
|
||||
).toBe("⌨ input_command → proc-1a2b3c4d << a\\\\n");
|
||||
renderPartialToolCall("exec_command", '{"cmd":"ls -la","description":"List files"}'),
|
||||
).toBe("exec_command <- List files ($ ls -la)");
|
||||
// Multi-line descriptions fold to one line; an empty description falls back to the plain form.
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"ls","description":"a\\nb"}')).toBe(
|
||||
"exec_command <- a b ($ ls)",
|
||||
);
|
||||
expect(renderPartialToolCall("exec_command", '{"cmd":"ls","description":""}')).toBe(
|
||||
"exec_command <- $ ls",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps input_command previews append-only across \\uXXXX delta boundaries", () => {
|
||||
it("streams the description form append-only when the description arrives first", () => {
|
||||
const stages = [
|
||||
'{"process_id":"proc-1a2b3c4d","chars":"y',
|
||||
'{"process_id":"proc-1a2b3c4d","chars":"y\\u0',
|
||||
'{"process_id":"proc-1a2b3c4d","chars":"y\\u0003',
|
||||
'{"description":"List fi', // description streams live
|
||||
'{"description":"List files"', // description complete
|
||||
'{"description":"List files","cmd":"ls', // cmd streaming inside the open parenthesis
|
||||
'{"description":"List files","cmd":"ls -la"}', // cmd complete: parenthesis closes
|
||||
];
|
||||
const previews = stages.map((s) => renderPartialToolCall("input_command", s)!);
|
||||
expect(previews[0]).toBe("⌨ input_command → proc-1a2b3c4d << y");
|
||||
// An incomplete \u escape is treated as "stop here" rather than emitting the raw hex as literal text.
|
||||
expect(previews[1]).toBe("⌨ input_command → proc-1a2b3c4d << y");
|
||||
expect(previews[2]).toBe("⌨ input_command → proc-1a2b3c4d << y^C");
|
||||
const previews = stages.map((s) =>
|
||||
renderPartialToolCall("exec_command", s, { expectDescription: true }),
|
||||
);
|
||||
expect(previews[0]).toBe("exec_command <- List fi");
|
||||
expect(previews[1]).toBe("exec_command <- List files");
|
||||
expect(previews[2]).toBe("exec_command <- List files ($ ls");
|
||||
expect(previews[3]).toBe("exec_command <- List files ($ ls -la)");
|
||||
for (let i = 1; i < previews.length; i++) {
|
||||
expect(previews[i]!.startsWith(previews[i - 1]!)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders input_subagent polls without a payload and follow-up prompts with one", () => {
|
||||
it("never shows the plain form first when the model emits the payload before the description", () => {
|
||||
// The regression this guards: a plain line followed by a described one for the same call.
|
||||
const stages = [
|
||||
'{"cmd":"ls -la', // withheld: the schema says a description is coming
|
||||
'{"cmd":"ls -la","description":"List fi', // description streams; payload waits for it
|
||||
'{"cmd":"ls -la","description":"List files"}', // settled: payload appended
|
||||
];
|
||||
const previews = stages.map((s) =>
|
||||
renderPartialToolCall("exec_command", s, { expectDescription: true }),
|
||||
);
|
||||
expect(previews[0]).toBeNull();
|
||||
expect(previews[1]).toBe("exec_command <- List fi");
|
||||
expect(previews[2]).toBe("exec_command <- List files ($ ls -la)");
|
||||
expect(previews[2]!.startsWith(previews[1]!)).toBe(true);
|
||||
expect(previews.some((p) => p === "exec_command <- $ ls -la")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPartialToolCall — run_subagent", () => {
|
||||
it("renders `run_subagent <- {prompt}` and the description form", () => {
|
||||
expect(renderPartialToolCall("run_subagent", '{"prompt":')).toBeNull();
|
||||
expect(renderPartialToolCall("run_subagent", '{"prompt":"analy')).toBe("run_subagent <- analy");
|
||||
expect(renderPartialToolCall("run_subagent", '{"prompt":"line1\\nline2"}')).toBe(
|
||||
"run_subagent <- line1 line2",
|
||||
);
|
||||
expect(
|
||||
renderPartialToolCall(
|
||||
"run_subagent",
|
||||
'{"description":"Delegating research","prompt":"do the thing"}',
|
||||
),
|
||||
).toBe("run_subagent <- Delegating research (do the thing)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPartialToolCall — input_command / input_subagent", () => {
|
||||
it("renders polls (empty chars) without a payload", () => {
|
||||
expect(renderPartialToolCall("input_command", '{"process_id":')).toBeNull();
|
||||
expect(renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d"}')).toBe(
|
||||
"input_command <- proc-1a2b3c4d",
|
||||
);
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":""}'),
|
||||
).toBe("input_command <- proc-1a2b3c4d");
|
||||
});
|
||||
|
||||
it("renders non-empty chars with visible control characters", () => {
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"y\\n"}'),
|
||||
).toBe("input_command <- proc-1a2b3c4d << y\\n");
|
||||
// U+0003 (Ctrl-C) is rendered in caret notation.
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"\\u0003"}'),
|
||||
).toBe("input_command <- proc-1a2b3c4d << ^C");
|
||||
// Disambiguates literal backslash escapes: chars "a", "\", "n" render as a\\n, distinct from a real newline \n.
|
||||
expect(
|
||||
renderPartialToolCall("input_command", '{"process_id":"proc-1a2b3c4d","chars":"a\\\\n"}'),
|
||||
).toBe("input_command <- proc-1a2b3c4d << a\\\\n");
|
||||
});
|
||||
|
||||
it("wraps the payload in parentheses after the description", () => {
|
||||
expect(
|
||||
renderPartialToolCall(
|
||||
"input_command",
|
||||
'{"description":"Confirm the prompt","process_id":"proc-1a2b3c4d","chars":"y\\n"}',
|
||||
),
|
||||
).toBe("input_command <- Confirm the prompt (proc-1a2b3c4d << y\\n)");
|
||||
expect(
|
||||
renderPartialToolCall(
|
||||
"input_subagent",
|
||||
'{"description":"Poll for progress","subagent_id":"subagent-9f8e7d6c"}',
|
||||
),
|
||||
).toBe("input_subagent <- Poll for progress (subagent-9f8e7d6c)");
|
||||
});
|
||||
|
||||
it("keeps input_command previews append-only across \\uXXXX delta boundaries", () => {
|
||||
// Schema order (description first) keeps the whole call streaming live.
|
||||
const stages = [
|
||||
'{"description":"Confirm","process_id":"proc-1a2b3c4d","chars":"y',
|
||||
'{"description":"Confirm","process_id":"proc-1a2b3c4d","chars":"y\\u0',
|
||||
'{"description":"Confirm","process_id":"proc-1a2b3c4d","chars":"y\\u0003',
|
||||
];
|
||||
const previews = stages.map((s) =>
|
||||
renderPartialToolCall("input_command", s, { expectDescription: true })!,
|
||||
);
|
||||
expect(previews[0]).toBe("input_command <- Confirm (proc-1a2b3c4d << y");
|
||||
// An incomplete \u escape is treated as "stop here" rather than emitting the raw hex as literal text.
|
||||
expect(previews[1]).toBe("input_command <- Confirm (proc-1a2b3c4d << y");
|
||||
expect(previews[2]).toBe("input_command <- Confirm (proc-1a2b3c4d << y^C");
|
||||
for (let i = 1; i < previews.length; i++) {
|
||||
expect(previews[i]!.startsWith(previews[i - 1]!)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders input_subagent polls and follow-up prompts", () => {
|
||||
expect(
|
||||
renderPartialToolCall("input_subagent", '{"subagent_id":"subagent-9f8e7d6c","prompt":""}'),
|
||||
).toBe("⌨ input_subagent → subagent-9f8e7d6c");
|
||||
).toBe("input_subagent <- subagent-9f8e7d6c");
|
||||
expect(
|
||||
renderPartialToolCall(
|
||||
"input_subagent",
|
||||
'{"subagent_id":"subagent-9f8e7d6c","prompt":"continue with the tests"}',
|
||||
),
|
||||
).toBe("⌨ input_subagent → subagent-9f8e7d6c << continue with the tests");
|
||||
).toBe("input_subagent <- subagent-9f8e7d6c << continue with the tests");
|
||||
});
|
||||
|
||||
it("truncates long payload previews and stops growing afterwards", () => {
|
||||
@@ -75,15 +177,95 @@ describe("renderPartialToolCall", () => {
|
||||
"input_subagent",
|
||||
`{"subagent_id":"subagent-9f8e7d6c","prompt":"${long}"}`,
|
||||
);
|
||||
expect(capped).toBe(`⌨ input_subagent → subagent-9f8e7d6c << ${"x".repeat(120)}…`);
|
||||
expect(capped).toBe(`input_subagent <- subagent-9f8e7d6c << ${"x".repeat(120)}…`);
|
||||
const longer = renderPartialToolCall(
|
||||
"input_subagent",
|
||||
`{"subagent_id":"subagent-9f8e7d6c","prompt":"${long}yyy"}`,
|
||||
);
|
||||
expect(longer).toBe(capped);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPartialToolCall — file tools", () => {
|
||||
it("renders `<name> <shortened path>` only once the path is complete", () => {
|
||||
expect(renderPartialToolCall("read_file", '{"file_path":')).toBeNull();
|
||||
// A still-streaming path is withheld: shortening a growing path would rewrite the line.
|
||||
expect(renderPartialToolCall("read_file", '{"file_path":"src/ap')).toBeNull();
|
||||
expect(renderPartialToolCall("read_file", '{"file_path":"src/app.py","offset":10}')).toBe(
|
||||
"read_file src/app.py",
|
||||
);
|
||||
expect(
|
||||
renderPartialToolCall("edit_file", '{"file_path":"a.txt","old_string":"x","new_string":"y"}'),
|
||||
).toBe("edit_file a.txt");
|
||||
expect(
|
||||
renderPartialToolCall("write_file", '{"file_path":"packages/core/src/state/out.ts"}'),
|
||||
).toBe("write_file …/state/out.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPartialToolCall — fallback", () => {
|
||||
it("falls back to name(args-prefix) for unknown tools", () => {
|
||||
expect(renderPartialToolCall("search", '{"q":"hi')).toBe('search({"q":"hi');
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortenPath", () => {
|
||||
it("keeps at most one parent directory plus the filename", () => {
|
||||
expect(shortenPath("file.ts")).toBe("file.ts");
|
||||
expect(shortenPath("src/file.ts")).toBe("src/file.ts");
|
||||
expect(shortenPath("/etc/hosts")).toBe("/etc/hosts");
|
||||
expect(shortenPath("packages/core/src/state/default-config.ts")).toBe(
|
||||
"…/state/default-config.ts",
|
||||
);
|
||||
expect(shortenPath("/home/user/project/src/app.py")).toBe("…/src/app.py");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderFileToolApprovalPayload", () => {
|
||||
it("prints the decoded edit_file payload with gutters for multi-line fields", () => {
|
||||
const payload = renderFileToolApprovalPayload(
|
||||
"edit_file",
|
||||
JSON.stringify({
|
||||
file_path: "src/app.py",
|
||||
old_string: "a\nb",
|
||||
new_string: "a\nc",
|
||||
}),
|
||||
);
|
||||
expect(payload).toBe(
|
||||
[
|
||||
"file_path: src/app.py",
|
||||
"old_string:",
|
||||
" | a",
|
||||
" | b",
|
||||
"new_string:",
|
||||
" | a",
|
||||
" | c",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("prints write_file content and read_file window arguments", () => {
|
||||
expect(
|
||||
renderFileToolApprovalPayload("write_file", '{"file_path":"out.md","content":"hello"}'),
|
||||
).toBe(["file_path: out.md", "content: hello"].join("\n"));
|
||||
expect(
|
||||
renderFileToolApprovalPayload("read_file", '{"file_path":"a.txt","offset":3,"limit":5}'),
|
||||
).toBe(["file_path: a.txt", "offset: 3", "limit: 5"].join("\n"));
|
||||
});
|
||||
|
||||
it("bounds the payload to a line count with an explicit elision note", () => {
|
||||
const content = Array.from({ length: 60 }, (_, i) => `line-${i + 1}`).join("\n");
|
||||
const payload = renderFileToolApprovalPayload(
|
||||
"write_file",
|
||||
JSON.stringify({ file_path: "big.txt", content }),
|
||||
)!;
|
||||
const lines = payload.split("\n");
|
||||
// 24 shown lines + the elision note.
|
||||
expect(lines).toHaveLength(25);
|
||||
expect(lines[lines.length - 1]).toMatch(/\[… \d+ more lines not shown\]/);
|
||||
});
|
||||
|
||||
it("returns null for non-file tools", () => {
|
||||
expect(renderFileToolApprovalPayload("exec_command", '{"cmd":"ls"}')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,9 @@ export { Environment } from "./environment.js";
|
||||
export type { BuiltinTool, ToolExecutionContext } from "./tools/types.js";
|
||||
export { BUILTIN_TOOL_FACTORIES } from "./tools/registry.js";
|
||||
export type { BuiltinToolFactory } from "./tools/registry.js";
|
||||
export { createReadFileTool, READ_FILE_NAME } from "./tools/read-file.js";
|
||||
export { createEditFileTool, EDIT_FILE_NAME } from "./tools/edit-file.js";
|
||||
export { createWriteFileTool, WRITE_FILE_NAME } from "./tools/write-file.js";
|
||||
export { createExecCommandTool, EXEC_COMMAND_NAME } from "./tools/exec-command.js";
|
||||
export { createInputCommandTool, INPUT_COMMAND_NAME } from "./tools/input-command.js";
|
||||
export { createSubagentTool, SUBAGENT_NAME } from "./tools/run-subagent.js";
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Unified-diff rendering for the file tools' outputs (edit_file / write_file), modeled on
|
||||
* git's presentation: `@@ -oldStart,oldCount +newStart,newCount @@` hunk headers, ` `
|
||||
* context lines, `-` removed and `+` added lines, 3 lines of context, nearby changes
|
||||
* merged into one hunk.
|
||||
*
|
||||
* Two hunk builders share the renderer:
|
||||
* - `buildReplacementHunks` (edit_file): exact by construction — the replacement sites are
|
||||
* known, so each hunk is derived by re-applying the replacement to the affected line
|
||||
* region; no diff algorithm, cost independent of file size.
|
||||
* - `buildLineDiffHunks` (write_file): a real line diff (LCS over the middle remaining
|
||||
* after common prefix/suffix trimming) for arbitrary old/new contents, with a size guard
|
||||
* that falls back to a `+X/−Y` summary when the middle is too large to diff cheaply.
|
||||
*
|
||||
* Display safety: diff lines are capped in length and trailing `\r` is stripped (a raw CR
|
||||
* would glitch terminal rendering); CRLF-vs-LF differences therefore do not show as
|
||||
* whole-file rewrites in the display, while counts stay based on the true content.
|
||||
*/
|
||||
|
||||
/** Context lines shown on each side of a change (git default). */
|
||||
export const DIFF_CONTEXT_LINES = 3;
|
||||
|
||||
/** Max characters kept of a single diff line; the rest is replaced by a truncation marker. */
|
||||
const MAX_DIFF_LINE_LENGTH = 2000;
|
||||
|
||||
/** DP cell cap for the LCS line diff: above this the write_file diff falls back to a summary. */
|
||||
const MAX_LCS_CELLS = 250_000;
|
||||
|
||||
/** One unified hunk: header numbers plus already-prefixed (` `/`-`/`+`) display lines. */
|
||||
export interface DiffHunk {
|
||||
oldStart: number;
|
||||
oldCount: number;
|
||||
newStart: number;
|
||||
newCount: number;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
/** Caps a diff line for display and strips a trailing `\r` (CRLF files). */
|
||||
function capDiffLine(prefix: string, content: string): string {
|
||||
const noCr = content.endsWith("\r") ? content.slice(0, -1) : content;
|
||||
const capped =
|
||||
noCr.length > MAX_DIFF_LINE_LENGTH
|
||||
? `${noCr.slice(0, MAX_DIFF_LINE_LENGTH)}… [line truncated]`
|
||||
: noCr;
|
||||
return `${prefix}${capped}`;
|
||||
}
|
||||
|
||||
/** Renders one hunk in git's unified format (a count of 0 backs the start up by one, as git does). */
|
||||
export function renderHunk(h: DiffHunk): string {
|
||||
const oldStart = h.oldCount === 0 ? h.oldStart - 1 : h.oldStart;
|
||||
const newStart = h.newCount === 0 ? h.newStart - 1 : h.newStart;
|
||||
return [`@@ -${oldStart},${h.oldCount} +${newStart},${h.newCount} @@`, ...h.lines].join("\n");
|
||||
}
|
||||
|
||||
/** Splits content into lines the way git counts them: a trailing newline terminates the last line instead of adding an empty one. */
|
||||
export function contentLines(content: string): string[] {
|
||||
if (content === "") return [];
|
||||
const lines = content.split("\n");
|
||||
if (lines[lines.length - 1] === "") lines.pop();
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** 1-based line number containing the character at `pos` (a newline belongs to the line it terminates). */
|
||||
function lineOfChar(content: string, pos: number): number {
|
||||
let line = 1;
|
||||
for (let i = 0; i < pos && i < content.length; i += 1) {
|
||||
if (content.charCodeAt(i) === 10) line += 1;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
/** Char offset where 1-based line `line` starts. */
|
||||
function lineStartOffset(content: string, line: number): number {
|
||||
let current = 1;
|
||||
for (let i = 0; i < content.length; i += 1) {
|
||||
if (current === line) return i;
|
||||
if (content.charCodeAt(i) === 10) current += 1;
|
||||
}
|
||||
return content.length;
|
||||
}
|
||||
|
||||
/** Char offset just past the end of 1-based line `line` (including its terminating newline when present). */
|
||||
function lineEndOffset(content: string, line: number): number {
|
||||
const start = lineStartOffset(content, line);
|
||||
const nl = content.indexOf("\n", start);
|
||||
return nl === -1 ? content.length : nl + 1;
|
||||
}
|
||||
|
||||
export interface ReplacementDiff {
|
||||
/** Hunks in file order, each with the number of replacement occurrences it covers. */
|
||||
hunks: { hunk: DiffHunk; sites: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds unified hunks for edit_file: one hunk per replacement site, with sites whose
|
||||
* context ranges touch merged into a single hunk. Each hunk's `+` side is derived by
|
||||
* re-applying the replacement to the affected region, so it is exact by construction
|
||||
* (including several occurrences on one line). `maxHunks` caps the output; each hunk
|
||||
* reports how many occurrences it covers so the caller can note the uncovered rest.
|
||||
*/
|
||||
export function buildReplacementHunks(
|
||||
oldContent: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll: boolean,
|
||||
maxHunks: number,
|
||||
): ReplacementDiff {
|
||||
// Occurrence positions in the old content (non-overlapping, left to right).
|
||||
const positions: number[] = [];
|
||||
let idx = oldContent.indexOf(oldString);
|
||||
while (idx !== -1) {
|
||||
positions.push(idx);
|
||||
if (!replaceAll) break;
|
||||
idx = oldContent.indexOf(oldString, idx + oldString.length);
|
||||
}
|
||||
const oldLines = contentLines(oldContent);
|
||||
const total = oldLines.length;
|
||||
|
||||
// Line range touched by each site, then merge sites whose context ranges touch.
|
||||
const ranges = positions.map((pos) => {
|
||||
const a1 = lineOfChar(oldContent, pos);
|
||||
const a2 = lineOfChar(oldContent, pos + Math.max(0, oldString.length - 1));
|
||||
return { a1, a2, sites: 1 };
|
||||
});
|
||||
const merged: { a1: number; a2: number; sites: number }[] = [];
|
||||
for (const r of ranges) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && r.a1 - DIFF_CONTEXT_LINES <= last.a2 + DIFF_CONTEXT_LINES) {
|
||||
last.a2 = Math.max(last.a2, r.a2);
|
||||
last.sites += r.sites;
|
||||
} else {
|
||||
merged.push({ ...r });
|
||||
}
|
||||
}
|
||||
|
||||
const hunks: { hunk: DiffHunk; sites: number }[] = [];
|
||||
let lineDelta = 0; // Cumulative new-minus-old line shift from earlier hunks
|
||||
for (const region of merged) {
|
||||
if (hunks.length >= maxHunks) break;
|
||||
// Exact regional replacement: extract the affected whole-line region and re-apply.
|
||||
const regionText = oldContent.slice(
|
||||
lineStartOffset(oldContent, region.a1),
|
||||
lineEndOffset(oldContent, region.a2),
|
||||
);
|
||||
const newRegionText = regionText.split(oldString).join(newString);
|
||||
let minus = contentLines(regionText);
|
||||
let plus = contentLines(newRegionText);
|
||||
// Shared leading/trailing lines become context instead of noise.
|
||||
let leadShift = 0;
|
||||
while (minus.length > 0 && plus.length > 0 && minus[0] === plus[0]) {
|
||||
minus = minus.slice(1);
|
||||
plus = plus.slice(1);
|
||||
leadShift += 1;
|
||||
}
|
||||
let tailShift = 0;
|
||||
while (
|
||||
minus.length > 0 &&
|
||||
plus.length > 0 &&
|
||||
minus[minus.length - 1] === plus[plus.length - 1]
|
||||
) {
|
||||
minus = minus.slice(0, -1);
|
||||
plus = plus.slice(0, -1);
|
||||
tailShift += 1;
|
||||
}
|
||||
const changeStart = region.a1 + leadShift; // First old line actually changed
|
||||
const changeEnd = region.a2 - tailShift;
|
||||
const preFrom = Math.max(1, changeStart - DIFF_CONTEXT_LINES);
|
||||
const postTo = Math.min(total, changeEnd + DIFF_CONTEXT_LINES);
|
||||
const lines: string[] = [];
|
||||
for (let n = preFrom; n < changeStart; n += 1) lines.push(capDiffLine(" ", oldLines[n - 1]!));
|
||||
for (const l of minus) lines.push(capDiffLine("-", l));
|
||||
for (const l of plus) lines.push(capDiffLine("+", l));
|
||||
for (let n = changeEnd + 1; n <= postTo; n += 1) lines.push(capDiffLine(" ", oldLines[n - 1]!));
|
||||
const contextCount = changeStart - preFrom + (postTo - changeEnd);
|
||||
hunks.push({
|
||||
hunk: {
|
||||
oldStart: preFrom,
|
||||
oldCount: contextCount + minus.length,
|
||||
newStart: preFrom + lineDelta,
|
||||
newCount: contextCount + plus.length,
|
||||
lines,
|
||||
},
|
||||
sites: region.sites,
|
||||
});
|
||||
lineDelta += plus.length - minus.length;
|
||||
}
|
||||
return { hunks };
|
||||
}
|
||||
|
||||
export type LineDiffResult =
|
||||
| { kind: "hunks"; hunks: DiffHunk[]; plus: number; minus: number }
|
||||
| { kind: "too-large"; plus: number; minus: number }
|
||||
| { kind: "identical" };
|
||||
|
||||
/**
|
||||
* Line diff between two full contents (write_file overwrite): trims the common
|
||||
* prefix/suffix, LCS-diffs the middle, and groups changes into context-3 hunks. When the
|
||||
* middle is too large to diff cheaply, reports the middle sizes as a `+X/−Y` summary
|
||||
* instead.
|
||||
*/
|
||||
export function buildLineDiffHunks(oldContent: string, newContent: string): LineDiffResult {
|
||||
if (oldContent === newContent) return { kind: "identical" };
|
||||
const a = contentLines(oldContent);
|
||||
const b = contentLines(newContent);
|
||||
let prefix = 0;
|
||||
while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix += 1;
|
||||
let suffix = 0;
|
||||
while (
|
||||
suffix < a.length - prefix &&
|
||||
suffix < b.length - prefix &&
|
||||
a[a.length - 1 - suffix] === b[b.length - 1 - suffix]
|
||||
) {
|
||||
suffix += 1;
|
||||
}
|
||||
const midA = a.slice(prefix, a.length - suffix);
|
||||
const midB = b.slice(prefix, b.length - suffix);
|
||||
if (midA.length * midB.length > MAX_LCS_CELLS) {
|
||||
return { kind: "too-large", plus: midB.length, minus: midA.length };
|
||||
}
|
||||
|
||||
// LCS table over the middles, then backtrack into per-line ops.
|
||||
const n = midA.length;
|
||||
const m = midB.length;
|
||||
const width = m + 1;
|
||||
const table = new Int32Array((n + 1) * width);
|
||||
for (let i = n - 1; i >= 0; i -= 1) {
|
||||
for (let j = m - 1; j >= 0; j -= 1) {
|
||||
table[i * width + j] =
|
||||
midA[i] === midB[j]
|
||||
? table[(i + 1) * width + j + 1]! + 1
|
||||
: Math.max(table[(i + 1) * width + j]!, table[i * width + j + 1]!);
|
||||
}
|
||||
}
|
||||
// Ops over the middle region: "keep" advances both sides, "del"/"ins" one side.
|
||||
const ops: ("keep" | "del" | "ins")[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (midA[i] === midB[j]) {
|
||||
ops.push("keep");
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (table[(i + 1) * width + j]! >= table[i * width + j + 1]!) {
|
||||
ops.push("del");
|
||||
i += 1;
|
||||
} else {
|
||||
ops.push("ins");
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
while (i < n) {
|
||||
ops.push("del");
|
||||
i += 1;
|
||||
}
|
||||
while (j < m) {
|
||||
ops.push("ins");
|
||||
j += 1;
|
||||
}
|
||||
|
||||
// Group changed ops into hunks with DIFF_CONTEXT_LINES of context (merging when the
|
||||
// context ranges touch). Walk ops tracking old/new line cursors over the FULL files.
|
||||
interface Pending {
|
||||
startOld: number; // 1-based old line of the first display line
|
||||
startNew: number;
|
||||
lines: string[];
|
||||
oldCount: number;
|
||||
newCount: number;
|
||||
plus: number;
|
||||
minus: number;
|
||||
trailingContext: number;
|
||||
}
|
||||
const hunks: DiffHunk[] = [];
|
||||
let totalPlus = 0;
|
||||
let totalMinus = 0;
|
||||
let pending: Pending | null = null;
|
||||
let oldLine = prefix + 1; // 1-based cursors positioned at the middle's start
|
||||
let newLine = prefix + 1;
|
||||
// Context ring of the most recent unchanged lines before a change.
|
||||
const ring: { text: string; oldLine: number; newLine: number }[] = [];
|
||||
|
||||
const flush = (): void => {
|
||||
if (!pending) return;
|
||||
// Drop surplus trailing context beyond the window.
|
||||
while (pending.trailingContext > DIFF_CONTEXT_LINES) {
|
||||
pending.lines.pop();
|
||||
pending.oldCount -= 1;
|
||||
pending.newCount -= 1;
|
||||
pending.trailingContext -= 1;
|
||||
}
|
||||
hunks.push({
|
||||
oldStart: pending.startOld,
|
||||
oldCount: pending.oldCount,
|
||||
newStart: pending.startNew,
|
||||
newCount: pending.newCount,
|
||||
lines: pending.lines,
|
||||
});
|
||||
pending = null;
|
||||
};
|
||||
|
||||
const ensurePending = (): Pending => {
|
||||
if (pending) {
|
||||
pending.trailingContext = 0;
|
||||
return pending;
|
||||
}
|
||||
const ctx = ring.slice(-DIFF_CONTEXT_LINES);
|
||||
pending = {
|
||||
startOld: ctx.length > 0 ? ctx[0]!.oldLine : oldLine,
|
||||
startNew: ctx.length > 0 ? ctx[0]!.newLine : newLine,
|
||||
lines: ctx.map((c) => capDiffLine(" ", c.text)),
|
||||
oldCount: ctx.length,
|
||||
newCount: ctx.length,
|
||||
plus: 0,
|
||||
minus: 0,
|
||||
trailingContext: 0,
|
||||
};
|
||||
return pending;
|
||||
};
|
||||
|
||||
const onKeep = (text: string): void => {
|
||||
if (pending) {
|
||||
if (pending.trailingContext >= 2 * DIFF_CONTEXT_LINES) {
|
||||
// Far enough from the last change: close the hunk (surplus context trimmed in
|
||||
// flush; the trimmed lines are not re-fed into the ring, so two changes 8-13
|
||||
// lines apart may show slightly less than full leading context on the second
|
||||
// hunk — a presentation nuance only, the numbers stay exact).
|
||||
flush();
|
||||
ring.length = 0;
|
||||
} else {
|
||||
pending.lines.push(capDiffLine(" ", text));
|
||||
pending.oldCount += 1;
|
||||
pending.newCount += 1;
|
||||
pending.trailingContext += 1;
|
||||
}
|
||||
}
|
||||
if (!pending) {
|
||||
ring.push({ text, oldLine, newLine });
|
||||
if (ring.length > DIFF_CONTEXT_LINES) ring.shift();
|
||||
}
|
||||
oldLine += 1;
|
||||
newLine += 1;
|
||||
};
|
||||
|
||||
// Prefix/suffix lines are unchanged context feeding the ring only near the middle;
|
||||
// seed the ring with the tail of the common prefix.
|
||||
for (let k = Math.max(0, prefix - DIFF_CONTEXT_LINES); k < prefix; k += 1) {
|
||||
ring.push({ text: a[k]!, oldLine: k + 1, newLine: k + 1 });
|
||||
}
|
||||
|
||||
let ai = prefix;
|
||||
let bi = prefix;
|
||||
for (const op of ops) {
|
||||
if (op === "keep") {
|
||||
onKeep(a[ai]!);
|
||||
ai += 1;
|
||||
bi += 1;
|
||||
} else if (op === "del") {
|
||||
const h = ensurePending();
|
||||
h.lines.push(capDiffLine("-", a[ai]!));
|
||||
h.oldCount += 1;
|
||||
h.minus += 1;
|
||||
totalMinus += 1;
|
||||
ai += 1;
|
||||
oldLine += 1;
|
||||
} else {
|
||||
const h = ensurePending();
|
||||
h.lines.push(capDiffLine("+", b[bi]!));
|
||||
h.newCount += 1;
|
||||
h.plus += 1;
|
||||
totalPlus += 1;
|
||||
bi += 1;
|
||||
newLine += 1;
|
||||
}
|
||||
}
|
||||
// Trailing suffix lines: at most the context window matters.
|
||||
for (let k = 0; k < Math.min(suffix, DIFF_CONTEXT_LINES + 1); k += 1) {
|
||||
onKeep(a[a.length - suffix + k]!);
|
||||
}
|
||||
flush();
|
||||
return { kind: "hunks", hunks, plus: totalPlus, minus: totalMinus };
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* edit_file — exact-string file editing tool, a builtin tool implementation (BuiltinTool).
|
||||
*
|
||||
* Replaces `old_string` with `new_string` in an existing file. `old_string` must match the
|
||||
* file content exactly (including whitespace/indentation) and, unless `replace_all` is set,
|
||||
* occur exactly once — zero or multiple occurrences fail with an explanation telling the
|
||||
* model to fix the match or widen the context. On success the output confirms the
|
||||
* replacement count and shows a git-style unified diff of the changed regions (one hunk
|
||||
* per replacement site, nearby sites merged; capped for replace_all storms), so both the
|
||||
* model and the user can verify exactly what changed without re-reading the file. The
|
||||
* write is atomic (temp file + rename, preserving the original permission bits), so a
|
||||
* crash mid-write cannot leave the file half-edited. Relative paths resolve against the
|
||||
* Workspace; absolute paths are allowed (tools run with the user's full permissions, same
|
||||
* as the shell tool).
|
||||
*
|
||||
* Division of responsibility with Environment (see environment.ts): non-streaming — yields
|
||||
* one final text delta; failures are explanatory text finalized as `failed`; anything
|
||||
* unexpected that still throws is caught by Environment and likewise finalized as failed.
|
||||
* If interrupted, only reports `aborted` — the interruption note is appended by
|
||||
* Environment.
|
||||
* Docs: /docs/tools § "File tools".
|
||||
*/
|
||||
import path from "node:path";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { partialToolCallOutput } from "../../omnimessage/index.js";
|
||||
import type { OmniMessage } from "../../omnimessage/index.js";
|
||||
import type { ToolDefinitionConfig } from "../../interfaces.js";
|
||||
import type { BuiltinTool, ToolExecutionContext, ToolResult } from "./types.js";
|
||||
import { atomicWriteFile } from "./file-utils.js";
|
||||
import { buildReplacementHunks, renderHunk } from "./diff.js";
|
||||
|
||||
/** Tool name constant (used only within this tool module, never exposed to Environment). */
|
||||
export const EDIT_FILE_NAME = "edit_file";
|
||||
|
||||
/** Max diff hunks shown in the result (replace_all over a large file stays readable). */
|
||||
const MAX_DIFF_HUNKS = 5;
|
||||
|
||||
/** Output-budget headroom reserved for the trailing "…and N more replacements" note. */
|
||||
const NOTE_RESERVE = 120;
|
||||
|
||||
/** Fallback output budget when the definition carries no maxOutputLength (mirrors the default config entry). */
|
||||
const DEFAULT_OUTPUT_BUDGET = 16000;
|
||||
|
||||
/** Counts non-overlapping occurrences of `needle` in `haystack` (needle is non-empty here). */
|
||||
function countOccurrences(haystack: string, needle: string): number {
|
||||
let count = 0;
|
||||
let index = haystack.indexOf(needle);
|
||||
while (index !== -1) {
|
||||
count += 1;
|
||||
index = haystack.indexOf(needle, index + needle.length);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* edit_file builtin tool: reads the file, validates the uniqueness of `old_string`,
|
||||
* writes the replaced content back atomically, and reports a unified diff of the change.
|
||||
* `definition` is overridden by Environment at construction time with the same-named entry
|
||||
* from ToolConfig (description/arguments/permissions/limits).
|
||||
*/
|
||||
export function createEditFileTool(definition: ToolDefinitionConfig): BuiltinTool {
|
||||
return {
|
||||
name: definition.name,
|
||||
definition,
|
||||
async *execute(
|
||||
args: Record<string, unknown>,
|
||||
ctx: ToolExecutionContext,
|
||||
): AsyncGenerator<OmniMessage, ToolResult | void> {
|
||||
const { toolCallId, signal } = ctx;
|
||||
const delta = (output: string): OmniMessage =>
|
||||
partialToolCallOutput({ eventType: "delta", output, toolCallId });
|
||||
|
||||
const filePath = args["file_path"];
|
||||
if (typeof filePath !== "string" || filePath.length === 0) {
|
||||
yield delta(`Missing required argument "file_path" for ${definition.name}.`);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
const oldString = args["old_string"];
|
||||
if (typeof oldString !== "string") {
|
||||
yield delta(`Missing required argument "old_string" for ${definition.name}.`);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
if (oldString.length === 0) {
|
||||
yield delta(
|
||||
"old_string must not be empty — edit_file replaces existing text. To create a file or rewrite it wholesale, use write_file.",
|
||||
);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
const newString = args["new_string"];
|
||||
if (typeof newString !== "string") {
|
||||
yield delta(`Missing required argument "new_string" for ${definition.name}.`);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
if (oldString === newString) {
|
||||
yield delta(
|
||||
"old_string and new_string are identical — nothing to change. Make new_string the desired replacement text.",
|
||||
);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
const replaceAll = args["replace_all"] === true;
|
||||
|
||||
const resolved = path.resolve(ctx.workspaceDir, filePath);
|
||||
let content: string;
|
||||
let fileMode: number | undefined;
|
||||
try {
|
||||
const st = await stat(resolved);
|
||||
if (st.isDirectory()) {
|
||||
yield delta(`Cannot edit "${filePath}": it is a directory.`);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
fileMode = st.mode & 0o777;
|
||||
content = await readFile(resolved, { encoding: "utf8", ...(signal ? { signal } : {}) });
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return { stopReason: "aborted" };
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
yield delta(
|
||||
`File not found: "${filePath}". edit_file only edits existing files — check the path, or use write_file to create it.`,
|
||||
);
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
yield delta(`Failed to read "${filePath}": ${message}`);
|
||||
}
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
if (signal?.aborted) return { stopReason: "aborted" };
|
||||
|
||||
const occurrences = countOccurrences(content, oldString);
|
||||
if (occurrences === 0) {
|
||||
// A CRLF file is the classic silent mismatch: text copied from read_file's display
|
||||
// has bare \n while the file has \r\n — say so explicitly.
|
||||
const crlfHint = content.includes("\r\n")
|
||||
? " Note: the file uses CRLF (\\r\\n) line endings — a multi-line old_string must include the \\r characters."
|
||||
: "";
|
||||
yield delta(
|
||||
`old_string not found in "${filePath}". Make sure it matches the file content exactly, including whitespace and indentation.${crlfHint}`,
|
||||
);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
if (occurrences > 1 && !replaceAll) {
|
||||
yield delta(
|
||||
`old_string occurs ${occurrences} times in "${filePath}". Add surrounding context to make it unique, or set replace_all to true to replace every occurrence.`,
|
||||
);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
|
||||
const replaceStart = content.indexOf(oldString);
|
||||
const newContent = replaceAll
|
||||
? content.split(oldString).join(newString)
|
||||
: content.slice(0, replaceStart) +
|
||||
newString +
|
||||
content.slice(replaceStart + oldString.length);
|
||||
try {
|
||||
await atomicWriteFile(resolved, newContent, {
|
||||
...(fileMode !== undefined ? { mode: fileMode } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return { stopReason: "aborted" };
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
yield delta(`Failed to write "${filePath}": ${message}`);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
|
||||
const replaced = replaceAll ? occurrences : 1;
|
||||
// Git-style unified diff of the changed regions, self-budgeted below the tool's
|
||||
// output cap so the leading summary line (and the elision note) always survive
|
||||
// Environment's front-keep truncation.
|
||||
const { hunks } = buildReplacementHunks(
|
||||
content,
|
||||
oldString,
|
||||
newString,
|
||||
replaceAll,
|
||||
MAX_DIFF_HUNKS,
|
||||
);
|
||||
const budget =
|
||||
definition.maxOutputLength !== undefined && definition.maxOutputLength > 0
|
||||
? definition.maxOutputLength
|
||||
: DEFAULT_OUTPUT_BUDGET;
|
||||
const out: string[] = [
|
||||
`Replaced ${replaced} occurrence${replaced === 1 ? "" : "s"} in "${filePath}".`,
|
||||
];
|
||||
let used = out[0]!.length;
|
||||
let shownSites = 0;
|
||||
for (const { hunk, sites } of hunks) {
|
||||
const rendered = renderHunk(hunk);
|
||||
if (used + 1 + rendered.length > budget - NOTE_RESERVE) break;
|
||||
out.push(rendered);
|
||||
used += 1 + rendered.length;
|
||||
shownSites += sites;
|
||||
}
|
||||
const omitted = replaced - shownSites;
|
||||
if (omitted > 0) {
|
||||
out.push(`…and ${omitted} more replacement${omitted === 1 ? "" : "s"}`);
|
||||
}
|
||||
yield delta(out.join("\n"));
|
||||
return;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -33,7 +33,9 @@ export const EXEC_COMMAND_NAME = "exec_command";
|
||||
* exec_command built-in tool: parses arguments, resolves workdir, and delegates to
|
||||
* `CommandSessionManager` to spawn the process and collect output.
|
||||
* `definition` is overridden by Environment at construction time with the matching entry
|
||||
* from ToolConfig (description/parameters/permission/limits).
|
||||
* from ToolConfig (description/parameters/permission/limits); the runtime tool name and
|
||||
* self-referential messages follow `definition.name` (the config entry is the single
|
||||
* source of truth for naming).
|
||||
* `services.commandSessions` is injected by Environment (shares the same registry with
|
||||
* input_command).
|
||||
*/
|
||||
@@ -43,7 +45,7 @@ export function createExecCommandTool(
|
||||
): BuiltinTool {
|
||||
const manager = services?.commandSessions;
|
||||
return {
|
||||
name: EXEC_COMMAND_NAME,
|
||||
name: definition.name,
|
||||
definition,
|
||||
async *execute(
|
||||
args: Record<string, unknown>,
|
||||
@@ -54,13 +56,13 @@ export function createExecCommandTool(
|
||||
partialToolCallOutput({ eventType: "delta", output, toolCallId });
|
||||
|
||||
if (!manager) {
|
||||
yield delta("[exec_command unavailable: no command session manager configured]");
|
||||
yield delta(`[${definition.name} unavailable: no command session manager configured]`);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
|
||||
const cmd = args["cmd"];
|
||||
if (typeof cmd !== "string" || cmd.length === 0) {
|
||||
yield delta('Missing required argument "cmd" for exec_command.');
|
||||
yield delta(`Missing required argument "cmd" for ${definition.name}.`);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
// workdir defaults to workspaceDir; relative paths are resolved against workspaceDir.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Shared helpers for the file tools (edit_file / write_file).
|
||||
*/
|
||||
import path from "node:path";
|
||||
import { rename, rm, writeFile } from "node:fs/promises";
|
||||
|
||||
/**
|
||||
* Atomic file write: writes to a temp file in the target's directory, then renames it over
|
||||
* the target — a crash or full disk mid-write can no longer leave the target half-written.
|
||||
* `mode` preserves an existing file's permission bits (rename replaces the inode, which
|
||||
* would otherwise reset them); the temp file is removed on failure (best effort).
|
||||
*/
|
||||
export async function atomicWriteFile(
|
||||
target: string,
|
||||
content: string,
|
||||
opts: { mode?: number; signal?: AbortSignal } = {},
|
||||
): Promise<void> {
|
||||
const tmp = path.join(
|
||||
path.dirname(target),
|
||||
`.${path.basename(target)}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
);
|
||||
try {
|
||||
await writeFile(tmp, content, {
|
||||
encoding: "utf8",
|
||||
...(opts.mode !== undefined ? { mode: opts.mode } : {}),
|
||||
...(opts.signal ? { signal: opts.signal } : {}),
|
||||
});
|
||||
await rename(tmp, target);
|
||||
} catch (err) {
|
||||
await rm(tmp, { force: true }).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* read_file — text-file reading tool, a builtin tool implementation (BuiltinTool).
|
||||
*
|
||||
* Reads a text file and returns it in `cat -n` style (line number, tab, content), so the
|
||||
* model can quote exact lines back to edit_file. Relative paths resolve against the
|
||||
* Workspace; absolute paths are allowed (tools run with the user's full permissions, same
|
||||
* as the shell tool). An optional 1-based `offset` and a `limit` (default 2000 lines) form
|
||||
* a window for paging through long files.
|
||||
*
|
||||
* Robustness properties:
|
||||
* - **Bounded read**: the file is scanned incrementally through a file handle, never loaded
|
||||
* whole — a multi-GB log cannot balloon the process. A hard scan cap (8MB) turns
|
||||
* pathological requests (offsets deeper than the cap, files with no newlines) into a
|
||||
* clean failure with guidance; a window that already produced lines when the cap hits is
|
||||
* returned as a partial result with a lower-bound line count instead.
|
||||
* - **Self-budgeted output**: the rendered window is trimmed to the tool's own
|
||||
* maxOutputLength before Environment's front-keep truncation could cut the trailing
|
||||
* continuation note — the note (and the shown range it reports) always survives.
|
||||
* - Overlong single lines are truncated with a marker; CRLF files display without the `\r`
|
||||
* and get an explicit note; binary content (NUL bytes) is rejected with advice; the
|
||||
* secret stores (.vault.toml / .project_config.toml) are refused outright.
|
||||
*
|
||||
* Division of responsibility with Environment (see environment.ts): non-streaming — yields
|
||||
* the whole numbered listing as one delta; failures (missing file, directory, binary
|
||||
* content, scan cap) are explanatory text finalized as `failed`; anything unexpected that
|
||||
* still throws is caught by Environment and likewise finalized as failed. If interrupted,
|
||||
* only reports `aborted` — the interruption note is appended by Environment.
|
||||
* Docs: /docs/tools § "File tools".
|
||||
*/
|
||||
import path from "node:path";
|
||||
import { open, stat } from "node:fs/promises";
|
||||
import { partialToolCallOutput } from "../../omnimessage/index.js";
|
||||
import type { OmniMessage } from "../../omnimessage/index.js";
|
||||
import type { ToolDefinitionConfig } from "../../interfaces.js";
|
||||
import type { BuiltinTool, ToolExecutionContext, ToolResult } from "./types.js";
|
||||
|
||||
/** Tool name constant (used only within this tool module, never exposed to Environment). */
|
||||
export const READ_FILE_NAME = "read_file";
|
||||
|
||||
/** Default max number of lines returned per call (overridable via the `limit` argument). */
|
||||
export const DEFAULT_READ_FILE_LIMIT = 2000;
|
||||
|
||||
/** Max characters kept of a single line; the rest is replaced by a truncation marker. */
|
||||
export const MAX_LINE_LENGTH = 2000;
|
||||
|
||||
/** Hard cap on bytes scanned per call: beyond it the tool stops instead of grinding through a huge file. */
|
||||
export const READ_FILE_SCAN_CAP_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** Bytes read per file-handle read (scan granularity; also the abort-signal check interval). */
|
||||
const CHUNK_BYTES = 256 * 1024;
|
||||
|
||||
/** Per-line byte retention cap: at 4 bytes/char worst-case UTF-8 this always decodes to >= MAX_LINE_LENGTH chars, so char truncation stays exact. */
|
||||
const LINE_BYTE_CAP = MAX_LINE_LENGTH * 4;
|
||||
|
||||
/** Output-budget headroom reserved for the trailing notes (continuation / CRLF), so they survive self-budget trimming. */
|
||||
const NOTE_RESERVE = 256;
|
||||
|
||||
/** Fallback output budget when the definition carries no maxOutputLength (mirrors the default config entry). */
|
||||
const DEFAULT_OUTPUT_BUDGET = 64000;
|
||||
|
||||
/**
|
||||
* Secret stores the system prompt bans the model from reading; read_file refuses them by
|
||||
* basename regardless of directory. The tool needs its own guard because `permission: "r"`
|
||||
* makes it auto-approved under read-only approval (the shell tool stays rw-gated).
|
||||
*/
|
||||
const SECRET_BASENAMES = new Set([".vault.toml", ".project_config.toml"]);
|
||||
|
||||
/** Renders one `cat -n` style line: 6-column right-aligned line number, tab, content. */
|
||||
export function numberedLine(lineNo: number, content: string): string {
|
||||
const capped =
|
||||
content.length > MAX_LINE_LENGTH
|
||||
? `${content.slice(0, MAX_LINE_LENGTH)}… [line truncated]`
|
||||
: content;
|
||||
return `${String(lineNo).padStart(6)}\t${capped}`;
|
||||
}
|
||||
|
||||
/** Coerces a count argument (offset/limit): numbers and numeric strings are accepted; anything else is rejected. */
|
||||
function coerceCount(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
): { ok: true; value: number } | { ok: false } {
|
||||
if (value === undefined || value === null) return { ok: true, value: fallback };
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return { ok: true, value: Math.floor(value) };
|
||||
}
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const n = Number(value.trim());
|
||||
if (Number.isFinite(n)) return { ok: true, value: Math.floor(n) };
|
||||
}
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
/** Result of the bounded window scan. */
|
||||
interface ScanOutcome {
|
||||
/** Decoded window lines (trailing \r already stripped), in order starting at `offset`. */
|
||||
lines: string[];
|
||||
/** Total line count — exact when `totalKnown`, otherwise a lower bound (lines seen before the scan cap). */
|
||||
total: number;
|
||||
totalKnown: boolean;
|
||||
/** Whether any CRLF (\r\n) line ending was seen in the scanned range. */
|
||||
sawCRLF: boolean;
|
||||
/** Whether a NUL byte was seen (binary content). */
|
||||
binary: boolean;
|
||||
/** Scan cap hit before the window produced any line: the request cannot be served. */
|
||||
capBeforeWindow: boolean;
|
||||
aborted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental windowed scan: reads the file in chunks through a file handle, splitting on
|
||||
* `\n` at the byte level (always safe in UTF-8), storing only lines inside
|
||||
* [offset, offset+limit-1] (each retained up to LINE_BYTE_CAP bytes) and counting the rest.
|
||||
* After the window fills, it keeps counting lines until EOF or the scan cap so the
|
||||
* continuation note can report an exact total when cheap and a lower bound otherwise.
|
||||
*/
|
||||
async function scanWindow(
|
||||
filePath: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ScanOutcome> {
|
||||
const end = offset + limit - 1;
|
||||
const out: ScanOutcome = {
|
||||
lines: [],
|
||||
total: 0,
|
||||
totalKnown: false,
|
||||
sawCRLF: false,
|
||||
binary: false,
|
||||
capBeforeWindow: false,
|
||||
aborted: false,
|
||||
};
|
||||
let completedLines = 0; // Lines terminated by \n so far
|
||||
let currentHasBytes = false; // Whether the in-progress line has any content
|
||||
let currentParts: Buffer[] = []; // Retained bytes of the in-progress line (window lines only)
|
||||
let currentBytes = 0;
|
||||
let scanned = 0;
|
||||
|
||||
const inWindow = (): boolean => completedLines + 1 >= offset && completedLines + 1 <= end;
|
||||
|
||||
const appendRun = (buf: Buffer, from: number, to: number): void => {
|
||||
if (currentBytes >= LINE_BYTE_CAP) return; // Overlong line: keep only the head (display truncates anyway)
|
||||
const take = Math.min(LINE_BYTE_CAP - currentBytes, to - from);
|
||||
currentParts.push(Buffer.from(buf.subarray(from, from + take)));
|
||||
currentBytes += take;
|
||||
};
|
||||
|
||||
const finishLine = (): void => {
|
||||
if (inWindow()) {
|
||||
let text = Buffer.concat(currentParts).toString("utf8");
|
||||
if (text.endsWith("\r")) text = text.slice(0, -1);
|
||||
out.lines.push(text);
|
||||
}
|
||||
completedLines += 1;
|
||||
currentParts = [];
|
||||
currentBytes = 0;
|
||||
currentHasBytes = false;
|
||||
};
|
||||
|
||||
const fd = await open(filePath, "r");
|
||||
try {
|
||||
const chunk = Buffer.alloc(CHUNK_BYTES);
|
||||
let prevByte = -1; // For CRLF detection across chunk boundaries
|
||||
for (;;) {
|
||||
if (signal?.aborted) {
|
||||
out.aborted = true;
|
||||
return out;
|
||||
}
|
||||
const toRead = Math.min(CHUNK_BYTES, READ_FILE_SCAN_CAP_BYTES - scanned);
|
||||
if (toRead <= 0) {
|
||||
// Scan cap: with no window line produced the request cannot be served (fails with
|
||||
// guidance); with a partial window, return it plus a lower-bound total.
|
||||
out.capBeforeWindow = out.lines.length === 0;
|
||||
out.total = completedLines + (currentHasBytes ? 1 : 0);
|
||||
return out;
|
||||
}
|
||||
const { bytesRead } = await fd.read(chunk, 0, toRead, scanned);
|
||||
if (bytesRead === 0) {
|
||||
// EOF: a final line without a trailing newline still counts.
|
||||
if (currentHasBytes) finishLine();
|
||||
out.total = completedLines;
|
||||
out.totalKnown = true;
|
||||
return out;
|
||||
}
|
||||
scanned += bytesRead;
|
||||
let from = 0;
|
||||
for (let i = 0; i < bytesRead; i += 1) {
|
||||
const b = chunk[i]!;
|
||||
if (b === 0) {
|
||||
out.binary = true;
|
||||
return out;
|
||||
}
|
||||
if (b === 0x0a) {
|
||||
if (prevByte === 0x0d) out.sawCRLF = true;
|
||||
if (i > from) {
|
||||
currentHasBytes = true;
|
||||
if (inWindow()) appendRun(chunk, from, i);
|
||||
}
|
||||
finishLine();
|
||||
from = i + 1;
|
||||
}
|
||||
prevByte = b;
|
||||
}
|
||||
if (from < bytesRead) {
|
||||
currentHasBytes = true;
|
||||
if (inWindow()) appendRun(chunk, from, bytesRead);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* read_file builtin tool: resolves the path against the Workspace, validates it is a
|
||||
* readable text file, and outputs the requested line window with line numbers.
|
||||
* `definition` is overridden by Environment at construction time with the same-named entry
|
||||
* from ToolConfig (description/arguments/permissions/limits).
|
||||
*/
|
||||
export function createReadFileTool(definition: ToolDefinitionConfig): BuiltinTool {
|
||||
return {
|
||||
name: definition.name,
|
||||
definition,
|
||||
async *execute(
|
||||
args: Record<string, unknown>,
|
||||
ctx: ToolExecutionContext,
|
||||
): AsyncGenerator<OmniMessage, ToolResult | void> {
|
||||
const { toolCallId, signal } = ctx;
|
||||
const delta = (output: string): OmniMessage =>
|
||||
partialToolCallOutput({ eventType: "delta", output, toolCallId });
|
||||
|
||||
const filePath = args["file_path"];
|
||||
if (typeof filePath !== "string" || filePath.length === 0) {
|
||||
yield delta(`Missing required argument "file_path" for ${definition.name}.`);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
const offsetArg = coerceCount(args["offset"], 1);
|
||||
if (!offsetArg.ok) {
|
||||
yield delta(`Invalid "offset": expected a number (got ${JSON.stringify(args["offset"])}).`);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
const offset = Math.max(1, offsetArg.value);
|
||||
const limitArg = coerceCount(args["limit"], DEFAULT_READ_FILE_LIMIT);
|
||||
if (!limitArg.ok || limitArg.value <= 0) {
|
||||
yield delta(
|
||||
`Invalid "limit": expected a positive number (got ${JSON.stringify(args["limit"])}).`,
|
||||
);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
const limit = limitArg.value;
|
||||
|
||||
const resolved = path.resolve(ctx.workspaceDir, filePath);
|
||||
// Secret stores are refused by name: read_file is auto-approved under read-only
|
||||
// approval, so it needs its own guard (aligned with the system prompt's ban).
|
||||
if (SECRET_BASENAMES.has(path.basename(resolved))) {
|
||||
yield delta(
|
||||
`Refusing to read "${filePath}": ${path.basename(resolved)} holds the user's secrets and must never enter the conversation.`,
|
||||
);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
|
||||
let size: number;
|
||||
try {
|
||||
const st = await stat(resolved);
|
||||
if (st.isDirectory()) {
|
||||
yield delta(
|
||||
`Cannot read "${filePath}": it is a directory. Pass the path of a file inside it.`,
|
||||
);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
size = st.size;
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return { stopReason: "aborted" };
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
yield delta(
|
||||
`File not found: "${filePath}". Check the path — relative paths resolve against the workspace (${ctx.workspaceDir}).`,
|
||||
);
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
yield delta(`Failed to read "${filePath}": ${message}`);
|
||||
}
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
if (signal?.aborted) return { stopReason: "aborted" };
|
||||
if (size === 0) {
|
||||
yield delta(`"${filePath}" is an empty file (0 lines).`);
|
||||
return;
|
||||
}
|
||||
|
||||
let scan: ScanOutcome;
|
||||
try {
|
||||
scan = await scanWindow(resolved, offset, limit, signal);
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return { stopReason: "aborted" };
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
yield delta(`Failed to read "${filePath}": ${message}`);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
if (scan.aborted || signal?.aborted) return { stopReason: "aborted" };
|
||||
if (scan.binary) {
|
||||
yield delta(
|
||||
`"${filePath}" looks like a binary file (contains NUL bytes). Use shell commands to inspect it, or read_image if it is an image.`,
|
||||
);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
if (scan.capBeforeWindow) {
|
||||
const mb = Math.round(READ_FILE_SCAN_CAP_BYTES / (1024 * 1024));
|
||||
yield delta(
|
||||
`Stopped after scanning ${mb} MB of "${filePath}" without reaching the requested window ` +
|
||||
`(scanned ${scan.total} line${scan.total === 1 ? "" : "s"}). The offset is too deep or the file has extremely long ` +
|
||||
`lines — use shell commands (e.g. sed -n '${offset},${offset + limit - 1}p') for this file.`,
|
||||
);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
if (scan.totalKnown && offset > scan.total) {
|
||||
yield delta(
|
||||
`Offset ${offset} is past the end of "${filePath}" (${scan.total} line${scan.total === 1 ? "" : "s"} total).`,
|
||||
);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
|
||||
// Render the window, self-budgeted below the tool's output cap so the trailing notes
|
||||
// are never cut by Environment's front-keep truncation.
|
||||
const budget =
|
||||
definition.maxOutputLength !== undefined && definition.maxOutputLength > 0
|
||||
? definition.maxOutputLength
|
||||
: DEFAULT_OUTPUT_BUDGET;
|
||||
const contentBudget = Math.max(1, budget - NOTE_RESERVE);
|
||||
const rows: string[] = [];
|
||||
let used = 0;
|
||||
let shownEnd = offset - 1;
|
||||
let budgetTrimmed = false;
|
||||
for (let i = 0; i < scan.lines.length; i += 1) {
|
||||
const row = numberedLine(offset + i, scan.lines[i]!);
|
||||
const cost = row.length + (rows.length > 0 ? 1 : 0);
|
||||
if (used + cost > contentBudget) {
|
||||
if (rows.length === 0) {
|
||||
// Even the first row overflows a (tiny custom) budget: hard-cut it so the notes survive.
|
||||
rows.push(row.slice(0, contentBudget));
|
||||
shownEnd = offset + i;
|
||||
}
|
||||
budgetTrimmed = true;
|
||||
break;
|
||||
}
|
||||
used += cost;
|
||||
rows.push(row);
|
||||
shownEnd = offset + i;
|
||||
}
|
||||
|
||||
const moreRemains = budgetTrimmed || !scan.totalKnown || shownEnd < scan.total;
|
||||
if (scan.sawCRLF) rows.push("(file uses CRLF line endings)");
|
||||
if (moreRemains) {
|
||||
const trimmedInfix = budgetTrimmed ? " (output limit reached)" : "";
|
||||
const totalPart = scan.totalKnown
|
||||
? `file has ${scan.total} lines total`
|
||||
: `file has more than ${scan.total} lines`;
|
||||
rows.push(
|
||||
`[${totalPart}; showing ${offset}-${shownEnd}${trimmedInfix} — call again with offset to continue]`,
|
||||
);
|
||||
}
|
||||
yield delta(rows.join("\n"));
|
||||
return;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Environment uses this table to assemble entries from ToolConfig into BuiltinTool instances:
|
||||
* a tool is only assembled if its name is in the table (i.e. a supported built-in tool); the
|
||||
* description/parameters/permission/maxOutputLength from config are injected into the tool's
|
||||
* `definition` by each factory.
|
||||
* `definition` by each factory, and the runtime tool name follows the config entry's name.
|
||||
* When adding a new built-in tool, just register one factory entry here — no changes to
|
||||
* Environment needed.
|
||||
*
|
||||
@@ -13,6 +13,9 @@
|
||||
*/
|
||||
import type { EnvironmentServices, ToolDefinitionConfig } from "../../interfaces.js";
|
||||
import type { BuiltinTool } from "./types.js";
|
||||
import { READ_FILE_NAME, createReadFileTool } from "./read-file.js";
|
||||
import { EDIT_FILE_NAME, createEditFileTool } from "./edit-file.js";
|
||||
import { WRITE_FILE_NAME, createWriteFileTool } from "./write-file.js";
|
||||
import { EXEC_COMMAND_NAME, createExecCommandTool } from "./exec-command.js";
|
||||
import { INPUT_COMMAND_NAME, createInputCommandTool } from "./input-command.js";
|
||||
import { SUBAGENT_NAME, createSubagentTool } from "./run-subagent.js";
|
||||
@@ -32,6 +35,9 @@ export type BuiltinToolFactory = (
|
||||
|
||||
/** Tool name -> factory. */
|
||||
export const BUILTIN_TOOL_FACTORIES: Record<string, BuiltinToolFactory> = {
|
||||
[READ_FILE_NAME]: createReadFileTool,
|
||||
[EDIT_FILE_NAME]: createEditFileTool,
|
||||
[WRITE_FILE_NAME]: createWriteFileTool,
|
||||
[EXEC_COMMAND_NAME]: createExecCommandTool,
|
||||
[INPUT_COMMAND_NAME]: createInputCommandTool,
|
||||
[SUBAGENT_NAME]: createSubagentTool,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* write_file — whole-file writing tool, a builtin tool implementation (BuiltinTool).
|
||||
*
|
||||
* Writes `content` to a file, creating it (including missing parent directories) or
|
||||
* overwriting it entirely; an empty string is a valid content (creates an empty file).
|
||||
* The output distinguishes "Created" from "Overwrote" and reports the size written, so
|
||||
* the model notices when it clobbered an existing file; an overwrite additionally shows a
|
||||
* git-style unified diff against the previous content when the change is small, and a
|
||||
* one-line `+X/−Y lines` summary otherwise (created files carry no diff — the model just
|
||||
* supplied the content). The write is atomic (temp file + rename, preserving an
|
||||
* overwritten file's permission bits), so a crash mid-write cannot leave the target
|
||||
* half-written. Relative paths resolve against the Workspace; absolute
|
||||
* paths are allowed (tools run with the user's full permissions, same as the shell tool).
|
||||
* For surgical changes to an existing file, edit_file is the better tool — this one
|
||||
* replaces the whole content.
|
||||
*
|
||||
* Division of responsibility with Environment (see environment.ts): non-streaming — yields
|
||||
* one final text delta; failures (path is a directory, permission errors) are explanatory
|
||||
* text finalized as `failed`; anything unexpected that still throws is caught by
|
||||
* Environment and likewise finalized as failed. If interrupted, only reports `aborted` —
|
||||
* the interruption note is appended by Environment.
|
||||
* Docs: /docs/tools § "File tools".
|
||||
*/
|
||||
import path from "node:path";
|
||||
import { mkdir, readFile, stat } from "node:fs/promises";
|
||||
import { atomicWriteFile } from "./file-utils.js";
|
||||
import { buildLineDiffHunks, renderHunk } from "./diff.js";
|
||||
import { partialToolCallOutput } from "../../omnimessage/index.js";
|
||||
import type { OmniMessage } from "../../omnimessage/index.js";
|
||||
import type { ToolDefinitionConfig } from "../../interfaces.js";
|
||||
import type { BuiltinTool, ToolExecutionContext, ToolResult } from "./types.js";
|
||||
|
||||
/** Tool name constant (used only within this tool module, never exposed to Environment). */
|
||||
export const WRITE_FILE_NAME = "write_file";
|
||||
|
||||
/** Max bytes of previous content read back for the overwrite diff; larger files get no diff. */
|
||||
const DIFF_SOURCE_CAP_BYTES = 1024 * 1024;
|
||||
|
||||
/** Max rendered diff lines (headers included) shown inline; larger diffs collapse to a +X/−Y summary. */
|
||||
const MAX_DIFF_DISPLAY_LINES = 60;
|
||||
|
||||
/** Output-budget headroom kept below the tool's maxOutputLength when appending the diff. */
|
||||
const NOTE_RESERVE = 120;
|
||||
|
||||
/** Fallback output budget when the definition carries no maxOutputLength (mirrors the default config entry). */
|
||||
const DEFAULT_OUTPUT_BUDGET = 16000;
|
||||
|
||||
/** Counts content lines the way `cat -n` numbers them: a trailing newline ends the last line instead of adding an empty one. */
|
||||
function countLines(content: string): number {
|
||||
if (content === "") return 0;
|
||||
const lines = content.split("\n");
|
||||
return lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* write_file builtin tool: creates parent directories as needed and writes the full
|
||||
* content, reporting created/overwrote plus the written size.
|
||||
* `definition` is overridden by Environment at construction time with the same-named entry
|
||||
* from ToolConfig (description/arguments/permissions/limits).
|
||||
*/
|
||||
export function createWriteFileTool(definition: ToolDefinitionConfig): BuiltinTool {
|
||||
return {
|
||||
name: definition.name,
|
||||
definition,
|
||||
async *execute(
|
||||
args: Record<string, unknown>,
|
||||
ctx: ToolExecutionContext,
|
||||
): AsyncGenerator<OmniMessage, ToolResult | void> {
|
||||
const { toolCallId, signal } = ctx;
|
||||
const delta = (output: string): OmniMessage =>
|
||||
partialToolCallOutput({ eventType: "delta", output, toolCallId });
|
||||
|
||||
const filePath = args["file_path"];
|
||||
if (typeof filePath !== "string" || filePath.length === 0) {
|
||||
yield delta(`Missing required argument "file_path" for ${definition.name}.`);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
// An empty string is valid content (creates an empty file); only a missing/non-string
|
||||
// value is an argument error.
|
||||
const content = args["content"];
|
||||
if (typeof content !== "string") {
|
||||
yield delta(`Missing required argument "content" for ${definition.name}.`);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
|
||||
const resolved = path.resolve(ctx.workspaceDir, filePath);
|
||||
// Determine created-vs-overwrote before writing; also reject directories up front
|
||||
// (writeFile's raw EISDIR is not model-friendly).
|
||||
let existed = false;
|
||||
let fileMode: number | undefined;
|
||||
let previous: string | null = null; // Previous content, for the overwrite diff
|
||||
try {
|
||||
const st = await stat(resolved);
|
||||
if (st.isDirectory()) {
|
||||
yield delta(`Cannot write "${filePath}": it is a directory.`);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
existed = true;
|
||||
fileMode = st.mode & 0o777; // Preserved across the atomic temp-file + rename write
|
||||
// Read the old content back for the diff — bounded: a huge or unreadable/binary
|
||||
// previous file simply gets no diff (never a failure).
|
||||
if (st.size <= DIFF_SOURCE_CAP_BYTES) {
|
||||
const bytes = await readFile(resolved);
|
||||
if (!bytes.includes(0)) previous = bytes.toString("utf8");
|
||||
}
|
||||
} catch {
|
||||
// Missing file (or unstatable path): proceed to create; real write errors surface below.
|
||||
}
|
||||
if (signal?.aborted) return { stopReason: "aborted" };
|
||||
|
||||
try {
|
||||
await mkdir(path.dirname(resolved), { recursive: true });
|
||||
await atomicWriteFile(resolved, content, {
|
||||
...(fileMode !== undefined ? { mode: fileMode } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return { stopReason: "aborted" };
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
yield delta(`Failed to write "${filePath}": ${message}`);
|
||||
return { stopReason: "failed" };
|
||||
}
|
||||
|
||||
const lines = countLines(content);
|
||||
const bytes = Buffer.byteLength(content, "utf8");
|
||||
const out: string[] = [
|
||||
`${existed ? "Overwrote" : "Created"} "${filePath}" (${lines} line${lines === 1 ? "" : "s"}, ${bytes} byte${bytes === 1 ? "" : "s"}).`,
|
||||
];
|
||||
// Overwrites show what changed, Claude Code style: a small unified diff inline, a
|
||||
// one-line +X/−Y summary otherwise. Self-budgeted below the tool's output cap so
|
||||
// the leading summary line always survives Environment's front-keep truncation.
|
||||
if (existed && previous !== null) {
|
||||
const diff = buildLineDiffHunks(previous, content);
|
||||
if (diff.kind === "identical") {
|
||||
out.push("(content unchanged)");
|
||||
} else if (diff.kind === "too-large") {
|
||||
out.push(
|
||||
`+${diff.plus}/−${diff.minus} lines vs the previous content (diff too large to show)`,
|
||||
);
|
||||
} else {
|
||||
const rendered = diff.hunks.map(renderHunk);
|
||||
const budget =
|
||||
definition.maxOutputLength !== undefined && definition.maxOutputLength > 0
|
||||
? definition.maxOutputLength
|
||||
: DEFAULT_OUTPUT_BUDGET;
|
||||
const totalLines = rendered.reduce((acc, h) => acc + h.split("\n").length, 0);
|
||||
const totalChars = rendered.reduce((acc, h) => acc + h.length + 1, out[0]!.length);
|
||||
if (totalLines > MAX_DIFF_DISPLAY_LINES || totalChars > budget - NOTE_RESERVE) {
|
||||
out.push(
|
||||
`+${diff.plus}/−${diff.minus} lines vs the previous content (diff too large to show)`,
|
||||
);
|
||||
} else {
|
||||
out.push(...rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
yield delta(out.join("\n"));
|
||||
return;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -56,6 +56,16 @@ export interface ToolDefinitionConfig {
|
||||
timeoutMs?: number;
|
||||
/** Max length of tool output; Environment truncates from the front (keeping the head) if exceeded; <=0 disables it. */
|
||||
maxOutputLength?: number;
|
||||
/**
|
||||
* Per-tool toggle for the optional `description` call argument (a model-written sentence
|
||||
* shown to the user while the call runs). The argument itself is declared as a normal
|
||||
* property in this entry's `parameters` (editable config is the single source of truth);
|
||||
* setting `call_description: false` filters that property out of the schema handed to the
|
||||
* LLM at assembly time (in-memory only — the stored YAML is never rewritten). Missing =
|
||||
* true (the property stays). No effect on entries whose parameters declare no
|
||||
* `description` property.
|
||||
*/
|
||||
call_description?: boolean;
|
||||
}
|
||||
|
||||
export interface MCPServerConfig {
|
||||
|
||||
@@ -455,11 +455,37 @@ export function selectBuiltinToolsForModel(
|
||||
return tools.filter((t) => t.forModel === undefined || t.forModel === kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a tool entry's per-tool `call_description` toggle: the `description` call argument
|
||||
* is declared as a normal property in the entry's `parameters` (editable config is the
|
||||
* single source of truth) and is **required** there, so a tool that offers it always gets
|
||||
* one — the frontends can then pick a call's display form up front instead of guessing while
|
||||
* the arguments stream. When the entry sets `call_description: false`, the property is
|
||||
* filtered out of the schema handed to the LLM, `required` along with it — on an in-memory
|
||||
* clone only, the stored YAML is never rewritten. Missing/true, entries without a parameter
|
||||
* schema, and entries whose properties declare no `description` all pass through unchanged
|
||||
* (old configs predating the field are a no-op).
|
||||
*/
|
||||
function applyCallDescriptionToggle(def: ToolDefinitionConfig): ToolDefinitionConfig {
|
||||
if (def.call_description !== false) return def;
|
||||
const params = def.parameters;
|
||||
if (params === undefined) return def;
|
||||
const properties = params["properties"];
|
||||
if (properties === null || typeof properties !== "object") return def;
|
||||
if (!("description" in (properties as Record<string, unknown>))) return def;
|
||||
const { description: _dropped, ...rest } = properties as Record<string, unknown>;
|
||||
const required = params["required"];
|
||||
const trimmed = Array.isArray(required)
|
||||
? { required: required.filter((name) => name !== "description") }
|
||||
: {};
|
||||
return { ...def, parameters: { ...params, properties: rest, ...trimmed } };
|
||||
}
|
||||
|
||||
export function buildToolConfig(state: AgentState): ToolConfig {
|
||||
const systemTools = state.systemConfig.tools;
|
||||
const builtin = systemTools?.builtin ?? defaultSystemConfig().tools?.builtin ?? [];
|
||||
return {
|
||||
customTools: builtin,
|
||||
customTools: builtin.map(applyCallDescriptionToggle),
|
||||
mcpServers: systemTools?.mcpServers ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export interface SystemConfig {
|
||||
/** Context compaction (enabled by default, max_context_length 128k, mode summarize). */
|
||||
compaction?: CompactionConfig;
|
||||
tools?: {
|
||||
/** Built-in system tool configuration. */
|
||||
/** Built-in system tool configuration (per-entry fields incl. the `call_description` toggle live on ToolDefinitionConfig). */
|
||||
builtin?: ToolDefinitionConfig[];
|
||||
/** MCP Server configuration. */
|
||||
mcpServers?: MCPServerConfig[];
|
||||
@@ -140,7 +140,7 @@ The vault holds this agent's per-agent secrets (agent_state/.vault.toml). Each e
|
||||
{{VAULT_KEYS}}
|
||||
|
||||
# Skills
|
||||
Skills are reusable instruction packages stored under <app_data_dir>/agents/<agent_id>/agent_state/skills/<skill_name>/SKILL.md. There is no skill tool: when a task matches an installed skill below, or the user asks to use one (a message may start with a <use_skills> block listing skill names), first read that skill's SKILL.md in full with a shell command, then follow it. If a request only names a skill without a concrete task, ask the user what they need before starting.
|
||||
Skills are reusable instruction packages stored under <app_data_dir>/agents/<agent_id>/agent_state/skills/<skill_name>/SKILL.md. There is no skill tool: when a task matches an installed skill below, or the user asks to use one (a message may start with a <use_skills> block listing skill names), first read that skill's SKILL.md in full with the read_file tool, then follow it. If a request only names a skill without a concrete task, ask the user what they need before starting.
|
||||
{{SKILL_METADATA}}
|
||||
|
||||
# Environment
|
||||
@@ -169,21 +169,115 @@ export const DEFAULT_COMPACTION_PROMPT =
|
||||
"tools while writing the summary; respond with text only.";
|
||||
|
||||
/**
|
||||
* Default built-in system tools: bash execution and subagent spawning.
|
||||
* Default built-in system tools: file reading/editing/writing first, then bash execution
|
||||
* and subagent spawning.
|
||||
* Docs: /docs/tools § "Built-in tools".
|
||||
*/
|
||||
function defaultBuiltinTools(): ToolDefinitionConfig[] {
|
||||
return [
|
||||
{
|
||||
name: "read_file",
|
||||
description:
|
||||
"Read a text file and return its content with line numbers (cat -n style) — the preferred " +
|
||||
"way to inspect a file. Returns up to 2000 lines starting at the given offset; for longer " +
|
||||
"files call again with offset to continue. Use the image tools for images and the shell " +
|
||||
"tool for binary files.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file_path: {
|
||||
type: "string",
|
||||
description: "Path to the file to read; absolute, or relative to the workspace.",
|
||||
},
|
||||
offset: {
|
||||
type: "number",
|
||||
description: "1-based line number to start reading from; defaults to 1.",
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description: "Max lines to read; defaults to 2000.",
|
||||
},
|
||||
},
|
||||
required: ["file_path"],
|
||||
},
|
||||
permission: "r",
|
||||
timeoutMs: 30000,
|
||||
// Wider than the other tools' cap: a 2000-line window of code rarely fits in 16k characters.
|
||||
maxOutputLength: 64000,
|
||||
},
|
||||
{
|
||||
name: "edit_file",
|
||||
description:
|
||||
"Edit a file by exact string replacement — the preferred way to make a precise change. " +
|
||||
"old_string must match the file content exactly (including whitespace) and be unique " +
|
||||
"unless replace_all is set; read the file first to copy the text verbatim. The result " +
|
||||
"echoes a line-numbered snippet around the change for verification.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file_path: {
|
||||
type: "string",
|
||||
description: "Path to the file to edit; absolute, or relative to the workspace.",
|
||||
},
|
||||
old_string: {
|
||||
type: "string",
|
||||
description: "Exact text to replace, including whitespace/indentation.",
|
||||
},
|
||||
new_string: {
|
||||
type: "string",
|
||||
description: "Replacement text; must differ from old_string.",
|
||||
},
|
||||
replace_all: {
|
||||
type: "boolean",
|
||||
description: "Replace every occurrence of old_string; defaults to false.",
|
||||
},
|
||||
},
|
||||
required: ["file_path", "old_string", "new_string"],
|
||||
},
|
||||
permission: "rw",
|
||||
timeoutMs: 30000,
|
||||
maxOutputLength: 16000,
|
||||
},
|
||||
{
|
||||
name: "write_file",
|
||||
description:
|
||||
"Create or overwrite a file with the given content, creating parent directories as " +
|
||||
"needed. Use it for new files or full rewrites; for precise changes to an existing file " +
|
||||
"prefer edit_file.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file_path: {
|
||||
type: "string",
|
||||
description: "Path to the file to write; absolute, or relative to the workspace.",
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description: "Full file content to write; an empty string creates an empty file.",
|
||||
},
|
||||
},
|
||||
required: ["file_path", "content"],
|
||||
},
|
||||
permission: "rw",
|
||||
timeoutMs: 30000,
|
||||
maxOutputLength: 16000,
|
||||
},
|
||||
{
|
||||
name: "exec_command",
|
||||
description:
|
||||
"Run a shell command in the workspace to read, write, edit files and run programs. " +
|
||||
"Run a shell command in the workspace to run programs, search, install dependencies, and " +
|
||||
"everything the file tools don't cover. " +
|
||||
"Run long-lived commands (servers, watchers, builds) in the foreground: past yield_time_ms " +
|
||||
"they keep running in the background with a process_id. Do not background them with `&` — " +
|
||||
"the whole process group is cleaned up when the foreground command exits.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
description: {
|
||||
type: "string",
|
||||
description:
|
||||
"Required, and emit it first, before the other arguments: it is shown to the user while the call runs. One short sentence describing what this call is doing and why, written in the user's language.",
|
||||
},
|
||||
cmd: {
|
||||
type: "string",
|
||||
description: "Shell command to execute.",
|
||||
@@ -199,9 +293,10 @@ function defaultBuiltinTools(): ToolDefinitionConfig[] {
|
||||
"How long to wait for the command before yielding. If it is still running when this elapses, the tool returns the output so far plus a process_id, and the command keeps running in the background (drive it with input_command). Defaults to 60000; minimum 250, capped below the tool timeout.",
|
||||
},
|
||||
},
|
||||
required: ["cmd"],
|
||||
required: ["description", "cmd"],
|
||||
},
|
||||
permission: "rw",
|
||||
call_description: true,
|
||||
timeoutMs: 120000,
|
||||
maxOutputLength: 16000,
|
||||
},
|
||||
@@ -212,6 +307,11 @@ function defaultBuiltinTools(): ToolDefinitionConfig[] {
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
description: {
|
||||
type: "string",
|
||||
description:
|
||||
"Required, and emit it first, before the other arguments: it is shown to the user while the call runs. One short sentence describing what this call is doing and why, written in the user's language.",
|
||||
},
|
||||
process_id: {
|
||||
type: "string",
|
||||
description: "The process_id returned by exec_command for the running command session.",
|
||||
@@ -227,9 +327,10 @@ function defaultBuiltinTools(): ToolDefinitionConfig[] {
|
||||
"How long to wait for new output or exit before returning. Non-empty writes default to 250; empty polls default to 5000. Minimum 250, capped below the tool timeout.",
|
||||
},
|
||||
},
|
||||
required: ["process_id"],
|
||||
required: ["description", "process_id"],
|
||||
},
|
||||
permission: "rw",
|
||||
call_description: true,
|
||||
// An empty poll can wait out a build/test run (the yield ceiling is derived from timeoutMs, clamped inside the tool).
|
||||
timeoutMs: 130000,
|
||||
maxOutputLength: 16000,
|
||||
@@ -242,6 +343,11 @@ function defaultBuiltinTools(): ToolDefinitionConfig[] {
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
description: {
|
||||
type: "string",
|
||||
description:
|
||||
"Required, and emit it first, before the other arguments: it is shown to the user while the call runs. One short sentence describing what this call is doing and why, written in the user's language.",
|
||||
},
|
||||
prompt: {
|
||||
type: "string",
|
||||
description:
|
||||
@@ -268,9 +374,10 @@ function defaultBuiltinTools(): ToolDefinitionConfig[] {
|
||||
"How long to wait for the subagent before yielding. If it is still working when this elapses, the tool returns the output so far plus a subagent_id, and the subagent keeps running in the background (drive it with input_subagent). Defaults to 300000; minimum 250, capped below the tool timeout.",
|
||||
},
|
||||
},
|
||||
required: ["prompt"],
|
||||
required: ["description", "prompt"],
|
||||
},
|
||||
permission: "rw",
|
||||
call_description: true,
|
||||
// Subagent tasks typically run far longer than a single command, so the timeout ceiling is raised accordingly.
|
||||
timeoutMs: 600000,
|
||||
maxOutputLength: 16000,
|
||||
@@ -282,6 +389,11 @@ function defaultBuiltinTools(): ToolDefinitionConfig[] {
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
description: {
|
||||
type: "string",
|
||||
description:
|
||||
"Required, and emit it first, before the other arguments: it is shown to the user while the call runs. One short sentence describing what this call is doing and why, written in the user's language.",
|
||||
},
|
||||
subagent_id: {
|
||||
type: "string",
|
||||
description: "The subagent_id returned by run_subagent for the background subagent.",
|
||||
@@ -297,9 +409,10 @@ function defaultBuiltinTools(): ToolDefinitionConfig[] {
|
||||
"How long to wait for new output or completion before returning. Follow-up prompts default to 300000; empty polls default to 10000. Minimum 250, capped below the tool timeout.",
|
||||
},
|
||||
},
|
||||
required: ["subagent_id"],
|
||||
required: ["description", "subagent_id"],
|
||||
},
|
||||
permission: "rw",
|
||||
call_description: true,
|
||||
// Same generous timeout tier as run_subagent: an empty poll can wait a long time for the subagent to wrap up.
|
||||
timeoutMs: 600000,
|
||||
maxOutputLength: 16000,
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
/**
|
||||
* Behavior tests for the file tools (read_file / edit_file / write_file): happy paths,
|
||||
* every failure case, workspace-relative path resolution, offset/limit windows,
|
||||
* replace_all semantics, and parent-directory creation. Directly drives
|
||||
* BuiltinTool.execute and captures the generator's return value (same approach as
|
||||
* read-image.test.ts); Environment-side framing is covered by environment.test.ts.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
DEFAULT_READ_FILE_LIMIT,
|
||||
MAX_LINE_LENGTH,
|
||||
READ_FILE_NAME,
|
||||
READ_FILE_SCAN_CAP_BYTES,
|
||||
createReadFileTool,
|
||||
} from "../src/environment/tools/read-file.js";
|
||||
import { EDIT_FILE_NAME, createEditFileTool } from "../src/environment/tools/edit-file.js";
|
||||
import { WRITE_FILE_NAME, createWriteFileTool } from "../src/environment/tools/write-file.js";
|
||||
import type { BuiltinTool, ToolResult } from "../src/environment/tools/types.js";
|
||||
import type { OmniMessage } from "../src/omnimessage/index.js";
|
||||
import type { ToolDefinitionConfig } from "../src/interfaces.js";
|
||||
|
||||
function def(name: string, permission: "r" | "rw"): ToolDefinitionConfig {
|
||||
return { name, description: "test", permission };
|
||||
}
|
||||
|
||||
/** Runs one tool execution: concatenates text deltas and captures the generator's return value. */
|
||||
async function run(tool: BuiltinTool, args: Record<string, unknown>, workspaceDir: string) {
|
||||
const gen = tool.execute(args, { workspaceDir, toolCallId: "c1" });
|
||||
const messages: OmniMessage[] = [];
|
||||
let result: ToolResult | void;
|
||||
for (;;) {
|
||||
const res = await gen.next();
|
||||
if (res.done) {
|
||||
result = res.value;
|
||||
break;
|
||||
}
|
||||
messages.push(res.value);
|
||||
}
|
||||
const text = messages.map((m) => (m.payload as { output?: string }).output ?? "").join("");
|
||||
return { result, text };
|
||||
}
|
||||
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmp = await mkdtemp(path.join(tmpdir(), "penguin-filetools-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("read_file", () => {
|
||||
const tool = () => createReadFileTool(def(READ_FILE_NAME, "r"));
|
||||
|
||||
it("reads a file by workspace-relative path in cat -n style", async () => {
|
||||
await writeFile(path.join(tmp, "a.txt"), "alpha\nbeta\ngamma\n");
|
||||
const { result, text } = await run(tool(), { file_path: "a.txt" }, tmp);
|
||||
expect(result?.stopReason).toBeUndefined(); // Defaults to completed
|
||||
expect(text.split("\n")).toEqual([" 1\talpha", " 2\tbeta", " 3\tgamma"]);
|
||||
});
|
||||
|
||||
it("reads an absolute path outside the workspace", async () => {
|
||||
const outside = await mkdtemp(path.join(tmpdir(), "penguin-filetools-out-"));
|
||||
try {
|
||||
const abs = path.join(outside, "abs.txt");
|
||||
await writeFile(abs, "outside\n");
|
||||
const { result, text } = await run(tool(), { file_path: abs }, tmp);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toContain("\toutside");
|
||||
} finally {
|
||||
await rm(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("windows the output with offset/limit and points at the continuation", async () => {
|
||||
const lines = Array.from({ length: 10 }, (_, i) => `line-${i + 1}`).join("\n");
|
||||
await writeFile(path.join(tmp, "many.txt"), `${lines}\n`);
|
||||
const { result, text } = await run(tool(), { file_path: "many.txt", offset: 3, limit: 4 }, tmp);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
const rows = text.split("\n");
|
||||
expect(rows[0]).toBe(" 3\tline-3");
|
||||
expect(rows[3]).toBe(" 6\tline-6");
|
||||
expect(rows[4]).toBe(
|
||||
"[file has 10 lines total; showing 3-6 — call again with offset to continue]",
|
||||
);
|
||||
});
|
||||
|
||||
it("caps a missing limit at the default window and reports the total", async () => {
|
||||
const total = DEFAULT_READ_FILE_LIMIT + 5;
|
||||
const lines = Array.from({ length: total }, (_, i) => `l${i + 1}`).join("\n");
|
||||
await writeFile(path.join(tmp, "long.txt"), lines);
|
||||
const { text } = await run(tool(), { file_path: "long.txt" }, tmp);
|
||||
expect(text).toContain(`[file has ${total} lines total; showing 1-${DEFAULT_READ_FILE_LIMIT}`);
|
||||
});
|
||||
|
||||
it("truncates a single overlong line with a marker", async () => {
|
||||
await writeFile(path.join(tmp, "wide.txt"), `${"x".repeat(MAX_LINE_LENGTH + 50)}\nshort\n`);
|
||||
const { text } = await run(tool(), { file_path: "wide.txt" }, tmp);
|
||||
expect(text).toContain("… [line truncated]");
|
||||
expect(text).toContain("\tshort");
|
||||
expect(text).not.toContain("x".repeat(MAX_LINE_LENGTH + 1));
|
||||
});
|
||||
|
||||
it("reports an empty file as a note, not a failure", async () => {
|
||||
await writeFile(path.join(tmp, "empty.txt"), "");
|
||||
const { result, text } = await run(tool(), { file_path: "empty.txt" }, tmp);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toContain("empty file");
|
||||
});
|
||||
|
||||
it("fails with a path hint when the file does not exist", async () => {
|
||||
const { result, text } = await run(tool(), { file_path: "missing.txt" }, tmp);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("File not found");
|
||||
expect(text).toContain("missing.txt");
|
||||
expect(text).toContain("workspace");
|
||||
});
|
||||
|
||||
it("fails when the path is a directory", async () => {
|
||||
await mkdir(path.join(tmp, "subdir"));
|
||||
const { result, text } = await run(tool(), { file_path: "subdir" }, tmp);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("directory");
|
||||
});
|
||||
|
||||
it("fails on binary content (NUL bytes) and advises other tools", async () => {
|
||||
await writeFile(path.join(tmp, "bin.dat"), Buffer.from([0x89, 0x50, 0x00, 0x0a, 0x42]));
|
||||
const { result, text } = await run(tool(), { file_path: "bin.dat" }, tmp);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("binary");
|
||||
expect(text).toContain("read_image");
|
||||
});
|
||||
|
||||
it("fails when offset is past the end of the file", async () => {
|
||||
await writeFile(path.join(tmp, "two.txt"), "a\nb\n");
|
||||
const { result, text } = await run(tool(), { file_path: "two.txt", offset: 5 }, tmp);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("past the end");
|
||||
expect(text).toContain("2 lines");
|
||||
});
|
||||
|
||||
it("fails when file_path is missing", async () => {
|
||||
const { result, text } = await run(tool(), {}, tmp);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain('"file_path"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit_file", () => {
|
||||
const tool = () => createEditFileTool(def(EDIT_FILE_NAME, "rw"));
|
||||
|
||||
it("replaces a unique occurrence and echoes a git-style unified diff", async () => {
|
||||
const file = path.join(tmp, "src.ts");
|
||||
await writeFile(file, "const a = 1;\nconst b = 2;\nconst c = 3;\n");
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "src.ts", old_string: "const b = 2;", new_string: "const b = 20;" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toBe(
|
||||
[
|
||||
'Replaced 1 occurrence in "src.ts".',
|
||||
"@@ -1,3 +1,3 @@",
|
||||
" const a = 1;",
|
||||
"-const b = 2;",
|
||||
"+const b = 20;",
|
||||
" const c = 3;",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(await readFile(file, "utf8")).toBe("const a = 1;\nconst b = 20;\nconst c = 3;\n");
|
||||
});
|
||||
|
||||
it("replaces every occurrence with replace_all and diffs same-line hits in one hunk", async () => {
|
||||
const file = path.join(tmp, "multi.txt");
|
||||
await writeFile(file, "foo bar foo baz foo\n");
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "multi.txt", old_string: "foo", new_string: "qux", replace_all: true },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toBe(
|
||||
[
|
||||
'Replaced 3 occurrences in "multi.txt".',
|
||||
"@@ -1,1 +1,1 @@",
|
||||
"-foo bar foo baz foo",
|
||||
"+qux bar qux baz qux",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(await readFile(file, "utf8")).toBe("qux bar qux baz qux\n");
|
||||
});
|
||||
|
||||
it("fails when old_string is not found", async () => {
|
||||
await writeFile(path.join(tmp, "f.txt"), "hello\n");
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "f.txt", old_string: "goodbye", new_string: "farewell" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain('old_string not found in "f.txt"');
|
||||
expect(await readFile(path.join(tmp, "f.txt"), "utf8")).toBe("hello\n"); // Untouched
|
||||
});
|
||||
|
||||
it("fails with the count when old_string is ambiguous and replace_all is unset", async () => {
|
||||
await writeFile(path.join(tmp, "dup.txt"), "x\nx\nx\n");
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "dup.txt", old_string: "x", new_string: "y" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("3 times");
|
||||
expect(text).toContain("replace_all");
|
||||
expect(await readFile(path.join(tmp, "dup.txt"), "utf8")).toBe("x\nx\nx\n");
|
||||
});
|
||||
|
||||
it("fails when old_string equals new_string", async () => {
|
||||
await writeFile(path.join(tmp, "same.txt"), "abc\n");
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "same.txt", old_string: "abc", new_string: "abc" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("identical");
|
||||
});
|
||||
|
||||
it("fails and suggests write_file when the file does not exist", async () => {
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "nope.txt", old_string: "a", new_string: "b" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("File not found");
|
||||
expect(text).toContain("write_file");
|
||||
});
|
||||
|
||||
it("fails when the path is a directory", async () => {
|
||||
await mkdir(path.join(tmp, "d"));
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "d", old_string: "a", new_string: "b" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("directory");
|
||||
});
|
||||
|
||||
it("fails when required arguments are missing", async () => {
|
||||
const missingPath = await run(tool(), { old_string: "a", new_string: "b" }, tmp);
|
||||
expect(missingPath.result?.stopReason).toBe("failed");
|
||||
expect(missingPath.text).toContain('"file_path"');
|
||||
const missingOld = await run(tool(), { file_path: "f", new_string: "b" }, tmp);
|
||||
expect(missingOld.result?.stopReason).toBe("failed");
|
||||
expect(missingOld.text).toContain('"old_string"');
|
||||
const missingNew = await run(tool(), { file_path: "f", old_string: "a" }, tmp);
|
||||
expect(missingNew.result?.stopReason).toBe("failed");
|
||||
expect(missingNew.text).toContain('"new_string"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("write_file", () => {
|
||||
const tool = () => createWriteFileTool(def(WRITE_FILE_NAME, "rw"));
|
||||
|
||||
it("creates a new file (workspace-relative) and reports lines/bytes", async () => {
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "out.txt", content: "one\ntwo\n" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toContain('Created "out.txt"');
|
||||
expect(text).toContain("2 lines");
|
||||
expect(text).toContain("8 bytes");
|
||||
expect(await readFile(path.join(tmp, "out.txt"), "utf8")).toBe("one\ntwo\n");
|
||||
});
|
||||
|
||||
it("creates missing parent directories", async () => {
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "a/b/c/deep.txt", content: "deep" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toContain("Created");
|
||||
expect(await readFile(path.join(tmp, "a/b/c/deep.txt"), "utf8")).toBe("deep");
|
||||
expect((await stat(path.join(tmp, "a/b"))).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("reports Overwrote when the file already exists", async () => {
|
||||
await writeFile(path.join(tmp, "exists.txt"), "old");
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "exists.txt", content: "new content" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toContain('Overwrote "exists.txt"');
|
||||
expect(await readFile(path.join(tmp, "exists.txt"), "utf8")).toBe("new content");
|
||||
});
|
||||
|
||||
it("accepts an empty string as content", async () => {
|
||||
const { result, text } = await run(tool(), { file_path: "blank.txt", content: "" }, tmp);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toContain("0 lines, 0 bytes");
|
||||
expect(await readFile(path.join(tmp, "blank.txt"), "utf8")).toBe("");
|
||||
});
|
||||
|
||||
it("fails when the path is a directory", async () => {
|
||||
await mkdir(path.join(tmp, "adir"));
|
||||
const { result, text } = await run(tool(), { file_path: "adir", content: "x" }, tmp);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("directory");
|
||||
});
|
||||
|
||||
it("fails when a parent path component is an existing file", async () => {
|
||||
await writeFile(path.join(tmp, "plain.txt"), "x");
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "plain.txt/child.txt", content: "y" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("Failed to write");
|
||||
});
|
||||
|
||||
it("fails when content is missing (but not when it is empty)", async () => {
|
||||
const { result, text } = await run(tool(), { file_path: "nocontent.txt" }, tmp);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain('"content"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("read_file — argument coercion, CRLF, secret guard", () => {
|
||||
const tool = () => createReadFileTool(def(READ_FILE_NAME, "r"));
|
||||
|
||||
it("accepts numeric strings for offset/limit", async () => {
|
||||
const lines = Array.from({ length: 10 }, (_, i) => `line-${i + 1}`).join("\n");
|
||||
await writeFile(path.join(tmp, "many.txt"), `${lines}\n`);
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "many.txt", offset: "3", limit: "4" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toContain(" 3\tline-3");
|
||||
expect(text).toContain("showing 3-6");
|
||||
});
|
||||
|
||||
it("fails on non-numeric offset/limit instead of silently defaulting", async () => {
|
||||
await writeFile(path.join(tmp, "a.txt"), "x\n");
|
||||
const badOffset = await run(tool(), { file_path: "a.txt", offset: "abc" }, tmp);
|
||||
expect(badOffset.result?.stopReason).toBe("failed");
|
||||
expect(badOffset.text).toContain('Invalid "offset"');
|
||||
const badLimit = await run(tool(), { file_path: "a.txt", limit: 0 }, tmp);
|
||||
expect(badLimit.result?.stopReason).toBe("failed");
|
||||
expect(badLimit.text).toContain('Invalid "limit"');
|
||||
});
|
||||
|
||||
it("strips trailing \\r from displayed lines and notes CRLF line endings", async () => {
|
||||
await writeFile(path.join(tmp, "crlf.txt"), "alpha\r\nbeta\r\n");
|
||||
const { result, text } = await run(tool(), { file_path: "crlf.txt" }, tmp);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toContain(" 1\talpha\n");
|
||||
expect(text).not.toContain("\r");
|
||||
expect(text).toContain("(file uses CRLF line endings)");
|
||||
});
|
||||
|
||||
it("refuses the secret stores by basename regardless of directory", async () => {
|
||||
await writeFile(path.join(tmp, ".vault.toml"), "SECRET=1\n");
|
||||
await mkdir(path.join(tmp, "sub"));
|
||||
await writeFile(path.join(tmp, "sub", ".project_config.toml"), "key=1\n");
|
||||
const vault = await run(tool(), { file_path: ".vault.toml" }, tmp);
|
||||
expect(vault.result?.stopReason).toBe("failed");
|
||||
expect(vault.text).toContain("Refusing to read");
|
||||
const cfg = await run(tool(), { file_path: "sub/.project_config.toml" }, tmp);
|
||||
expect(cfg.result?.stopReason).toBe("failed");
|
||||
expect(cfg.text).toContain("Refusing to read");
|
||||
});
|
||||
});
|
||||
|
||||
describe("read_file — bounded scan and output budget", () => {
|
||||
it("self-budgets the window so the continuation note survives a small maxOutputLength", async () => {
|
||||
const lines = Array.from({ length: 10 }, (_, i) => `line-${i + 1}`).join("\n");
|
||||
await writeFile(path.join(tmp, "many.txt"), `${lines}\n`);
|
||||
const tool = createReadFileTool({
|
||||
name: READ_FILE_NAME,
|
||||
description: "test",
|
||||
permission: "r",
|
||||
maxOutputLength: 300,
|
||||
});
|
||||
const { result, text } = await run(tool, { file_path: "many.txt" }, tmp);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
// The whole output fits under the tool's own cap, so Environment cannot cut the note.
|
||||
expect(text.length).toBeLessThanOrEqual(300);
|
||||
expect(text).toMatch(
|
||||
/\[file has 10 lines total; showing 1-\d+ \(output limit reached\) — call again with offset to continue\]/,
|
||||
);
|
||||
expect(text).not.toContain("line-10");
|
||||
});
|
||||
|
||||
it("fails with guidance when the scan cap is hit before the window (one huge line)", async () => {
|
||||
await writeFile(path.join(tmp, "huge-line.txt"), "x".repeat(READ_FILE_SCAN_CAP_BYTES + 1024));
|
||||
const tool = () => createReadFileTool(def(READ_FILE_NAME, "r"));
|
||||
const { result, text } = await run(tool(), { file_path: "huge-line.txt" }, tmp);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("Stopped after scanning 8 MB");
|
||||
expect(text).toContain("sed -n");
|
||||
});
|
||||
|
||||
it("reports a lower-bound total when the file outruns the scan cap after the window", async () => {
|
||||
// > 8MB of two-byte lines: the window (1-2000) completes early, counting stops at the cap.
|
||||
await writeFile(path.join(tmp, "long.txt"), "x\n".repeat(READ_FILE_SCAN_CAP_BYTES / 2 + 4096));
|
||||
const tool = () => createReadFileTool(def(READ_FILE_NAME, "r"));
|
||||
const { result, text } = await run(tool(), { file_path: "long.txt" }, tmp);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toMatch(
|
||||
/\[file has more than \d+ lines; showing 1-2000 — call again with offset to continue\]/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit_file — review follow-ups", () => {
|
||||
const tool = () => createEditFileTool(def(EDIT_FILE_NAME, "rw"));
|
||||
|
||||
it("gives an explicitly-empty old_string its own message", async () => {
|
||||
await writeFile(path.join(tmp, "f.txt"), "hello\n");
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "f.txt", old_string: "", new_string: "x" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("old_string must not be empty");
|
||||
expect(text).toContain("write_file");
|
||||
});
|
||||
|
||||
it("mentions CRLF in the not-found error when the file uses \\r\\n", async () => {
|
||||
await writeFile(path.join(tmp, "crlf.txt"), "alpha\r\nbeta\r\n");
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "crlf.txt", old_string: "alpha\nbeta", new_string: "gamma" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBe("failed");
|
||||
expect(text).toContain("old_string not found");
|
||||
expect(text).toContain("CRLF");
|
||||
});
|
||||
|
||||
it("erasing the whole content diffs to a pure removal hunk", async () => {
|
||||
const file = path.join(tmp, "erase.txt");
|
||||
await writeFile(file, "abc");
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "erase.txt", old_string: "abc", new_string: "" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toBe('Replaced 1 occurrence in "erase.txt".\n@@ -1,1 +0,0 @@\n-abc');
|
||||
expect(await readFile(file, "utf8")).toBe("");
|
||||
});
|
||||
|
||||
it("writes atomically: no temp files are left behind", async () => {
|
||||
await writeFile(path.join(tmp, "at.txt"), "a b a\n");
|
||||
await run(tool(), { file_path: "at.txt", old_string: "b", new_string: "c" }, tmp);
|
||||
const entries = await readdir(tmp);
|
||||
expect(entries.filter((e) => e.includes(".tmp-"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("write_file — review follow-ups", () => {
|
||||
const tool = () => createWriteFileTool(def(WRITE_FILE_NAME, "rw"));
|
||||
|
||||
it("preserves the permission bits of an overwritten file (atomic rename)", async () => {
|
||||
const file = path.join(tmp, "mode.txt");
|
||||
await writeFile(file, "old");
|
||||
await chmod(file, 0o600);
|
||||
const { result } = await run(tool(), { file_path: "mode.txt", content: "new" }, tmp);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o600);
|
||||
expect(await readFile(file, "utf8")).toBe("new");
|
||||
});
|
||||
|
||||
it("writes atomically: no temp files are left behind", async () => {
|
||||
await run(tool(), { file_path: "fresh.txt", content: "x" }, tmp);
|
||||
const entries = await readdir(tmp);
|
||||
expect(entries.filter((e) => e.includes(".tmp-"))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit_file — diff output caps", () => {
|
||||
const tool = () => createEditFileTool(def(EDIT_FILE_NAME, "rw"));
|
||||
|
||||
it("caps replace_all storms at a handful of hunks plus an elision note", async () => {
|
||||
// 8 far-apart sites (gaps wider than twice the context) -> 8 hunks, capped at 5.
|
||||
const block = ["target", ...Array.from({ length: 9 }, (_, i) => `filler-${i}`)].join("\n");
|
||||
await writeFile(
|
||||
path.join(tmp, "cap.txt"),
|
||||
`${Array.from({ length: 8 }, () => block).join("\n")}\n`,
|
||||
);
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "cap.txt", old_string: "target", new_string: "changed", replace_all: true },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toContain('Replaced 8 occurrences in "cap.txt".');
|
||||
expect(text.match(/@@ /g)).toHaveLength(5);
|
||||
expect(text).toContain("…and 3 more replacements");
|
||||
});
|
||||
|
||||
it("self-budgets under a tiny maxOutputLength: the summary and note survive, hunks are dropped", async () => {
|
||||
await writeFile(path.join(tmp, "tiny.txt"), "a\nb\na\n");
|
||||
const budgetTool = createEditFileTool({
|
||||
name: EDIT_FILE_NAME,
|
||||
description: "test",
|
||||
permission: "rw",
|
||||
maxOutputLength: 150,
|
||||
});
|
||||
const { result, text } = await run(
|
||||
budgetTool,
|
||||
{ file_path: "tiny.txt", old_string: "a", new_string: "z", replace_all: true },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text.length).toBeLessThanOrEqual(150);
|
||||
expect(text).toContain('Replaced 2 occurrences in "tiny.txt".');
|
||||
expect(text).not.toContain("@@");
|
||||
expect(text).toContain("…and 2 more replacements");
|
||||
});
|
||||
|
||||
it("strips \\r from diff lines on CRLF files", async () => {
|
||||
await writeFile(path.join(tmp, "crlfdiff.txt"), "alpha\r\nbeta\r\n");
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "crlfdiff.txt", old_string: "alpha", new_string: "gamma" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toContain("-alpha");
|
||||
expect(text).toContain("+gamma");
|
||||
expect(text).toContain(" beta");
|
||||
expect(text).not.toContain("\r");
|
||||
});
|
||||
});
|
||||
|
||||
describe("write_file — overwrite diffs", () => {
|
||||
const tool = () => createWriteFileTool(def(WRITE_FILE_NAME, "rw"));
|
||||
|
||||
it("appends a small unified diff when overwriting", async () => {
|
||||
await writeFile(path.join(tmp, "d.txt"), "one\ntwo\nthree\n");
|
||||
const { result, text } = await run(
|
||||
tool(),
|
||||
{ file_path: "d.txt", content: "one\nTWO\nthree\n" },
|
||||
tmp,
|
||||
);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect(text).toBe(
|
||||
[
|
||||
'Overwrote "d.txt" (3 lines, 14 bytes).',
|
||||
"@@ -1,3 +1,3 @@",
|
||||
" one",
|
||||
"-two",
|
||||
"+TWO",
|
||||
" three",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("notes an unchanged overwrite", async () => {
|
||||
await writeFile(path.join(tmp, "same.txt"), "keep\n");
|
||||
const { text } = await run(tool(), { file_path: "same.txt", content: "keep\n" }, tmp);
|
||||
expect(text).toContain('Overwrote "same.txt"');
|
||||
expect(text).toContain("(content unchanged)");
|
||||
expect(text).not.toContain("@@");
|
||||
});
|
||||
|
||||
it("collapses a large rewrite to a one-line +X/−Y summary", async () => {
|
||||
const oldContent = Array.from({ length: 200 }, (_, i) => `a${i}`).join("\n");
|
||||
const newContent = Array.from({ length: 200 }, (_, i) => `b${i}`).join("\n");
|
||||
await writeFile(path.join(tmp, "big.txt"), oldContent);
|
||||
const { text } = await run(tool(), { file_path: "big.txt", content: newContent }, tmp);
|
||||
expect(text).toContain('Overwrote "big.txt"');
|
||||
expect(text).toContain("+200/−200 lines vs the previous content (diff too large to show)");
|
||||
expect(text).not.toContain("@@");
|
||||
});
|
||||
|
||||
it("created files carry no diff", async () => {
|
||||
const { text } = await run(tool(), { file_path: "fresh2.txt", content: "x\ny\n" }, tmp);
|
||||
expect(text).toContain('Created "fresh2.txt"');
|
||||
expect(text).not.toContain("@@");
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
toolsDir,
|
||||
type ModelRef,
|
||||
type ProjectConfig,
|
||||
type SystemConfig,
|
||||
} from "../src/state/index.js";
|
||||
import { sessionEnvironment } from "../src/internal/session-support.js";
|
||||
|
||||
@@ -170,7 +171,7 @@ describe("loadOrInitAgentState", () => {
|
||||
expect(second.systemConfig.system_prompt).toContain("PenguinHarness");
|
||||
expect(second.agentsMd).toBe(first.agentsMd);
|
||||
// The tool config is fully preserved on the load path.
|
||||
expect(second.systemConfig.tools?.builtin?.[0]?.name).toBe("exec_command");
|
||||
expect(second.systemConfig.tools?.builtin?.[0]?.name).toBe("read_file");
|
||||
});
|
||||
|
||||
it("respects custom agentId / projectId", async () => {
|
||||
@@ -182,11 +183,14 @@ describe("loadOrInitAgentState", () => {
|
||||
});
|
||||
|
||||
describe("buildToolConfig", () => {
|
||||
it("exposes exec/input command, run/input subagent (rw) and read_image (r)", async () => {
|
||||
it("exposes command, file, subagent (rw) and image (r) tools", async () => {
|
||||
const state = await loadOrInitAgentState();
|
||||
const cfg = buildToolConfig(state);
|
||||
expect(cfg.mcpServers).toEqual([]);
|
||||
expect(cfg.customTools.map((t) => t.name)).toEqual([
|
||||
"read_file",
|
||||
"edit_file",
|
||||
"write_file",
|
||||
"exec_command",
|
||||
"input_command",
|
||||
"run_subagent",
|
||||
@@ -198,16 +202,55 @@ describe("buildToolConfig", () => {
|
||||
expect(exec.permission).toBe("rw");
|
||||
expect(exec.timeoutMs).toBe(120000);
|
||||
expect(exec.maxOutputLength).toBe(16000);
|
||||
expect((exec.parameters as { required?: string[] }).required).toEqual(["cmd"]);
|
||||
expect((exec.parameters as { required?: string[] }).required).toEqual(["description", "cmd"]);
|
||||
// The four command/subagent tools declare the description call argument in config,
|
||||
// toggled by the per-entry call_description field (default true).
|
||||
expect(exec.call_description).toBe(true);
|
||||
expect(
|
||||
Object.keys((exec.parameters as { properties: Record<string, unknown> }).properties),
|
||||
).toEqual(["description", "cmd", "workdir", "yield_time_ms"]);
|
||||
const write = cfg.customTools.find((t) => t.name === "input_command")!;
|
||||
expect(write.permission).toBe("rw");
|
||||
expect((write.parameters as { required?: string[] }).required).toEqual(["process_id"]);
|
||||
expect(write.call_description).toBe(true);
|
||||
expect((write.parameters as { required?: string[] }).required).toEqual([
|
||||
"description",
|
||||
"process_id",
|
||||
]);
|
||||
// File tools: read_file is read-only with a wider output cap; edit/write are rw.
|
||||
const readFile = cfg.customTools.find((t) => t.name === "read_file")!;
|
||||
expect(readFile.permission).toBe("r");
|
||||
expect(readFile.timeoutMs).toBe(30000);
|
||||
expect(readFile.maxOutputLength).toBe(64000);
|
||||
expect((readFile.parameters as { required?: string[] }).required).toEqual(["file_path"]);
|
||||
expect(Object.keys((readFile.parameters as { properties: object }).properties)).toEqual([
|
||||
"file_path",
|
||||
"offset",
|
||||
"limit",
|
||||
]);
|
||||
const editFile = cfg.customTools.find((t) => t.name === "edit_file")!;
|
||||
expect(editFile.permission).toBe("rw");
|
||||
expect(editFile.timeoutMs).toBe(30000);
|
||||
expect(editFile.maxOutputLength).toBe(16000);
|
||||
expect((editFile.parameters as { required?: string[] }).required).toEqual([
|
||||
"file_path",
|
||||
"old_string",
|
||||
"new_string",
|
||||
]);
|
||||
const writeFile = cfg.customTools.find((t) => t.name === "write_file")!;
|
||||
expect(writeFile.permission).toBe("rw");
|
||||
expect((writeFile.parameters as { required?: string[] }).required).toEqual([
|
||||
"file_path",
|
||||
"content",
|
||||
]);
|
||||
const sub = cfg.customTools.find((t) => t.name === "run_subagent")!;
|
||||
expect(sub.permission).toBe("rw");
|
||||
expect((sub.parameters as { required?: string[] }).required).toEqual(["prompt"]);
|
||||
expect((sub.parameters as { required?: string[] }).required).toEqual(["description", "prompt"]);
|
||||
const writeSub = cfg.customTools.find((t) => t.name === "input_subagent")!;
|
||||
expect(writeSub.permission).toBe("rw");
|
||||
expect((writeSub.parameters as { required?: string[] }).required).toEqual(["subagent_id"]);
|
||||
expect((writeSub.parameters as { required?: string[] }).required).toEqual([
|
||||
"description",
|
||||
"subagent_id",
|
||||
]);
|
||||
// Both image-reading tool entries are explicitly in the config, each declaring its
|
||||
// applicable model kind via the forModel annotation.
|
||||
const readImage = cfg.customTools.find((t) => t.name === "read_image")!;
|
||||
@@ -273,6 +316,9 @@ describe("buildToolConfig", () => {
|
||||
};
|
||||
const cfg = buildToolConfig(state);
|
||||
expect(cfg.customTools.map((t) => t.name)).toEqual([
|
||||
"read_file",
|
||||
"edit_file",
|
||||
"write_file",
|
||||
"exec_command",
|
||||
"input_command",
|
||||
"run_subagent",
|
||||
@@ -283,6 +329,105 @@ describe("buildToolConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildToolConfig — per-tool call_description filter", () => {
|
||||
const makeState = (tools: NonNullable<SystemConfig["tools"]>) => ({
|
||||
root: tmpRoot,
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
stateDir: agentStateDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID),
|
||||
systemConfig: { system_prompt: "x", tools },
|
||||
agentsMd: "y",
|
||||
});
|
||||
const properties = (t: { parameters?: Record<string, unknown> }) =>
|
||||
(t.parameters as { properties: Record<string, unknown> }).properties;
|
||||
const required = (t: { parameters?: Record<string, unknown> }) =>
|
||||
(t.parameters as { required?: string[] }).required;
|
||||
|
||||
it("keeps the config-declared description property when call_description is missing or true (defaults)", async () => {
|
||||
const state = await loadOrInitAgentState();
|
||||
const cfg = buildToolConfig(state);
|
||||
for (const name of ["exec_command", "input_command", "run_subagent", "input_subagent"]) {
|
||||
const tool = cfg.customTools.find((t) => t.name === name)!;
|
||||
const desc = properties(tool)["description"] as { type?: string; description?: string };
|
||||
expect(desc.type).toBe("string");
|
||||
expect(desc.description).toContain("shown to the user");
|
||||
// Required whenever the tool offers it, so a call always carries one: the frontends
|
||||
// pick the call's display form from the schema instead of guessing mid-stream.
|
||||
expect(required(tool)).toContain("description");
|
||||
}
|
||||
// The file tools' path argument is self-describing: no description parameter in config.
|
||||
for (const name of ["read_file", "edit_file", "write_file", "read_image", "describe_image"]) {
|
||||
const tool = cfg.customTools.find((t) => t.name === name)!;
|
||||
expect(properties(tool)["description"]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("filters the description property out when call_description is false, without mutating the stored config", () => {
|
||||
const builtin = [
|
||||
{
|
||||
name: "exec_command",
|
||||
description: "shell",
|
||||
call_description: false,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { description: { type: "string" }, cmd: { type: "string" } },
|
||||
required: ["description", "cmd"],
|
||||
},
|
||||
},
|
||||
];
|
||||
const state = makeState({ builtin });
|
||||
const cfg = buildToolConfig(state);
|
||||
const assembled = cfg.customTools[0]!;
|
||||
expect(properties(assembled)["description"]).toBeUndefined();
|
||||
expect(properties(assembled)["cmd"]).toBeDefined();
|
||||
// The property and its `required` entry go together: a filtered-out argument must not
|
||||
// stay mandatory.
|
||||
expect(required(assembled)).toEqual(["cmd"]);
|
||||
expect((builtin[0]!.parameters.required as string[]).includes("description")).toBe(true);
|
||||
// In-memory clone only: the stored entry still declares the property.
|
||||
expect(properties(builtin[0]!)["description"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("is a no-op for entries without the property, a schema, or with the toggle on", () => {
|
||||
const builtin = [
|
||||
{
|
||||
name: "exec_command",
|
||||
description: "shell",
|
||||
call_description: false,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { description: { type: "string" }, cmd: { type: "string" } },
|
||||
required: ["cmd"],
|
||||
},
|
||||
},
|
||||
// call_description without a matching property (old config shape): no-op.
|
||||
{
|
||||
name: "input_command",
|
||||
description: "no description property",
|
||||
call_description: false,
|
||||
parameters: { type: "object", properties: { process_id: { type: "string" } } },
|
||||
},
|
||||
// No parameter schema at all: no-op.
|
||||
{ name: "run_subagent", description: "no schema", call_description: false },
|
||||
// call_description true keeps the declared property.
|
||||
{
|
||||
name: "input_subagent",
|
||||
description: "kept",
|
||||
call_description: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { description: { type: "string" }, subagent_id: { type: "string" } },
|
||||
},
|
||||
},
|
||||
];
|
||||
const cfg = buildToolConfig(makeState({ builtin }));
|
||||
expect(properties(cfg.customTools[0]!)["description"]).toBeUndefined();
|
||||
expect(properties(cfg.customTools[1]!)["process_id"]).toBeDefined();
|
||||
expect(cfg.customTools[2]!.parameters).toBeUndefined();
|
||||
expect(properties(cfg.customTools[3]!)["description"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assembleSystemPrompt", () => {
|
||||
it("renders default system prompt placeholders", async () => {
|
||||
const state = await loadOrInitAgentState();
|
||||
|
||||
@@ -81,7 +81,7 @@ packages/
|
||||
│ ├── engine/context-engine.ts # ReAct loop orchestration: turn lifecycle, approvals, carry-over, reconnect, compaction
|
||||
│ ├── omnimessage/ # types.ts protocol types · builders.ts constructors · aggregate.ts partial aggregation
|
||||
│ ├── llm/ # generative-model.ts AgentHub adapter · tool-call-ids.ts id uniqueness
|
||||
│ ├── environment/ # environment.ts execution close-out · tools/ registry, 6 builtin tools, background sessions
|
||||
│ ├── environment/ # environment.ts execution close-out · tools/ registry, 9 builtin tools, background sessions
|
||||
│ ├── state/ # paths · default-config · project-config · model-catalog
|
||||
│ │ # agent-state (Skill install, prompt assembly) · agent-vault · builtin-agents
|
||||
│ ├── trace/ # writer.ts append-only JSONL · resume.ts replay-based recovery
|
||||
|
||||
@@ -81,7 +81,7 @@ packages/
|
||||
│ ├── engine/context-engine.ts # ReAct 循环编排:轮生命周期、审批、补发、重连、压缩
|
||||
│ ├── omnimessage/ # types.ts 协议类型 · builders.ts 构造函数 · aggregate.ts 分片聚合
|
||||
│ ├── llm/ # generative-model.ts AgentHub 适配 · tool-call-ids.ts id 唯一化
|
||||
│ ├── environment/ # environment.ts 执行与收尾 · tools/ 注册表、6 个内置工具、后台会话
|
||||
│ ├── environment/ # environment.ts 执行与收尾 · tools/ 注册表、9 个内置工具、后台会话
|
||||
│ ├── state/ # paths · default-config · project-config · model-catalog
|
||||
│ │ # agent-state(Skill 安装、提示词装配)· agent-vault · builtin-agents
|
||||
│ ├── trace/ # writer.ts 追加式 JSONL · resume.ts 回放恢复
|
||||
|
||||
@@ -101,7 +101,7 @@ Edit this file via the CLI (`penguin config model …`) or the Web Models page
|
||||
| `compaction.max_session_turns` | `-1` | Cumulative Session turn threshold (`-1` = unlimited) |
|
||||
| `compaction.mode` | `summarize` | `summarize` / `discard` |
|
||||
| `compaction.prompt` | built-in template | Prompt used for summarize compaction |
|
||||
| `tools.builtin` | full default toolset when omitted | Tool entries: `name` / `description` / `parameters` / `permission` (`r` or `rw`) / `forModel` / `timeoutMs` / `maxOutputLength`; once written it replaces the default list wholesale |
|
||||
| `tools.builtin` | full default toolset when omitted | Tool entries: `name` / `description` / `parameters` / `permission` (`r` or `rw`) / `forModel` / `timeoutMs` / `maxOutputLength` / `call_description` (per-tool toggle for the `description` call argument, required while on; missing = kept); once written it replaces the default list wholesale |
|
||||
| `tools.mcpServers` | `[]` | MCP Server configuration (`name` + `config`); reserved for the MCP adapter layer |
|
||||
|
||||
Tool permissions and approval semantics are covered in [Tools & Approval](/tools).
|
||||
|
||||
@@ -101,7 +101,7 @@ output = 0.857143
|
||||
| `compaction.max_session_turns` | `-1` | Session 累计轮数阈值(`-1` 不限制) |
|
||||
| `compaction.mode` | `summarize` | `summarize` / `discard` |
|
||||
| `compaction.prompt` | 内置模板 | summarize 压缩使用的 Prompt |
|
||||
| `tools.builtin` | 缺省时为完整默认工具集 | 工具条目:`name` / `description` / `parameters` / `permission`(`r` 或 `rw`)/ `forModel` / `timeoutMs` / `maxOutputLength`;一旦写出即整体替换默认列表 |
|
||||
| `tools.builtin` | 缺省时为完整默认工具集 | 工具条目:`name` / `description` / `parameters` / `permission`(`r` 或 `rw`)/ `forModel` / `timeoutMs` / `maxOutputLength` / `call_description`(条目级开关:控制 `description` 调用参数,开启时为必填,缺省保留);一旦写出即整体替换默认列表 |
|
||||
| `tools.mcpServers` | `[]` | MCP Server 配置(`name` + `config`),预留给 MCP 适配层 |
|
||||
|
||||
工具权限与审批语义见[工具与审批](/tools)。
|
||||
|
||||
@@ -32,7 +32,7 @@ One install gives you four layers that share a single data directory and a singl
|
||||
|
||||
These principles run through every component; the design pages keep coming back to them:
|
||||
|
||||
- **A minimal toolset**: the shell is the universal interface — file reads, writes and edits all go through `exec_command`. See [Tools & Approval](/tools).
|
||||
- **A minimal toolset**: dedicated file tools (`read_file` / `edit_file` / `write_file`) for precise reading and editing, with the shell (`exec_command`) as the general-purpose fallback for everything else. See [Tools & Approval](/tools).
|
||||
- **Agents are editable data**: prompts, Skills and config are editable files on disk, not hardcoded constants — what you can see, an Agent can improve. See the [Configuration Reference](/configuration).
|
||||
- **Everything observable**: every request, tool call and approval decision is appended to the [Trace](/sessions-and-traces); a Session restores fully from it.
|
||||
- **Errors converge into messages**: model and tool failures never throw — they become messages the model can react to. See [The Agent Loop](/agent-loop).
|
||||
|
||||
@@ -32,7 +32,7 @@ PenguinHarness 的能力围绕三个递进的概念展开——消息协议、SD
|
||||
|
||||
这些原则贯穿所有组件,后续每一页设计文档都会反复引用:
|
||||
|
||||
- **极简工具集**:shell 是通用接口,文件读写与命令执行统一经 `exec_command` 完成,见[工具与审批](/tools)。
|
||||
- **极简工具集**:专门的文件工具(`read_file` / `edit_file` / `write_file`)负责精确读写,shell(`exec_command`)作为通用兜底接口负责其余一切,见[工具与审批](/tools)。
|
||||
- **Agent 是可编辑的数据**:Prompt、Skill、配置都是磁盘上的可编辑文件,而非硬编码——你能看到的,Agent 就能改进,见[配置参考](/configuration)。
|
||||
- **全量可观测**:每一次请求、工具调用与审批决策都以追加方式写入 [Trace](/sessions-and-traces),Session 可从 Trace 完整恢复。
|
||||
- **错误收敛为消息**:模型与工具的错误不抛异常,而是变成模型可以继续处理的消息,见 [Agent 运行循环](/agent-loop)。
|
||||
|
||||
@@ -36,7 +36,7 @@ Parsing is tolerant: only `key: value` scalar lines inside the first `---` block
|
||||
|
||||
## Progressive loading
|
||||
|
||||
Skills follow an "index first, body on demand" design: the system prompt injects only each installed Skill's metadata (name + description) through the `{{SKILL_METADATA}}` placeholder, and instructs the model to read the matching `SKILL.md` in full via the shell before following it. There is no dedicated skill tool — reading the body is just one `exec_command` call (see [Tools & Approval](/tools)).
|
||||
Skills follow an "index first, body on demand" design: the system prompt injects only each installed Skill's metadata (name + description) through the `{{SKILL_METADATA}}` placeholder, and instructs the model to read the matching `SKILL.md` in full via the shell before following it. There is no dedicated skill tool — reading the body is just one `read_file` or shell call (see [Tools & Approval](/tools)).
|
||||
|
||||
Chat can also pin skills explicitly: the message then starts with a `<use_skills>` block listing the skill names.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ updated: 2026-07-17
|
||||
|
||||
## 渐进式加载
|
||||
|
||||
Skill 采用「先索引、后正文」的设计:系统 Prompt 经 `{{SKILL_METADATA}}` 占位符只注入每个已安装 Skill 的元数据(name + description),并指示模型在任务匹配某个 Skill 时,先用 Shell 完整读取对应的 `SKILL.md`,再遵循执行。系统不设专门的 Skill 工具,读取正文就是一次 `exec_command` 调用(见 [工具与审批](/tools))。
|
||||
Skill 采用「先索引、后正文」的设计:系统 Prompt 经 `{{SKILL_METADATA}}` 占位符只注入每个已安装 Skill 的元数据(name + description),并指示模型在任务匹配某个 Skill 时,先用 Shell 完整读取对应的 `SKILL.md`,再遵循执行。系统不设专门的 Skill 工具,读取正文就是一次 `read_file` 或 Shell 调用(见 [工具与审批](/tools))。
|
||||
|
||||
对话中也可以显式指定 Skill:此时消息以 `<use_skills>` 块开头,列出要使用的 Skill 名。
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: The deliberately minimal built-in toolset, its execution contract w
|
||||
|
||||
## Design
|
||||
|
||||
PenguinHarness ships a deliberately minimal built-in toolset: the shell is the universal interface, and reading, writing and editing files all go through `exec_command` — there are no separate file tools. Fewer tools mean fewer schema tokens and fewer wrong calls.
|
||||
PenguinHarness ships a deliberately minimal built-in toolset: dedicated file tools (`read_file` / `edit_file` / `write_file`) cover precise reading and editing — line-numbered output and exact-string replacement beat quoting `sed` one-liners — while the shell (`exec_command`) remains the general-purpose fallback for everything else: running programs, searching, installing dependencies. Every tool that remains earns its schema tokens.
|
||||
|
||||
## Execution contract
|
||||
|
||||
@@ -58,20 +58,30 @@ Each tool is described by one `ToolDefinitionConfig`:
|
||||
| `forModel` | `"vision"` / `"text-only"`: selected by the Session model's class; omitted = available to all models |
|
||||
| `timeoutMs` | Per-call timeout (ms), default 120000; `<=0` disables |
|
||||
| `maxOutputLength` | Output length cap (characters); `<=0` disables |
|
||||
| `call_description` | Per-tool toggle for the `description` call argument declared in `parameters` (required while on); missing = kept, `false` filters it and its `required` entry out of the schema at assembly |
|
||||
|
||||
## Built-in tools
|
||||
|
||||
There are 6 built-in tools (assembled via `packages/core/src/environment/tools/registry.ts`):
|
||||
There are 9 built-in tools (assembled via `packages/core/src/environment/tools/registry.ts`):
|
||||
|
||||
| Tool | Permission | Timeout (ms) | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `exec_command` | rw | 120000 | Run a shell command in the Workspace via `bash -lc`, streaming stdout/stderr |
|
||||
| `input_command` | rw | 130000 | Drive a running command by `process_id`: write stdin, send Ctrl-C, poll output |
|
||||
| `read_file` | r | 30000 | Read a text file as a line-numbered (`cat -n`) window, paged by offset/limit |
|
||||
| `edit_file` | rw | 30000 | Exact-string replacement in an existing file, echoing a verification snippet |
|
||||
| `write_file` | rw | 30000 | Create or overwrite a whole file, creating parent directories as needed |
|
||||
| `run_subagent` | rw | 600000 | Delegate a self-contained subtask to a child Agent in the same Workspace |
|
||||
| `input_subagent` | rw | 600000 | Poll a background subagent, or send a follow-up prompt once it is idle |
|
||||
| `read_image` | r | 60000 | Read an image and return it as image content (vision models) |
|
||||
| `describe_image` | r | 90000 | Have the configured `vision_model` read the image and answer in text (text-only models) |
|
||||
|
||||
Note that an existing agent's persisted `tools.builtin` list is frozen as written (the settings UI edits rows but adds none): agents created before this toolset do not pick up the file tools automatically — hand-edit the agent's `system_config.yaml` and add the new entries (copy them from the default definitions in `packages/core/src/state/default-config.ts`) to adopt them.
|
||||
|
||||
### Call descriptions
|
||||
|
||||
The command/subagent tools (`exec_command`, `input_command`, `run_subagent`, `input_subagent`) take a `description` argument: one model-written sentence about what the call is doing, shown by the CLI and Web UI while the call runs. The argument is declared as a normal `description` property in each entry's `parameters` in `system_config.yaml` (tool schemas live entirely in the editable config), and it is **required** there — a tool that offers the argument always gets one, so the frontends can pick a call's display form from the schema instead of guessing while the arguments stream; the model is also asked to emit it first. The per-entry `call_description` field toggles the whole thing — missing = kept, `call_description: false` filters the property (and its `required` entry) out of the schema at assembly time (in-memory only, the YAML is never rewritten). The file tools don't take it — their `file_path` argument is self-describing.
|
||||
|
||||
### Command sessions
|
||||
|
||||
`exec_command` waits in the foreground first; if the command outruns `yield_time_ms` it moves to the background and the call returns the output so far plus a `process_id`, driven from then on by `input_command`:
|
||||
@@ -94,6 +104,7 @@ Both tools' arguments (explicit keys):
|
||||
cmd: string; // required: the shell command to run
|
||||
workdir?: string; // working directory; defaults to the Workspace root, relative paths resolve against it
|
||||
yield_time_ms?: number; // foreground wait; default 60000, minimum 250, capped below the tool timeout
|
||||
description: string; // required while call_description is on: one sentence shown to the user while the call runs, emitted first
|
||||
}
|
||||
|
||||
// input_command
|
||||
@@ -101,6 +112,40 @@ Both tools' arguments (explicit keys):
|
||||
process_id: string; // required: the command-session id returned by exec_command
|
||||
chars?: string; // characters for stdin; send "\u0003" alone to deliver Ctrl-C; empty = poll only
|
||||
yield_time_ms?: number; // wait; defaults 250 for writes, 5000 for empty polls
|
||||
description: string; // required while call_description is on
|
||||
}
|
||||
```
|
||||
|
||||
### File tools
|
||||
|
||||
`read_file` / `edit_file` / `write_file` run with the user's full permissions, same as the shell tool; relative paths resolve against the Workspace and absolute paths are allowed. They are non-streaming (a single final output) and never throw — failures come back as explanatory text with `stop_reason: failed`.
|
||||
|
||||
```ts
|
||||
// read_file — cat -n style output (line number, tab, content); overlong single lines are
|
||||
// truncated, and binary content (NUL bytes) is rejected with advice to use shell/image tools.
|
||||
{
|
||||
file_path: string; // required: absolute, or relative to the Workspace
|
||||
offset?: number; // 1-based line to start from; default 1
|
||||
limit?: number; // max lines returned; default 2000 — a trailing note points at the continuation
|
||||
}
|
||||
|
||||
// edit_file — the file must exist; old_string must occur exactly once (or set replace_all);
|
||||
// success echoes "Replaced N occurrence(s)" plus a git-style unified diff of the changed
|
||||
// regions (one hunk per site, nearby sites merged; replace_all storms are capped at a few
|
||||
// hunks plus an "…and N more replacements" note).
|
||||
{
|
||||
file_path: string; // required
|
||||
old_string: string; // required: exact text to replace, including whitespace/indentation
|
||||
new_string: string; // required: must differ from old_string
|
||||
replace_all?: boolean; // replace every occurrence; default false
|
||||
}
|
||||
|
||||
// write_file — creates parent directories as needed; reports "Created" vs "Overwrote" with
|
||||
// lines/bytes. An overwrite also shows a small unified diff against the previous content,
|
||||
// or a one-line +X/−Y summary when the change is large.
|
||||
{
|
||||
file_path: string; // required
|
||||
content: string; // required: full file content; an empty string creates an empty file
|
||||
}
|
||||
```
|
||||
|
||||
@@ -115,6 +160,7 @@ Both tools' arguments (explicit keys):
|
||||
agent_id?: string; // the child Agent; defaults to the current Agent
|
||||
model_id?: string; // the child Session's model; inherits the parent Session's model when omitted
|
||||
yield_time_ms?: number; // foreground wait; default 300000
|
||||
description: string; // required while call_description is on
|
||||
}
|
||||
|
||||
// input_subagent
|
||||
@@ -122,6 +168,7 @@ Both tools' arguments (explicit keys):
|
||||
subagent_id: string; // required: the background Subagent id returned by run_subagent
|
||||
prompt?: string; // follow-up task, accepted only while the child Session is idle; empty = poll only
|
||||
yield_time_ms?: number; // wait; defaults 300000 with a prompt, 10000 for empty polls
|
||||
description: string; // required while call_description is on
|
||||
}
|
||||
```
|
||||
|
||||
@@ -182,6 +229,9 @@ tools:
|
||||
- name: exec_command
|
||||
description: Run a shell command in the workspace.
|
||||
permission: rw
|
||||
# Optional per-tool toggle: false filters the `description` call argument
|
||||
# (declared in parameters.properties) out of the schema (missing = kept).
|
||||
call_description: false
|
||||
timeoutMs: 120000
|
||||
maxOutputLength: 16000
|
||||
# parameters: the complete JSON Schema is required (see the default definition
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 极简内置工具集的设计与执行契约、Environment 统一
|
||||
|
||||
## 设计取向
|
||||
|
||||
PenguinHarness 刻意维持一个极小的内置工具集:Shell 是通用接口,文件的读取、写入、编辑全部经由 `exec_command` 完成,不设专门的文件工具。工具越少,注入的 schema 越少,Token 开销越小,模型误调用的概率也越低。
|
||||
PenguinHarness 刻意维持一个极小的内置工具集:文件的精确读取与编辑交给专门的文件工具(`read_file` / `edit_file` / `write_file`)——带行号的输出与精确字符串替换比拼 `sed` 命令更可靠;Shell(`exec_command`)仍是通用兜底接口,负责运行程序、搜索、装依赖等其余一切。保留下来的每个工具都对得起它占用的 schema Token。
|
||||
|
||||
## 执行契约
|
||||
|
||||
@@ -58,20 +58,30 @@ interface ToolResult {
|
||||
| `forModel` | `"vision"` / `"text-only"`:按 Session 模型类别装配;缺省对所有模型可用 |
|
||||
| `timeoutMs` | 单次调用超时(ms),默认 120000;`<=0` 关闭 |
|
||||
| `maxOutputLength` | 输出长度上限(字符);`<=0` 关闭 |
|
||||
| `call_description` | 条目级开关:控制 `parameters` 中声明的 `description` 调用参数(开启时为必填);缺省保留,`false` 时装配阶段将其连同 `required` 项从 schema 滤除 |
|
||||
|
||||
## 内置工具
|
||||
|
||||
共 6 个内置工具(装配入口 `packages/core/src/environment/tools/registry.ts`):
|
||||
共 9 个内置工具(装配入口 `packages/core/src/environment/tools/registry.ts`):
|
||||
|
||||
| 工具 | 权限 | 超时(ms) | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| `exec_command` | rw | 120000 | 在 Workspace 内以 `bash -lc` 运行命令,流式返回 stdout/stderr |
|
||||
| `input_command` | rw | 130000 | 按 `process_id` 驱动运行中的命令:写 stdin、发 Ctrl-C、轮询输出 |
|
||||
| `read_file` | r | 30000 | 按 `cat -n` 风格带行号读取文本文件,以 offset/limit 分页 |
|
||||
| `edit_file` | rw | 30000 | 对既有文件做精确字符串替换,回显校验片段 |
|
||||
| `write_file` | rw | 30000 | 新建或整体覆写文件,按需创建父目录 |
|
||||
| `run_subagent` | rw | 600000 | 把自包含子任务委派给同 Workspace 的子 Agent |
|
||||
| `input_subagent` | rw | 600000 | 轮询后台 Subagent,或在其空闲时追加后续 Prompt |
|
||||
| `read_image` | r | 60000 | 读取图片并作为图像内容返回(vision 模型) |
|
||||
| `describe_image` | r | 90000 | 由 `vision_model` 代读图片并返回文字回答(text-only 模型) |
|
||||
|
||||
注意:既有 Agent 已落盘的 `tools.builtin` 列表按原样冻结(设置页只能编辑行、不能增行):本工具集之前创建的 Agent 不会自动获得文件工具——需手工编辑该 Agent 的 `system_config.yaml`,把新条目补进去(可从 `packages/core/src/state/default-config.ts` 的默认定义复制)。
|
||||
|
||||
### 调用描述
|
||||
|
||||
命令 / Subagent 类工具(`exec_command`、`input_command`、`run_subagent`、`input_subagent`)带 `description` 参数:由模型写一句"本次调用在做什么",CLI 与 Web 在调用运行期间展示给用户。该参数作为普通的 `description` 属性直接写在各条目的 `parameters` 中(工具 schema 完全存于可编辑配置),并且是**必填**的——提供该参数的工具每次调用都会带上它,前端据 schema 即可确定这次调用的展示形态,无需在参数流式过程中猜测;同时要求模型最先输出它。整个参数由条目级 `call_description` 字段控制——缺省保留,写 `call_description: false` 时装配阶段将该属性连同其 `required` 项一起从 schema 中滤除(仅内存内,不改写 YAML)。文件工具不带此参数——其 `file_path` 参数本身已说明用途。
|
||||
|
||||
### 命令会话
|
||||
|
||||
`exec_command` 先在前台等待;命令超过 `yield_time_ms` 仍未结束时转入后台,返回已有输出和一个 `process_id`,之后用 `input_command` 驱动:
|
||||
@@ -93,6 +103,7 @@ exec_command(cmd)
|
||||
cmd: string; // 必填:要执行的 shell 命令
|
||||
workdir?: string; // 工作目录;缺省为 Workspace 根,相对路径按其解析
|
||||
yield_time_ms?: number; // 前台等待时长;默认 60000,最小 250,上限受工具超时约束
|
||||
description: string; // 开关开启时必填:一句话说明,最先输出,调用运行期间展示给用户
|
||||
}
|
||||
|
||||
// input_command
|
||||
@@ -100,6 +111,39 @@ exec_command(cmd)
|
||||
process_id: string; // 必填:exec_command 返回的命令会话 id
|
||||
chars?: string; // 写入 stdin 的字符;单独发送 "\u0003" 传递 Ctrl-C;缺省仅轮询
|
||||
yield_time_ms?: number; // 等待时长;有写入默认 250,空轮询默认 5000
|
||||
description: string; // 开关开启时必填
|
||||
}
|
||||
```
|
||||
|
||||
### 文件工具
|
||||
|
||||
`read_file` / `edit_file` / `write_file` 与 Shell 工具一样以用户完整权限运行:相对路径按 Workspace 解析,也接受绝对路径。三者均为非流式(一次性输出最终结果),从不抛异常——失败以解释性文本收尾,`stop_reason` 为 `failed`。
|
||||
|
||||
```ts
|
||||
// read_file — cat -n 风格输出(行号、制表符、内容);超长单行会被截断,
|
||||
// 含 NUL 字节的二进制内容被拒绝并提示改用 Shell / 图像工具。
|
||||
{
|
||||
file_path: string; // 必填:绝对路径,或相对 Workspace 的路径
|
||||
offset?: number; // 起始行号(1 起);默认 1
|
||||
limit?: number; // 最多返回的行数;默认 2000——未读完时尾部注记提示续读
|
||||
}
|
||||
|
||||
// edit_file — 文件必须已存在;old_string 必须恰好出现一次(或设 replace_all);
|
||||
// 成功时回显 "Replaced N occurrence(s)" 及改动区域的 git 风格 unified diff
|
||||
// (每个替换点一个 hunk,相邻替换点合并;replace_all 大量命中时截断为少量 hunk
|
||||
// 并附 "…and N more replacements" 注记)。
|
||||
{
|
||||
file_path: string; // 必填
|
||||
old_string: string; // 必填:要替换的原文,须与文件内容(含空白/缩进)完全一致
|
||||
new_string: string; // 必填:替换文本,须与 old_string 不同
|
||||
replace_all?: boolean; // 替换全部出现处;默认 false
|
||||
}
|
||||
|
||||
// write_file — 按需创建父目录;报告 "Created" 或 "Overwrote" 及行数/字节数。
|
||||
// 覆写时还会附上与旧内容的小型 unified diff;改动过大时改为一行 +X/−Y 摘要。
|
||||
{
|
||||
file_path: string; // 必填
|
||||
content: string; // 必填:完整文件内容;空字符串创建空文件
|
||||
}
|
||||
```
|
||||
|
||||
@@ -114,6 +158,7 @@ exec_command(cmd)
|
||||
agent_id?: string; // 子 Agent;缺省复用当前 Agent
|
||||
model_id?: string; // 子 Session 模型;缺省继承父 Session 的模型
|
||||
yield_time_ms?: number; // 前台等待时长;默认 300000
|
||||
description: string; // 开关开启时必填
|
||||
}
|
||||
|
||||
// input_subagent
|
||||
@@ -121,6 +166,7 @@ exec_command(cmd)
|
||||
subagent_id: string; // 必填:run_subagent 返回的后台 Subagent id
|
||||
prompt?: string; // 追加任务,仅在子 Session 空闲时接受;缺省仅轮询
|
||||
yield_time_ms?: number; // 等待时长;有追加默认 300000,空轮询默认 10000
|
||||
description: string; // 开关开启时必填
|
||||
}
|
||||
```
|
||||
|
||||
@@ -180,6 +226,9 @@ tools:
|
||||
- name: exec_command
|
||||
description: Run a shell command in the workspace.
|
||||
permission: rw
|
||||
# 可选的条目级开关:false 时从 schema 滤除 parameters.properties 里声明的
|
||||
# description 调用参数(缺省保留)。
|
||||
call_description: false
|
||||
timeoutMs: 120000
|
||||
maxOutputLength: 16000
|
||||
# parameters: 必须携带完整 JSON Schema(默认定义见
|
||||
|
||||
@@ -64,7 +64,7 @@ The list page creates and deletes Agents; clicking through opens the `/agents/:a
|
||||
| Overview | Basic info, export / import of Agent State snapshots, and restoring the default configuration (overwrites customizations, keeping only name/description) |
|
||||
| Prompt | AGENTS.md and system_prompt |
|
||||
| Runtime | Runtime parameters such as max_turns, model.*, compaction.* |
|
||||
| Tools | Built-in tool table and MCP server JSON configuration |
|
||||
| Tools | Built-in tool table (incl. per-tool call_description switches) and MCP server JSON configuration |
|
||||
| Vault | Environment-variable entries with masked values |
|
||||
| Schedule | Scheduled tasks (TOML-defined): create, edit, toggle, delete |
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ penguin web
|
||||
| Overview | 基本信息、Agent State 快照的导出 / 导入,以及还原为默认配置(覆盖自定义内容,仅保留名称与描述) |
|
||||
| Prompt | AGENTS.md 与 system_prompt |
|
||||
| Runtime | max_turns、model.*、compaction.* 等运行参数 |
|
||||
| Tools | 内置工具表格与 MCP Server 的 JSON 配置 |
|
||||
| Tools | 内置工具表格(含条目级 call_description 开关)与 MCP Server 的 JSON 配置 |
|
||||
| Vault | 环境变量条目,值以掩码显示 |
|
||||
| Schedule | 定时任务(TOML 定义):创建、编辑、启停、删除 |
|
||||
|
||||
|
||||
@@ -187,6 +187,17 @@ export function optionalNumber(
|
||||
return v;
|
||||
}
|
||||
|
||||
export function optionalBoolean(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
label = key,
|
||||
): boolean | undefined {
|
||||
const v = obj[key];
|
||||
if (v === undefined) return undefined;
|
||||
if (typeof v !== "boolean") throw badRequest(`${label} must be a boolean.`);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Validate a yyyy-mm-dd query parameter (defaults to undefined). */
|
||||
export function optionalDateParam(value: string | undefined, label: string): string | undefined {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
|
||||
@@ -35,7 +35,13 @@ import type {
|
||||
VaultUpdateRequest,
|
||||
} from "../api/types.js";
|
||||
import { HttpError } from "../http/errors.js";
|
||||
import { badRequest, optionalEnum, optionalNumber, optionalString } from "../http/validate.js";
|
||||
import {
|
||||
badRequest,
|
||||
optionalBoolean,
|
||||
optionalEnum,
|
||||
optionalNumber,
|
||||
optionalString,
|
||||
} from "../http/validate.js";
|
||||
import { maskApiKey } from "./project-config-service.js";
|
||||
|
||||
const THINKING_LEVELS: readonly ThinkingLevelName[] = ["none", "low", "medium", "high", "xhigh"];
|
||||
@@ -328,6 +334,7 @@ function validateToolsBuiltin(value: unknown): ToolDefinitionConfig[] {
|
||||
if (t.forModel !== undefined && t.forModel !== "vision" && t.forModel !== "text-only") {
|
||||
throw badRequest(`toolsBuiltin[${i}].forModel must be one of vision / text-only.`);
|
||||
}
|
||||
optionalBoolean(t, "call_description", `toolsBuiltin[${i}].call_description`);
|
||||
optionalNumber(t, "timeoutMs", {
|
||||
integer: true,
|
||||
positiveOrMinusOne: true,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Agent config route: the per-tool `call_description` field on toolsBuiltin rows. The
|
||||
* default config writes `call_description: true` (plus the `description` property in
|
||||
* parameters) on the four command/subagent tools; PUT round-trips a flipped `false` into
|
||||
* system_config.yaml (preserving the rest of the file); a non-boolean value is a 400.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import { systemConfigPath } from "@prismshadow/penguin-core";
|
||||
import type { ToolDefinitionConfig } from "@prismshadow/penguin-core";
|
||||
import type { AgentConfigResponse, ProjectCreateResponse } from "../src/api/types.js";
|
||||
import { apiClient, createTestApp, provisionUser } from "./helpers.js";
|
||||
import type { TestApp } from "./helpers.js";
|
||||
|
||||
describe("agent config: per-tool call_description", () => {
|
||||
let t: TestApp;
|
||||
let owner: ReturnType<typeof apiClient>;
|
||||
let projectId: string;
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
t = await createTestApp();
|
||||
const a = await provisionUser(t.app, "owner_cd");
|
||||
owner = apiClient(t.app, a.cookie);
|
||||
const created = (await (
|
||||
await owner.post("/api/projects", { projectId: "owner_cd-calldesc", name: "cd project" })
|
||||
).json()) as ProjectCreateResponse;
|
||||
projectId = created.project.projectId;
|
||||
configPath = `/api/projects/${projectId}/agents/default_agent/config`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await t.cleanup();
|
||||
});
|
||||
|
||||
it("defaults carry call_description: true and the description property; PUT false round-trips", async () => {
|
||||
const initial = (await (await owner.get(configPath)).json()) as AgentConfigResponse;
|
||||
const names = initial.config.toolsBuiltin.map((tool) => tool.name);
|
||||
// File tools lead the default toolset; the renamed shell tool follows.
|
||||
expect(names.slice(0, 4)).toEqual(["read_file", "edit_file", "write_file", "exec_command"]);
|
||||
const propsOf = (tool: ToolDefinitionConfig): Record<string, unknown> =>
|
||||
(tool.parameters as { properties: Record<string, unknown> }).properties;
|
||||
for (const name of ["exec_command", "input_command", "run_subagent", "input_subagent"]) {
|
||||
const tool = initial.config.toolsBuiltin.find((row) => row.name === name)!;
|
||||
expect(tool.call_description).toBe(true);
|
||||
expect(propsOf(tool)["description"]).toBeDefined();
|
||||
}
|
||||
for (const name of ["read_file", "edit_file", "write_file"]) {
|
||||
const tool = initial.config.toolsBuiltin.find((row) => row.name === name)!;
|
||||
expect(tool.call_description).toBeUndefined();
|
||||
expect(propsOf(tool)["description"]).toBeUndefined();
|
||||
}
|
||||
|
||||
// Flip exec_command's toggle off via the whole-table PUT.
|
||||
const tools = initial.config.toolsBuiltin.map((row) =>
|
||||
row.name === "exec_command" ? { ...row, call_description: false } : row,
|
||||
);
|
||||
const putRes = await owner.put(configPath, { config: { toolsBuiltin: tools } });
|
||||
expect(putRes.status).toBe(200);
|
||||
const updated = (await putRes.json()) as AgentConfigResponse;
|
||||
expect(
|
||||
updated.config.toolsBuiltin.find((row) => row.name === "exec_command")!.call_description,
|
||||
).toBe(false);
|
||||
// Written into the YAML itself; the rest of the config is preserved.
|
||||
const yaml = await fs.readFile(systemConfigPath(t.root, projectId, "default_agent"), "utf8");
|
||||
expect(yaml).toContain("call_description: false");
|
||||
expect(updated.config.systemPrompt).toBe(initial.config.systemPrompt);
|
||||
// The property stays declared in the stored parameters (filtering happens at assembly, not in the config).
|
||||
expect(
|
||||
propsOf(updated.config.toolsBuiltin.find((row) => row.name === "exec_command")!)[
|
||||
"description"
|
||||
],
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects a non-boolean call_description with 400", async () => {
|
||||
const initial = (await (await owner.get(configPath)).json()) as AgentConfigResponse;
|
||||
const tools = initial.config.toolsBuiltin.map((row) =>
|
||||
row.name === "exec_command" ? { ...row, call_description: "yes" } : row,
|
||||
);
|
||||
const res = await owner.put(configPath, { config: { toolsBuiltin: tools } });
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error?: { message?: string } };
|
||||
expect(JSON.stringify(body)).toContain("call_description");
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import { Button } from "../../components/ui/button";
|
||||
import { toastError, toastInfo, toastSuccess } from "../../components/ui/toast";
|
||||
import { Input, Textarea } from "../../components/ui/input";
|
||||
import { OptionMenu, type OptionMenuChoice } from "../../components/ui/option-menu";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import { ConfirmModal, useSaveConfirm } from "../../components/ui/confirm-modal";
|
||||
import { Skeleton } from "../../components/ui/skeleton";
|
||||
import { VaultTab } from "./vault-tab";
|
||||
@@ -755,6 +756,12 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
errs[`${i}-maxOutputLength`] = S.agent.toolFieldInvalid(row.base.name, "maxOutputLength");
|
||||
} else tool.maxOutputLength = n;
|
||||
}
|
||||
// call_description: missing = true, so flipping a stored-missing row back to on
|
||||
// rewinds to "not written" instead of writing the default explicitly.
|
||||
const origRow = data.config.toolsBuiltin[i];
|
||||
if (tool.call_description === true && origRow?.call_description === undefined) {
|
||||
delete tool.call_description;
|
||||
}
|
||||
tools.push(tool);
|
||||
}
|
||||
if (Object.keys(errs).length > 0) {
|
||||
@@ -772,7 +779,8 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
return (
|
||||
t.permission !== o.permission ||
|
||||
t.timeoutMs !== o.timeoutMs ||
|
||||
t.maxOutputLength !== o.maxOutputLength
|
||||
t.maxOutputLength !== o.maxOutputLength ||
|
||||
t.call_description !== o.call_description
|
||||
);
|
||||
});
|
||||
if (!dirty) {
|
||||
@@ -782,16 +790,24 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
requestSave(() => void onSave({ config: { toolsBuiltin: tools } }));
|
||||
};
|
||||
|
||||
/** Whether a tool's config schema declares the optional `description` call argument (only then does the per-row switch make sense). */
|
||||
const hasDescriptionProperty = (t: ToolDefinitionConfig): boolean => {
|
||||
const props = (t.parameters as { properties?: Record<string, unknown> } | undefined)
|
||||
?.properties;
|
||||
return props !== undefined && props !== null && props["description"] !== undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto overflow-y-clip rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<table className="w-full min-w-[520px] text-left text-sm">
|
||||
<table className="w-full min-w-[640px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900">
|
||||
<th className="px-3 py-2">{S.common.name}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolPermission}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolTimeout}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolMaxOutput}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolCallDescription}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -829,11 +845,25 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
onChange={(e) => update(i, { maxOutputLength: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
{/* Per-tool call_description switch (missing = on): shown only for tools whose
|
||||
config schema actually declares the description argument. */}
|
||||
{hasDescriptionProperty(row.base) ? (
|
||||
<Switch
|
||||
checked={row.base.call_description !== false}
|
||||
onChange={(v) => update(i, { base: { ...row.base, call_description: v } })}
|
||||
aria-label={`${row.base.name} ${S.agent.toolCallDescription}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-gray-300 dark:text-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">{S.agent.callDescriptionHint}</p>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.mcpServers}</p>
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
* it also doesn't render if none of the candidates exist — the heuristic extraction inevitably
|
||||
* matches error message examples, external paths, and other strings that can't actually be
|
||||
* opened, so this card is only responsible for "if you click it, it really opens".
|
||||
* Doesn't include diff stats — the only built-in tool is exec_command (file writes happen inside
|
||||
* the shell), so the protocol has no structured edit signal; this is just an aggregated view of
|
||||
* Doesn't include diff stats — file writes may happen inside opaque exec_command shells, so
|
||||
* the protocol has no reliable structured edit signal; this is just an aggregated view of
|
||||
* text references, hence the neutral "N files" title.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
@@ -28,19 +28,113 @@ import { LiveDuration } from "./live-duration";
|
||||
import { SubagentCard } from "./subagent-card";
|
||||
import type { StreamRenderContext } from "./message-stream";
|
||||
|
||||
/** Tools that accept the optional model-written `description` argument. */
|
||||
const DESCRIBED_TOOLS = new Set([
|
||||
"exec_command",
|
||||
"input_command",
|
||||
"run_subagent",
|
||||
"input_subagent",
|
||||
]);
|
||||
|
||||
/** The three file tools: previewed by their `file_path` argument. */
|
||||
const FILE_TOOLS = 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 (same rule as the
|
||||
* CLI's tool-render). The full path stays in the expanded arguments block.
|
||||
*/
|
||||
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]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Argument preview (same approach as the CLI's tool-render): exec_command shows `$ <cmd>`,
|
||||
* other tools show a single-line `name(args)` prefix. Arguments may be incomplete JSON
|
||||
* (mid-stream), so extraction is done leniently.
|
||||
* the file tools show their shortened file path, other tools show a single-line
|
||||
* `name(args)` prefix. Arguments may be incomplete JSON (mid-stream), so extraction is done
|
||||
* leniently. The preview deliberately keeps the real arguments (not the model-written
|
||||
* description): it heads the approval row, and the user must approve the actual command,
|
||||
* not the model's summary of it.
|
||||
*/
|
||||
function previewArguments(name: string, argsJson: string): string {
|
||||
export function previewArguments(name: string, argsJson: string): string {
|
||||
if (name === "exec_command") {
|
||||
const cmd = extractStringField(argsJson, "cmd");
|
||||
if (cmd !== null) return `$ ${cmd.replace(/\s+/g, " ").trim()}`;
|
||||
}
|
||||
if (FILE_TOOLS.has(name)) {
|
||||
const filePath = extractStringField(argsJson, "file_path");
|
||||
if (filePath !== null) return shortenPath(filePath.replace(/\s+/g, " ").trim());
|
||||
}
|
||||
return argsJson.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsed-header subtitle: the human-readable line next to the tool name — the
|
||||
* model-written `description` argument for the command/subagent tools (declared in their
|
||||
* config schema; per-tool `call_description: false` removes it, in which case the model
|
||||
* never sends it), or the shortened file path for the file tools. Null when there is
|
||||
* nothing beyond the raw arguments.
|
||||
*/
|
||||
export function headerSubtitle(name: string, argsJson: string): string | null {
|
||||
if (DESCRIBED_TOOLS.has(name)) {
|
||||
const desc = extractStringField(argsJson, "description");
|
||||
if (desc !== null) {
|
||||
const line = desc.replace(/\s+/g, " ").trim();
|
||||
if (line) return line;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (FILE_TOOLS.has(name)) {
|
||||
const filePath = extractStringField(argsJson, "file_path");
|
||||
if (filePath !== null) {
|
||||
const line = filePath.replace(/\s+/g, " ").trim();
|
||||
if (line) return shortenPath(line);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoded file-tool payload for the pending-approval block: the user is approving a
|
||||
* concrete rewrite (old_string/new_string/content), so the bare path is not enough — the
|
||||
* actual arguments are rendered in the scrollable expanded style while the call is PENDING.
|
||||
* Null for other tools or unparseable arguments (arguments are complete by approval time).
|
||||
*/
|
||||
export function pendingFilePayload(name: string, argsJson: string): string | null {
|
||||
if (!FILE_TOOLS.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 sections: string[] = [];
|
||||
const push = (label: string, value: unknown): void => {
|
||||
if (value === undefined) return;
|
||||
if (typeof value === "string" && value.includes("\n")) {
|
||||
sections.push(`${label}:\n${value}`);
|
||||
} else {
|
||||
sections.push(`${label}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
|
||||
}
|
||||
};
|
||||
push("file_path", args["file_path"]);
|
||||
if (name === "read_file") {
|
||||
push("offset", args["offset"]);
|
||||
push("limit", args["limit"]);
|
||||
} else if (name === "edit_file") {
|
||||
push("old_string", args["old_string"]);
|
||||
push("new_string", args["new_string"]);
|
||||
if (args["replace_all"] === true) push("replace_all", true);
|
||||
} else if (name === "write_file") {
|
||||
push("content", args["content"]);
|
||||
}
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
/** Extracts the current value of a string field from a possibly-incomplete JSON object string (a simplified version, good enough for preview purposes). */
|
||||
function extractStringField(argsJson: string, field: string): string | null {
|
||||
const key = `"${field}"`;
|
||||
@@ -94,6 +188,7 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
|
||||
}, [hasNestedPending]);
|
||||
|
||||
const preview = previewArguments(item.name, item.argumentsText);
|
||||
const subtitle = headerSubtitle(item.name, item.argumentsText);
|
||||
// Executing = the call has finished streaming, output hasn't arrived yet, and it's not waiting on approval (approval wait time doesn't count toward execution).
|
||||
const executing = item.callComplete && !item.outputComplete && !pending;
|
||||
// Argument-generation segment (settled): the live execution timer accumulates on top of this as a baseline, so the displayed duration doesn't shrink back once output arrives.
|
||||
@@ -135,6 +230,12 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
|
||||
<span className="shrink-0 truncate font-mono text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{item.name || S.chat.unknownTool}
|
||||
</span>
|
||||
{/* Human-readable subtitle: the model-written call description (command/subagent tools) or the file path (file tools). */}
|
||||
{subtitle && (
|
||||
<span className="min-w-0 shrink truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{item.durationMs !== undefined ? (
|
||||
humanizeDuration(item.durationMs)
|
||||
@@ -189,6 +290,17 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
|
||||
{preview}
|
||||
</span>
|
||||
</div>
|
||||
{/* File tools: the one-line preview shows only the (shortened) path, but the user is
|
||||
approving a concrete rewrite — render the decoded payload (old_string/new_string/
|
||||
content) in the scrollable expanded style while pending. */}
|
||||
{(() => {
|
||||
const payload = pendingFilePayload(item.name, item.argumentsText);
|
||||
return payload !== null ? (
|
||||
<pre className="mb-2 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-md bg-white/70 px-2 py-1.5 text-xs leading-5 text-gray-700 dark:bg-gray-950/40 dark:text-gray-300">
|
||||
{payload}
|
||||
</pre>
|
||||
) : null;
|
||||
})()}
|
||||
<ApprovalButtons
|
||||
onDecide={(decision) => ctx.onApprove(item.toolCallId, decision, ctx.origin)}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* tree" navigation command (driven by clicking a file chip inside a message).
|
||||
*
|
||||
* The panel's content is just WorkspaceBrowser's single directory-tree view — the protocol has no
|
||||
* structured file-write signal at all (the only built-in tool is the opaque exec_command shell),
|
||||
* structured file-write signal at all (file writes can happen inside the opaque exec_command shell),
|
||||
* so there's no "Agent output" list to maintain; a file clicked in a message jumps straight to
|
||||
* locating it in the tree.
|
||||
*
|
||||
|
||||
@@ -254,6 +254,9 @@ export const en: Strings = {
|
||||
"Can modify things. Needs manual confirmation when the approval mode is read-only.",
|
||||
toolTimeout: "timeoutMs",
|
||||
toolMaxOutput: "maxOutputLength",
|
||||
toolCallDescription: "call_description",
|
||||
callDescriptionHint:
|
||||
"call_description: when on (the default), the tool's schema keeps the optional description argument — a model-written sentence about each call, shown to the user while it runs; when off, the argument is filtered out of the schema at assembly. Only tools whose parameters declare a description property can be toggled.",
|
||||
mcpServers: "MCP Servers (read-only)",
|
||||
defaultValue: "(default)",
|
||||
deleteAgent: "Delete agent",
|
||||
|
||||
@@ -232,6 +232,9 @@ export const zh = {
|
||||
permissionReadWriteDescription: "可修改。审批模式为 read-only 时需人工确认。",
|
||||
toolTimeout: "timeoutMs",
|
||||
toolMaxOutput: "maxOutputLength",
|
||||
toolCallDescription: "call_description",
|
||||
callDescriptionHint:
|
||||
"call_description:开启(缺省)时该工具的 schema 保留可选的 description 参数——模型为每次调用写一句说明,运行期间展示给用户;关闭则装配时从 schema 滤除该参数。仅参数中定义了 description 属性的工具可切换。",
|
||||
mcpServers: "MCP Server(只读)",
|
||||
defaultValue: "(缺省)",
|
||||
/** Reset link next to the runtime dropdowns: rewinds the local pick back to "not overridden" (the menus offer no inherit row). */
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* tool-call-card.tsx preview helpers: previewArguments keeps the real arguments (what heads
|
||||
* the approval row), headerSubtitle surfaces the model-written `description` argument for
|
||||
* the command/subagent tools and the shortened file path for the file tools, and
|
||||
* pendingFilePayload decodes the file-tool arguments so a pending approval shows the actual
|
||||
* rewrite. All must tolerate incomplete mid-stream JSON.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
headerSubtitle,
|
||||
pendingFilePayload,
|
||||
previewArguments,
|
||||
shortenPath,
|
||||
} from "../src/features/chat/tool-call-card";
|
||||
|
||||
describe("previewArguments", () => {
|
||||
it("renders exec_command as $ <cmd>", () => {
|
||||
expect(previewArguments("exec_command", '{"cmd":"ls -la"}')).toBe("$ ls -la");
|
||||
// Mid-stream (incomplete JSON) still extracts the cmd prefix.
|
||||
expect(previewArguments("exec_command", '{"cmd":"echo h')).toBe("$ echo h");
|
||||
});
|
||||
|
||||
it("renders the file tools by their shortened file_path", () => {
|
||||
expect(previewArguments("read_file", '{"file_path":"src/app.py","offset":3}')).toBe(
|
||||
"src/app.py",
|
||||
);
|
||||
expect(
|
||||
previewArguments("edit_file", '{"file_path":"a.txt","old_string":"x","new_string":"y"}'),
|
||||
).toBe("a.txt");
|
||||
expect(previewArguments("write_file", '{"file_path":"packages/core/src/state/out.ts"}')).toBe(
|
||||
"…/state/out.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the real command even when a description argument is present (approval fidelity)", () => {
|
||||
expect(
|
||||
previewArguments("exec_command", '{"cmd":"rm -rf build","description":"Clean caches"}'),
|
||||
).toBe("$ rm -rf build");
|
||||
});
|
||||
|
||||
it("falls back to the single-line raw arguments for other tools", () => {
|
||||
expect(previewArguments("search", '{"q": "a\n b"}')).toBe('{"q": "a b"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortenPath", () => {
|
||||
it("keeps at most one parent directory plus the filename", () => {
|
||||
expect(shortenPath("file.ts")).toBe("file.ts");
|
||||
expect(shortenPath("src/file.ts")).toBe("src/file.ts");
|
||||
expect(shortenPath("/etc/hosts")).toBe("/etc/hosts");
|
||||
expect(shortenPath("packages/core/src/state/default-config.ts")).toBe(
|
||||
"…/state/default-config.ts",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("headerSubtitle", () => {
|
||||
it("shows the description for the command/subagent tools when present", () => {
|
||||
expect(
|
||||
headerSubtitle("exec_command", '{"cmd":"ls","description":"List workspace files"}'),
|
||||
).toBe("List workspace files");
|
||||
expect(
|
||||
headerSubtitle("run_subagent", '{"prompt":"p","description":"Delegating research"}'),
|
||||
).toBe("Delegating research");
|
||||
expect(
|
||||
headerSubtitle("input_command", '{"process_id":"proc-1","description":"Poll the build"}'),
|
||||
).toBe("Poll the build");
|
||||
expect(
|
||||
headerSubtitle("input_subagent", '{"subagent_id":"s-1","description":"Follow up"}'),
|
||||
).toBe("Follow up");
|
||||
});
|
||||
|
||||
it("is null when the description is absent or empty (call_description off = the model never sends it)", () => {
|
||||
expect(headerSubtitle("exec_command", '{"cmd":"ls"}')).toBeNull();
|
||||
expect(headerSubtitle("exec_command", '{"cmd":"ls","description":""}')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the shortened file path for the file tools and nothing for others", () => {
|
||||
expect(headerSubtitle("read_file", '{"file_path":"src/app.py"}')).toBe("src/app.py");
|
||||
expect(headerSubtitle("edit_file", '{"file_path":"packages/web/src/a.tsx"}')).toBe(
|
||||
"…/src/a.tsx",
|
||||
);
|
||||
expect(headerSubtitle("write_file", '{"file_path":"out.md"}')).toBe("out.md");
|
||||
expect(headerSubtitle("search", '{"q":"x"}')).toBeNull();
|
||||
});
|
||||
|
||||
it("folds a multi-line description to one line", () => {
|
||||
expect(headerSubtitle("exec_command", '{"cmd":"ls","description":"one\\ntwo"}')).toBe(
|
||||
"one two",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pendingFilePayload", () => {
|
||||
it("decodes the edit_file rewrite so the approval block shows it", () => {
|
||||
const payload = pendingFilePayload(
|
||||
"edit_file",
|
||||
JSON.stringify({ file_path: "src/app.py", old_string: "a\nb", new_string: "a\nc" }),
|
||||
);
|
||||
expect(payload).toBe("file_path: src/app.py\nold_string:\na\nb\nnew_string:\na\nc");
|
||||
});
|
||||
|
||||
it("decodes write_file content and read_file window arguments", () => {
|
||||
expect(pendingFilePayload("write_file", '{"file_path":"out.md","content":"hello"}')).toBe(
|
||||
"file_path: out.md\ncontent: hello",
|
||||
);
|
||||
expect(pendingFilePayload("read_file", '{"file_path":"a.txt","offset":3,"limit":5}')).toBe(
|
||||
"file_path: a.txt\noffset: 3\nlimit: 5",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for non-file tools and incomplete JSON", () => {
|
||||
expect(pendingFilePayload("exec_command", '{"cmd":"ls"}')).toBeNull();
|
||||
expect(pendingFilePayload("edit_file", '{"file_path":"a.txt","old_str')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user