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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user