feat(agent-core-v2): add built-in file and shell tools

- add fileTools domain (Read/Write/Edit/Grep/Glob)
- add shellTools domain (Bash)
- wire registration through FileToolsService and ShellToolsService
- add readLines to IAgentFileSystem for the Read tool
This commit is contained in:
haozhe.yang 2026-06-30 13:49:37 +08:00
parent 69084ab683
commit 78320259a5
34 changed files with 7210 additions and 0 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core-v2": patch
"@moonshot-ai/server-v2": patch
---
Reorganize the agent execution environment into separate filesystem, process and tool domains.

View file

@ -102,6 +102,8 @@ const DOMAIN_LAYER = new Map([
['microCompaction', 4],
['loop', 4],
['media', 4],
['fileTools', 4],
['shellTools', 4],
['llmRequester', 4],
['llmRequestLog', 4],
['externalHooks', 4],
@ -192,6 +194,7 @@ const ALLOWED_EXCEPTIONS = new Set([
'replayBuilder>background',
'replayBuilder>rpc',
'replayBuilder>session-metadata',
'shellTools>background',
'skill>contextMemory',
'skill>prompt',
'swarm>subagentHost',

View file

@ -22,6 +22,10 @@ export interface IAgentFileSystem {
readText(path: string): Promise<string>;
writeText(path: string, data: string): Promise<void>;
readBytes(path: string, n?: number): Promise<Uint8Array>;
readLines(
path: string,
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
): AsyncGenerator<string>;
writeBytes(path: string, data: Uint8Array): Promise<void>;
stat(path: string): Promise<AgentFileStat>;
readdir(path: string): Promise<readonly string[]>;

View file

@ -49,6 +49,13 @@ export class AgentFileSystem implements IAgentFileSystem {
return this.kaos.backend.readBytes(path, n);
}
readLines(
path: string,
options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' },
): AsyncGenerator<string> {
return this.kaos.backend.readLines(path, options);
}
writeBytes(path: string, data: Uint8Array): Promise<void> {
return this.kaos.backend.writeBytes(path, Buffer.from(data)).then(() => undefined);
}

View file

@ -0,0 +1,16 @@
/**
* `fileTools` domain (L4) built-in file tool registration contract.
*
* `IFileToolsService` is a marker: its implementation registers the built-in
* file tools (Read / Write / Edit / Grep / Glob) into the agent `IToolRegistry`
* on construction. Bound at Agent scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IFileToolsService {
readonly _serviceBrand: undefined;
}
export const IFileToolsService: ServiceIdentifier<IFileToolsService> =
createDecorator<IFileToolsService>('fileToolsService');

View file

@ -0,0 +1,53 @@
/**
* `fileTools` domain (L4) `IFileToolsService` implementation.
*
* Registers the built-in file tools (Read / Write / Edit / Grep / Glob) into
* the agent `IToolRegistry` on construction, wiring each to the session
* `IAgentFileSystem` (file IO), `IFsService` (workspace search/grep), `IKaos`
* (path semantics) and the session workspace. Bound at Agent scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import { IAgentFileSystem, IFsService } from '#/agentFs';
import { IKaos } from '#/kaos';
import { IToolRegistry } from '#/toolRegistry';
import { IWorkspaceContext } from '#/workspaceContext';
import { IFileToolsService } from './fileTools';
import { EditTool } from './tools/edit';
import { GlobTool } from './tools/glob';
import { GrepTool } from './tools/grep';
import { ReadTool } from './tools/read';
import { WriteTool } from './tools/write';
export class FileToolsService implements IFileToolsService {
declare readonly _serviceBrand: undefined;
constructor(
@IToolRegistry toolRegistry: IToolRegistry,
@IAgentFileSystem fs: IAgentFileSystem,
@IKaos kaos: IKaos,
@IWorkspaceContext workspace: IWorkspaceContext,
@IFsService fsService: IFsService,
) {
const workspaceConfig: WorkspaceConfig = {
workspaceDir: workspace.workDir,
additionalDirs: workspace.additionalDirs,
};
toolRegistry.register(new ReadTool(fs, kaos, workspaceConfig));
toolRegistry.register(new WriteTool(fs, kaos, workspaceConfig));
toolRegistry.register(new EditTool(fs, kaos, workspaceConfig));
toolRegistry.register(new GrepTool(fsService, kaos, workspaceConfig));
toolRegistry.register(new GlobTool(fs, kaos, workspaceConfig));
}
}
registerScopedService(
LifecycleScope.Agent,
IFileToolsService,
FileToolsService,
InstantiationType.Delayed,
'fileTools',
);

View file

@ -0,0 +1,15 @@
/**
* `fileTools` domain barrel re-exports the built-in file tools (Read / Write
* / Edit / Grep / Glob), the shared line-ending helpers, and the
* `IFileToolsService` registration contract + service. Importing this barrel
* registers the `IFileToolsService` binding into the scope registry.
*/
export * from './fileTools';
export * from './fileToolsService';
export * from './tools/edit';
export * from './tools/glob';
export * from './tools/grep';
export * from './tools/line-endings';
export * from './tools/read';
export * from './tools/write';

View file

@ -0,0 +1,13 @@
Perform exact replacements in existing files.
- Edit is mandatory for every incremental change, especially small edits. DO NOT use Write or Bash `sed`.
- Read the target file before every Edit. DO NOT call Edit from memory, stale context, or a guessed `old_string`.
- Take `old_string` and `new_string` from the Read output view.
- Drop the line-number prefix and tab; match only file content.
- `old_string` must be unique unless `replace_all` is set.
- If `old_string` is ambiguous, add surrounding context. Use `replace_all` only when every occurrence should change.
- Multiple Edit calls may run in one response only when they do not target the same file.
- DO NOT issue consecutive Edit calls on the same file. A previous Edit can invalidate a later Edit's `old_string`, causing `old_string not found`. Read the file again before the next Edit.
- A write lock serializes same-file edits in response order, but serialization does not make stale `old_string` valid.
- For pure CRLF files, Read shows LF; use LF in `old_string` and `new_string`, and Edit writes CRLF back.
- For mixed endings or lone carriage returns, Read shows carriage returns as \r; include actual \r escapes in those positions.

View file

@ -0,0 +1,187 @@
/**
* `fileTools` domain EditTool, exact string replacement in a text file.
*
* Replaces the first occurrence of `old_string` with `new_string` by default.
* When `replace_all` is true, replaces every occurrence. Errors when
* `old_string` is not found or not unique (when `replace_all` is false).
*
* Line endings are preserved: the raw file is normalized to the LF "model
* view" for matching (so pure CRLF files can be edited with LF `old_string`),
* then re-materialized to the original line-ending style on write pure CRLF
* files round-trip to CRLF, mixed/lone-CR files stay on the exact raw path.
*
* Path access policy is resolved before any filesystem I/O. Edit access flows
* through the `agentFs` domain; path semantics (home expansion, path class)
* come from the `kaos` domain.
*
* Ported from v1 (`packages/agent-core/src/tools/builtin/file/edit.ts`): the
* `kaos.readText` / `kaos.writeText` calls become `fs.readText` /
* `fs.writeText` against `IAgentFileSystem`, and `kaos.pathClass()` /
* `kaos.gethome()` come from `IKaos`.
*/
import { z } from 'zod';
import { resolvePathAccessPath } from '#/_base/tools/policies/path-access';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { literalRulePattern, matchesPathRuleSubject } from '#/_base/tools/support/rule-match';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import { renderPrompt } from '#/_base/utils/render-prompt';
import { IAgentFileSystem } from '#/agentFs';
import { IKaos } from '#/kaos';
import { ToolAccesses } from '#/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/tool';
import editDescriptionTemplate from './edit.md?raw';
import { materializeModelText, toModelTextView } from './line-endings';
// `old_string` must be non-empty: the non-replace_all branch walks
// occurrences with `content.indexOf("", pos)`, which would loop forever
// on an empty search string.
export const EditInputSchema = z.object({
path: z
.string()
.describe(
'Path to the text file to edit. Relative paths resolve against the working directory; a path outside the working directory must be absolute.',
),
old_string: z
.string()
.min(1)
.describe(
'Exact content to replace from the Read output view, without the line-number prefix. Use LF for pure CRLF files; use actual \\r escapes where Read shows \\r.',
),
new_string: z
.string()
.describe(
'Replacement text in the same Read output view. LF is written back as CRLF only for pure CRLF files.',
),
replace_all: z
.boolean()
.optional()
.describe('Set true only when every occurrence of old_string should be replaced.'),
});
export type EditInput = z.infer<typeof EditInputSchema>;
const EDIT_DESCRIPTION = renderPrompt(editDescriptionTemplate, {});
function replaceOnceLiteral(content: string, oldString: string, newString: string): string {
const index = content.indexOf(oldString);
if (index === -1) return content;
return content.slice(0, index) + newString + content.slice(index + oldString.length);
}
export class EditTool implements BuiltinTool<EditInput> {
readonly name = 'Edit' as const;
readonly description = EDIT_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(EditInputSchema);
constructor(
private readonly fs: IAgentFileSystem,
private readonly kaos: IKaos,
private readonly workspace: WorkspaceConfig,
) {}
resolveExecution(args: EditInput): ToolExecution {
const path = resolvePathAccessPath(args.path, {
kaos: this.kaos,
workspace: this.workspace,
operation: 'write',
});
return {
accesses: ToolAccesses.writeFile(path),
description: `Editing ${args.path}`,
display: {
kind: 'file_io',
operation: 'edit',
path,
before: args.old_string,
after: args.new_string,
},
approvalRule: literalRulePattern(this.name, path),
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
pathClass: this.kaos.pathClass(),
homeDir: this.kaos.gethome(),
}),
execute: () => this.execution(args, path),
};
}
private async execution(args: EditInput, safePath: string): Promise<ExecutableToolResult> {
if (args.old_string === args.new_string) {
return {
isError: true,
output: 'No changes to make: old_string and new_string are exactly the same.',
};
}
try {
const raw = await this.fs.readText(safePath);
const modelView = toModelTextView(raw);
const content = modelView.text;
const replaceAll = args.replace_all ?? false;
if (!replaceAll) {
let count = 0;
let pos = 0;
while (pos < content.length) {
const idx = content.indexOf(args.old_string, pos);
if (idx === -1) break;
count++;
pos = idx + args.old_string.length;
}
if (count === 0) {
return {
isError: true,
output: `old_string not found in ${args.path}, the file contents may be out of date. Please use the Read Tool to reload the content.
`,
};
}
if (count > 1) {
return {
isError: true,
output:
`old_string is not unique in ${args.path} (found ${String(count)} occurrences). ` +
'To replace every occurrence, set replace_all=true. To replace only one occurrence, include more surrounding context in old_string.',
};
}
const newContent = replaceOnceLiteral(content, args.old_string, args.new_string);
await this.fs.writeText(
safePath,
materializeModelText(newContent, modelView.lineEndingStyle),
);
return { output: `Replaced 1 occurrence in ${args.path}` };
}
const parts = content.split(args.old_string);
const replacementCount = parts.length - 1;
if (replacementCount === 0) {
return {
isError: true,
output: `old_string not found in ${args.path}, the file contents may be out of date. Please use the Read Tool to reload the content.
`,
};
}
const newContent = parts.join(args.new_string);
await this.fs.writeText(
safePath,
materializeModelText(newContent, modelView.lineEndingStyle),
);
return { output: `Replaced ${String(replacementCount)} occurrences in ${args.path}` };
} catch (error) {
const code = (error as { code?: unknown } | null)?.code;
if (code === 'EISDIR') {
return { isError: true, output: `${args.path} is not a file.` };
}
return {
isError: true,
output: error instanceof Error ? error.message : String(error),
};
}
}
}

View file

@ -0,0 +1,15 @@
Find files (and optionally directories) by glob pattern, sorted by modification time (most recent first).
Good patterns:
- `*.ts` — files in the current directory matching an extension
- `src/**/*.ts` — recursive walk with a subdirectory anchor and extension
- `**/*.py` — recursive walk from the search root for an extension
- `*.{ts,tsx}` — brace expansion is supported; expanded into `*.ts` and `*.tsx` before walking
- `{src,test}/**/*.ts` — cartesian brace expansion is supported too
Results are capped at the first 100 matching paths (walk order, not global modification-time order). If a search would return more, a truncation marker is appended with the count of matches seen so far. Refine the pattern (extension, subdirectory) when 100 is not enough, or call again with a narrower anchor.
Large-directory caveat — avoid recursing into dependency / build output even with an anchor:
- `node_modules/**/*.js`, `.venv/**/*.py`, `__pycache__/**`, `target/**` all match technically but
typically produce thousands of results that truncate at the match cap and waste the caller context.
Prefer specific subpaths like `node_modules/react/src/**/*.js`.

View file

@ -0,0 +1,416 @@
/**
* `fileTools` domain GlobTool, file pattern matching.
*
* Finds files (and optionally directories) matching a glob pattern and returns
* them as a newline-separated path list, capped at {@link MAX_MATCHES}. Brace
* expansion (`*.{ts,tsx}`, `{src,test}/**`) is fanned out at this layer into
* sub-patterns before each is handed to the filesystem, because the kaos
* walker treats `{` / `}` as literals.
*
* Ported from v1 (`packages/agent-core/src/tools/builtin/file/glob.ts`) onto
* the v2 domains:
* - Search root: v1 `kaos.glob(root, pattern)` (async generator) maps to
* `fs.withCwd(root).glob(pattern)`, since v2 `IAgentFileSystem.glob`
* searches from `fs.cwd` and returns a collected `Promise<readonly
* string[]>`. kaos yields absolute paths, so no further joining is needed.
* - Path safety / home expansion / path class: `resolvePathAccessPath` over
* the `kaos` domain, identical to Read/Write/Edit.
* - The v1 `iterdir` existence pre-check becomes `fs.readdir(root)`: it
* triggers the same directory read that the glob walker would do, so
* ENOENT / ENOTDIR surface as "does not exist" / "is not a directory"
* instead of a misleading "No matches found".
*
* Documented deviation from v1: results are no longer sorted by modification
* time. v2 `IAgentFileSystem.stat` exposes `{ isFile, isDirectory, size }`
* only it carries no mtime so matches are returned in walk order (the
* order `fs.glob` yields them, grouped by expanded sub-pattern). The cap,
* dedup, truncation markers, and `include_dirs` filtering are unchanged.
*
* Output convention: paths shown to the LLM are relativized to the search
* base only when that base sits inside the primary workspace. External roots
* stay absolute so downstream Read/Edit calls keep targeting the same file.
*/
import { normalize } from 'pathe';
import { z } from 'zod';
import { IAgentFileSystem } from '#/agentFs';
import { IKaos } from '#/kaos';
import { ToolAccesses } from '#/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/tool';
import {
isWithinDirectory,
resolvePathAccessPath,
type PathClass,
} from '#/_base/tools/policies/path-access';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '#/_base/tools/support/rule-match';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import globDescription from './glob.md?raw';
export const GlobInputSchema = z.object({
pattern: z.string().describe('Glob pattern to match files/directories.'),
path: z
.string()
.optional()
.describe(
'Absolute path to the directory to search in. Defaults to the current working directory.',
),
include_dirs: z
.boolean()
.default(true)
.optional()
.describe(
'Whether to include directories in results. Defaults to true. Set false to return only files.',
),
});
export type GlobInput = z.infer<typeof GlobInputSchema>;
export const MAX_MATCHES = 100;
/**
* Hard upper bound on the number of sub-patterns a single brace expansion
* is allowed to produce. Generous enough for the common LLM patterns
* (`*.{ts,tsx,js,jsx,mjs,cjs}` etc.) while still keeping pathological
* cartesian inputs like `{a,b}{c,d}{e,f}{g,h}{i,j}{k,l}` (= 64) from
* fanning out unboundedly. Beyond this we fall through with the original
* pattern unexpanded kaos would then treat the braces as literals and
* match zero, which is the right "obvious failure" signal for a pattern
* the model probably did not mean.
*/
const MAX_BRACE_EXPANSIONS = 64;
/**
* Path-shape hint appended to the tool description only on a Windows
* (`win32` path class) backend. The `path` argument accepts both native
* Windows paths and POSIX-style paths, but matched paths come back in
* Windows backslash form a command run through Bash must convert them
* to forward slashes first. Injected conditionally so non-Windows
* sessions are not shown a hint that does not apply to them.
*/
export const WINDOWS_PATH_HINT =
'\n\nWindows note: the `path` argument accepts both Windows paths ' +
'(e.g. `C:\\Users\\foo`) and POSIX-style paths (e.g. `/c/Users/foo`). Matched paths are ' +
'returned in Windows backslash form; convert them to forward slashes before ' +
'using them in a Bash command.';
/**
* Tool-level description shown to the LLM at tool declaration time.
* Tells the model before any round-trip which patterns are accepted,
* how brace expansion is handled, and which directories are too large to
* recurse into. On a Windows backend the description also carries
* `WINDOWS_PATH_HINT` (path-shape guidance).
*/
export class GlobTool implements BuiltinTool<GlobInput> {
readonly name = 'Glob' as const;
readonly description: string;
readonly parameters: Record<string, unknown> = toInputJsonSchema(GlobInputSchema);
constructor(
private readonly fs: IAgentFileSystem,
private readonly kaos: IKaos,
private readonly workspace: WorkspaceConfig,
) {
this.description =
this.kaos.pathClass() === 'win32' ? globDescription + WINDOWS_PATH_HINT : globDescription;
}
resolveExecution(args: GlobInput): ToolExecution {
let path: string | undefined;
if (args.path !== undefined) {
path = resolvePathAccessPath(args.path, {
kaos: this.kaos,
workspace: this.workspace,
operation: 'search',
policy: { guardMode: 'absolute-outside-allowed', checkSensitive: false },
});
}
const searchRoots = [path ?? this.workspace.workspaceDir];
const detailParts: string[] = [`pattern: ${args.pattern}`];
if (args.path !== undefined) {
detailParts.push(`path: ${args.path}`);
}
if (args.include_dirs === false) {
detailParts.push('include_dirs: false');
}
return {
accesses: ToolAccesses.searchTree(searchRoots[0]!),
description: `Searching ${args.pattern}`,
display: {
kind: 'file_io',
operation: 'glob',
path: searchRoots[0]!,
detail: detailParts.join(', '),
},
approvalRule: literalRulePattern(this.name, args.pattern),
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.pattern),
execute: () => this.execution(args, searchRoots),
};
}
private async execution(
args: GlobInput,
searchRoots: readonly string[],
): Promise<ExecutableToolResult> {
const subPatterns = expandBraces(args.pattern).map((p) =>
hasGlobEscape(p) ? p : normalize(p),
);
// Default true. When false, directories yielded by the filesystem are
// filtered out using the same stat that v1 used for the mtime sort
// (no second stat per path).
const includeDirs = args.include_dirs ?? true;
// `fs.glob` silently returns empty for missing or non-directory roots
// (its kaos walker catches the readdir failure and exits without
// yielding). Without this pre-check, a Glob against a missing path
// would report "No matches found" instead of "does not exist", and the
// model would not realize the search root itself was wrong. readdir is
// the right signal: it triggers the same directory read that the glob
// walker would do, so ENOENT / ENOTDIR surface here for the realistic
// backends before the walker is invoked. Any other failure (e.g. an
// unmocked test backend that throws "not implemented") falls through
// silently so the existing glob path still runs.
for (const root of searchRoots) {
try {
await this.fs.readdir(root);
} catch (error) {
if (error !== null && typeof error === 'object' && 'code' in error) {
const code = (error as { code?: string }).code;
if (code === 'ENOENT') {
return { isError: true, output: `${root} does not exist` };
}
if (code === 'ENOTDIR') {
return { isError: true, output: `${root} is not a directory` };
}
}
// Unknown failure (including unmocked test backends): fall
// through and let fs.glob run; it will either yield results or
// its own catch path will surface the error.
}
}
try {
// Two counters, two jobs:
// - `entries.length` caps the *unique* paths we return, so a
// truncation warning only fires after MAX_MATCHES real hits.
// - `yielded` counts every path the glob results emit, including
// duplicates across sub-patterns. Secondary safety belt that
// terminates the walk even if a backend ever re-yields the same
// real file. With brace expansion the legitimate yield volume
// scales with the number of sub-patterns (each is its own
// walk), so the cap scales too.
const seen = new Set<string>();
const entries: string[] = [];
const YIELD_SAFETY_CAP = MAX_MATCHES * 2 * subPatterns.length;
let yielded = 0;
let truncated = false;
outer: for (const root of searchRoots) {
const searchFs = this.fs.withCwd(root);
for (const subPattern of subPatterns) {
const matches = await searchFs.glob(subPattern);
for (const filePath of matches) {
yielded++;
if (yielded >= YIELD_SAFETY_CAP) {
truncated = true;
break outer;
}
if (seen.has(filePath)) continue;
if (entries.length >= MAX_MATCHES) {
truncated = true;
break outer;
}
seen.add(filePath);
let isDir = false;
try {
const st = await this.fs.stat(filePath);
isDir = st.isDirectory;
} catch {
// stat failure — assume file so it still surfaces.
}
// Apply include_dirs *after* marking seen so a filtered dir
// doesn't re-enter via a later duplicate yield, and *before*
// pushing to entries so MAX_MATCHES continues to cap output
// (not pre-filter) size.
if (!includeDirs && isDir) continue;
entries.push(filePath);
}
}
}
const paths = entries;
// Content shown to the LLM uses paths relative to the search base
// to save tokens, but only for the primary workspace. Relative paths
// are later resolved against workspaceDir, so additionalDir matches
// must stay absolute to keep follow-up Read/Edit calls on the same file.
const pathClass = this.kaos.pathClass();
const relBase = searchRoots[0] ?? this.workspace.workspaceDir;
const shouldRelativize = isWithinDirectory(relBase, this.workspace.workspaceDir, pathClass);
const displayLines = paths.map((p) =>
shouldRelativize ? relativizeIfUnder(p, relBase, pathClass) : p,
);
if (entries.length === 0 && !truncated) {
return { output: 'No matches found' };
}
const lines: string[] = [];
if (truncated) {
lines.push(
`[Truncated at ${String(MAX_MATCHES)} matches — ${String(seen.size)} matched so far, use a more specific pattern]`,
);
lines.push(`Only the first ${String(MAX_MATCHES)} matches are returned.`);
}
lines.push(...displayLines);
if (!truncated && entries.length === MAX_MATCHES) {
lines.push(`Found ${String(entries.length)} matches`);
}
return { output: lines.join('\n') };
} catch (error) {
if (error !== null && typeof error === 'object' && 'code' in error) {
const code = (error as { code?: string }).code;
const path = searchRoots[0] ?? this.workspace.workspaceDir;
if (code === 'ENOENT') {
return { isError: true, output: `${path} does not exist` };
}
if (code === 'ENOTDIR') {
return { isError: true, output: `${path} is not a directory` };
}
}
return { isError: true, output: error instanceof Error ? error.message : String(error) };
}
}
}
/**
* If `candidate` is under `base`, return the portion after `base/`.
* Otherwise return `candidate` unchanged (absolute). Both arguments
* should be canonical absolute paths.
*/
function relativizeIfUnder(candidate: string, base: string, pathClass: PathClass): string {
const normCandidate = normalize(candidate);
const normBase = normalize(base);
const comparableCandidate = pathClass === 'win32' ? normCandidate.toLowerCase() : normCandidate;
const comparableBase = pathClass === 'win32' ? normBase.toLowerCase() : normBase;
if (comparableCandidate === comparableBase) return '.';
const prefix = comparableBase.endsWith('/') ? comparableBase : comparableBase + '/';
if (comparableCandidate.startsWith(prefix)) {
return normCandidate.slice(prefix.length);
}
return normCandidate;
}
/**
* Expand brace alternations (`{a,b,c}`, `{src,test}/**`) into a flat list
* of sub-patterns. Recursive handles cartesian products (`{a,b}/{c,d}.ts`
* 4 patterns) and one or more levels of nesting (`{a,{b,c}}.ts`).
*
* Falls through with the original pattern as a single-element list when:
* - the pattern contains no `{...}` group at all;
* - the pattern contains `{...}` groups but none have a top-level comma
* (e.g. `{abc}` bash treats those as literal);
* - braces are unbalanced (a stray `{` with no matching `}`, etc.);
* - expansion would produce more than `MAX_BRACE_EXPANSIONS` patterns
* pathological cartesian inputs (`{a,b}{c,d}{e,f}{g,h}{i,j}{k,l,m}`
* 192) bail out rather than fan out unboundedly.
*
* Backslash-escaped braces (`\{`, `\}`) are treated as literals and skip
* the structural recognition so a user can opt out of expansion.
*/
export function expandBraces(pattern: string): string[] {
const out: string[] = [];
if (!expandInto(pattern, out, MAX_BRACE_EXPANSIONS)) {
// Cap exceeded somewhere down the recursion — discard partial
// fan-out and report the original. Letting half the alternatives
// through would be a silent footgun.
return [pattern];
}
return out;
}
function hasGlobEscape(pattern: string): boolean {
return /\\[{}[\]*?,]/.test(pattern);
}
function expandInto(pattern: string, out: string[], cap: number): boolean {
// Find the first balanced `{...}` group containing a top-level comma.
let depth = 0;
let start = -1;
for (let i = 0; i < pattern.length; i++) {
const ch = pattern[i];
if (ch === '\\' && i + 1 < pattern.length) {
i++;
continue;
}
if (ch === '{') {
if (depth === 0) start = i;
depth++;
continue;
}
if (ch === '}') {
if (depth === 0) {
// Stray `}` — treat the whole pattern as literal.
return pushLiteral(pattern, out, cap);
}
depth--;
if (depth === 0 && start !== -1) {
const inner = pattern.slice(start + 1, i);
const parts = splitTopLevelCommas(inner);
if (parts.length < 2) {
// No commas at the top level → literal group; skip past it
// and keep scanning for a real alternation further right.
start = -1;
continue;
}
const prefix = pattern.slice(0, start);
const suffix = pattern.slice(i + 1);
for (const part of parts) {
if (out.length >= cap) return false;
if (!expandInto(prefix + part + suffix, out, cap)) return false;
}
return true;
}
}
}
if (depth !== 0) {
// Unbalanced `{` — treat the whole pattern as literal.
return pushLiteral(pattern, out, cap);
}
return pushLiteral(pattern, out, cap);
}
function pushLiteral(pattern: string, out: string[], cap: number): boolean {
if (out.length >= cap) return false;
out.push(pattern);
return true;
}
/**
* Split on commas that sit at brace depth zero. Used by `expandBraces`
* to slice a `{a,{b,c},d}` group into `["a", "{b,c}", "d"]` rather than
* `["a", "{b", "c}", "d"]`.
*/
function splitTopLevelCommas(s: string): string[] {
const parts: string[] = [];
let depth = 0;
let last = 0;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '\\' && i + 1 < s.length) {
i++;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') depth--;
else if (ch === ',' && depth === 0) {
parts.push(s.slice(last, i));
last = i + 1;
}
}
parts.push(s.slice(last));
return parts;
}

View file

@ -0,0 +1,9 @@
Search file contents using regular expressions (powered by ripgrep).
Use Grep when the task is to find unknown content or unknown file locations. Do not use shell `grep` or `rg` directly; this tool applies workspace path policy, output limits, and sensitive-file filtering.
ALWAYS use Grep tool instead of running `grep` or `rg` from a shell — direct shell calls bypass workspace policy, output limits, and sensitive-file filtering.
If you already know a concrete file path and need to inspect its contents, use Read directly instead.
Write patterns in ripgrep regex syntax, which differs from POSIX `grep` syntax. For example, braces are special, so escape them as `\{` to match a literal `{`.
Hidden files (dotfiles such as `.gitlab-ci.yml` or `.eslintrc.json`) are searched by default. To also search files excluded by `.gitignore` (such as `node_modules` or build outputs), set `include_ignored` to `true`. Sensitive files (such as `.env`) are always skipped for safety, even when `include_ignored` is `true`.

View file

@ -0,0 +1,413 @@
/**
* `fileTools` domain GrepTool, the model's content search tool.
*
* Searches file contents with ripgrep-style regular expressions, delegating
* the actual scan to the `agentFs` domain's `IFsService.grep` (which is
* workspace-confined, `rg`-backed when available, and falls back to a Node
* walker otherwise, honoring gitignore and glob filters). The tool maps the
* model-facing input args onto an `FsGrepRequest`, then renders the
* `FsGrepResponse` in the v1 Grep output shape (`files_with_matches` /
* `content` / `count_matches`) with `offset` / `head_limit` pagination and a
* sensitive-file post-filter.
*
* Path safety goes through the shared path access resolver used by
* Read/Write/Edit/Grep: an explicit absolute path outside the workspace is
* allowed for the access declaration, while a relative path that escapes the
* workspace is rejected. The search itself is confined to the workspace by
* `IFsService`.
*
* Ported from v1 (`packages/agent-core/src/tools/builtin/file/grep.ts`). The
* v1 tool shelled out to `rg` directly through Kaos and parsed its output;
* that work now lives in `IFsService.grep`, so this tool only maps arguments
* and renders results. A few v1 behaviors that `IFsService.grep` does not
* expose (mtime ordering of `files_with_matches`, multiline matching, and
* searching a path outside the workspace) are intentionally not replicated.
*/
import type { FsGrepMatch, FsGrepRequest, FsGrepResponse } from '@moonshot-ai/protocol';
import { z } from 'zod';
import { IFsService } from '#/agentFs';
import { ErrorCodes, isKimiError } from '#/errors';
import { IKaos } from '#/kaos';
import { ToolAccesses } from '#/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/tool';
import { resolvePathAccessPath } from '#/_base/tools/policies/path-access';
import { isSensitiveFile } from '#/_base/tools/policies/sensitive';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '#/_base/tools/support/rule-match';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import { renderPrompt } from '#/_base/utils/render-prompt';
import grepDescriptionTemplate from './grep.md?raw';
// ── Input schema ─────────────────────────────────────────────────────
export const GrepInputSchema = z.object({
pattern: z.string().describe('Regular expression to search for.'),
path: z
.string()
.optional()
.describe(
'File or directory to search. Accepts an absolute path, or a path relative to the current working directory. Omit to search the current working directory. Use Read instead when you already know a concrete file path and need its contents.',
),
glob: z.string().optional().describe('Optional glob filter passed to ripgrep.'),
type: z
.string()
.optional()
.describe(
'Optional ripgrep file type filter, such as ts or py. Prefer this over `glob` when filtering by language or file kind: it is more efficient and less error-prone than an equivalent glob pattern.',
),
output_mode: z
.enum(['content', 'files_with_matches', 'count_matches'])
.optional()
.describe(
'Shape of the result. `content` shows matching lines (honors `-A`, `-B`, `-C`, `-n`, and `head_limit`); `files_with_matches` shows only the paths of files that contain a match (honors `head_limit`); `count_matches` shows the total number of matches. Defaults to `files_with_matches`.',
),
'-i': z.boolean().optional().describe('Perform a case-insensitive search. Defaults to false.'),
'-n': z
.boolean()
.optional()
.describe(
'Prefix each matching line with its line number. Applies only when `output_mode` is `content`. Defaults to true.',
),
'-A': z
.number()
.int()
.nonnegative()
.optional()
.describe(
'Number of lines to show after each match. Applies only when `output_mode` is `content`.',
),
'-B': z
.number()
.int()
.nonnegative()
.optional()
.describe(
'Number of lines to show before each match. Applies only when `output_mode` is `content`.',
),
'-C': z
.number()
.int()
.nonnegative()
.optional()
.describe(
'Number of lines to show before and after each match. Applies only when `output_mode` is `content`; takes precedence over `-A` and `-B`.',
),
head_limit: z
.number()
.int()
.nonnegative()
.optional()
.describe(
'Limit output to the first N lines/entries after offset. Defaults to 250. Pass 0 for unlimited.',
),
offset: z
.number()
.int()
.nonnegative()
.optional()
.describe(
'Number of leading lines/entries to skip before applying `head_limit`. Use it together with `head_limit` to page through large result sets. Defaults to 0.',
),
multiline: z
.boolean()
.optional()
.describe(
'Enable multiline matching, where the pattern can span line boundaries and `.` also matches newlines. Defaults to false.',
),
include_ignored: z
.boolean()
.optional()
.describe(
'Also search files excluded by ignore files such as `.gitignore`, `.ignore`, and `.rgignore` (for example `node_modules` or build outputs). Sensitive files (such as `.env`) remain filtered out for safety. Defaults to false.',
),
});
export type GrepInput = z.infer<typeof GrepInputSchema>;
type GrepMode = 'content' | 'files_with_matches' | 'count_matches';
// ── Constants ────────────────────────────────────────────────────────
const DEFAULT_HEAD_LIMIT = 250;
// The fs layer is told not to cap its scan so the tool's own `head_limit`
// pagination is the only bound on output. These are the protocol maximums.
const FS_MAX_FILES = 10_000;
const FS_MAX_MATCHES_PER_FILE = 10_000;
const FS_MAX_TOTAL_MATCHES = 100_000;
const FS_MAX_CONTEXT_LINES = 10;
const GREP_DESCRIPTION = renderPrompt(grepDescriptionTemplate, {});
// ── Tool ─────────────────────────────────────────────────────────────
export class GrepTool implements BuiltinTool<GrepInput> {
readonly name = 'Grep' as const;
readonly description = GREP_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(GrepInputSchema);
constructor(
private readonly fs: IFsService,
private readonly kaos: IKaos,
private readonly workspace: WorkspaceConfig,
) {}
resolveExecution(args: GrepInput): ToolExecution {
let searchPath: string | undefined;
if (args.path !== undefined) {
searchPath = resolvePathAccessPath(args.path, {
kaos: this.kaos,
workspace: this.workspace,
operation: 'search',
policy: { guardMode: 'absolute-outside-allowed', checkSensitive: false },
});
}
const accessPath = searchPath ?? this.workspace.workspaceDir;
const displayPath = args.path ?? this.workspace.workspaceDir;
return {
accesses: ToolAccesses.searchTree(accessPath),
description: `Searching for '${args.pattern}' in ${displayPath}`,
display: { kind: 'file_io', operation: 'grep', path: accessPath },
approvalRule: literalRulePattern(this.name, args.pattern),
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.pattern),
execute: ({ signal }) => this.execution(args, signal),
};
}
private async execution(args: GrepInput, signal: AbortSignal): Promise<ExecutableToolResult> {
if (signal.aborted) {
return { isError: true, output: 'Aborted before search started' };
}
let response: FsGrepResponse;
try {
response = await this.fs.grep(buildGrepRequest(args));
} catch (error) {
return mapGrepError(error);
}
if (signal.aborted) {
return { isError: true, output: 'Grep aborted' };
}
return renderGrepResponse(args, response);
}
}
// ── Request mapping ──────────────────────────────────────────────────
function buildGrepRequest(args: GrepInput): FsGrepRequest {
const includeGlobs: string[] = [];
if (args.glob !== undefined) includeGlobs.push(args.glob);
if (args.type !== undefined) includeGlobs.push(`**/*.${args.type}`);
return {
pattern: args.pattern,
// The tool's `pattern` is documented as a regular expression, so always
// ask the fs layer for regex matching.
regex: true,
case_sensitive: args['-i'] !== true,
follow_gitignore: args.include_ignored !== true,
max_files: FS_MAX_FILES,
max_matches_per_file: FS_MAX_MATCHES_PER_FILE,
max_total_matches: FS_MAX_TOTAL_MATCHES,
context_lines: contextLines(args),
include_globs: includeGlobs.length > 0 ? includeGlobs : undefined,
exclude_globs: undefined,
};
}
function contextLines(args: GrepInput): number {
if (args['-C'] !== undefined) return clamp(args['-C'], 0, FS_MAX_CONTEXT_LINES);
const before = args['-B'] ?? 0;
const after = args['-A'] ?? 0;
return clamp(Math.max(before, after), 0, FS_MAX_CONTEXT_LINES);
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
// ── Error mapping ────────────────────────────────────────────────────
function mapGrepError(error: unknown): ExecutableToolResult {
if (isKimiError(error) && error.code === ErrorCodes.FS_GREP_TIMEOUT) {
return {
isError: true,
output: 'Grep timed out. Try a more specific path or pattern.',
};
}
return {
isError: true,
output: `Failed to grep: ${error instanceof Error ? error.message : String(error)}`,
};
}
// ── Response rendering ───────────────────────────────────────────────
interface Page<T> {
readonly visible: readonly T[];
readonly truncated: boolean;
readonly total: number;
readonly nextOffset: number;
}
function paginate<T>(items: readonly T[], args: GrepInput): Page<T> {
const offset = args.offset ?? 0;
const headLimit = args.head_limit ?? DEFAULT_HEAD_LIMIT;
const afterOffset = offset > 0 ? items.slice(offset) : items;
const limitActive = headLimit > 0;
const visible = limitActive ? afterOffset.slice(0, headLimit) : afterOffset;
const truncated = limitActive && afterOffset.length > headLimit;
return { visible, truncated, total: items.length, nextOffset: offset + headLimit };
}
function renderGrepResponse(args: GrepInput, response: FsGrepResponse): ExecutableToolResult {
const mode: GrepMode = args.output_mode ?? 'files_with_matches';
// Post-filter sensitive files, mirroring v1's post-rg sensitive filter.
// `IFsService.grep` searches the whole workspace and does not exclude
// sensitive paths, so the tool drops them before rendering.
const filteredSensitive: string[] = [];
const keptFiles = response.files.filter((file) => {
if (isSensitiveFile(file.path)) {
filteredSensitive.push(file.path);
return false;
}
return true;
});
const inlineMessages: string[] = [];
if (filteredSensitive.length > 0) {
inlineMessages.push(
`Filtered ${String(filteredSensitive.length)} sensitive file(s): ${filteredSensitive.join(', ')}`,
);
}
if (response.truncated) {
inlineMessages.push(
'Search stopped early after reaching the match limit; results may be incomplete. Try a more specific path or pattern.',
);
}
if (mode === 'count_matches') {
return renderCountMatches(args, keptFiles, filteredSensitive.length > 0, inlineMessages);
}
if (mode === 'content') {
return renderContent(args, keptFiles, inlineMessages, filteredSensitive.length > 0);
}
return renderFilesWithMatches(args, keptFiles, inlineMessages, filteredSensitive.length > 0);
}
function renderFilesWithMatches(
args: GrepInput,
files: readonly { path: string }[],
inlineMessages: string[],
redactedSensitive: boolean,
): ExecutableToolResult {
const page = paginate(files, args);
const body = page.visible.map((file) => file.path).join('\n');
appendPaginationNotice(inlineMessages, page);
return {
output: combineBody(body, inlineMessages, emptyMessage(redactedSensitive)),
};
}
function renderContent(
args: GrepInput,
files: readonly { path: string; matches: readonly FsGrepMatch[] }[],
inlineMessages: string[],
redactedSensitive: boolean,
): ExecutableToolResult {
const includeLineNumbers = args['-n'] !== false;
const lines: string[] = [];
for (const file of files) {
for (const match of file.matches) {
lines.push(...renderMatchLines(file.path, match, includeLineNumbers));
}
}
const page = paginate(lines, args);
const body = page.visible.join('\n');
appendPaginationNotice(inlineMessages, page);
return {
output: combineBody(body, inlineMessages, emptyMessage(redactedSensitive)),
};
}
function renderMatchLines(
path: string,
match: FsGrepMatch,
includeLineNumbers: boolean,
): string[] {
const lines: string[] = [];
if (includeLineNumbers) {
const beforeStart = match.line - match.before.length;
for (let i = 0; i < match.before.length; i += 1) {
lines.push(`${path}-${String(beforeStart + i)}-${match.before[i]}`);
}
lines.push(`${path}:${String(match.line)}:${match.text}`);
for (let i = 0; i < match.after.length; i += 1) {
lines.push(`${path}-${String(match.line + 1 + i)}-${match.after[i]}`);
}
} else {
for (const text of match.before) lines.push(`${path}:${text}`);
lines.push(`${path}:${match.text}`);
for (const text of match.after) lines.push(`${path}:${text}`);
}
return lines;
}
function renderCountMatches(
args: GrepInput,
files: readonly { path: string; matches: readonly unknown[] }[],
redactedSensitive: boolean,
inlineMessages: string[],
): ExecutableToolResult {
const counts = files.map((file) => ({ path: file.path, count: file.matches.length }));
const totalMatches = counts.reduce((sum, entry) => sum + entry.count, 0);
const page = paginate(counts, args);
const body = page.visible.map((entry) => `${entry.path}:${String(entry.count)}`).join('\n');
// The count data stream stays pure `path:count` lines; the summary and the
// pagination notice move to the side channel so they don't contaminate it.
const sideMessages: string[] = [];
if (counts.length > 0) {
sideMessages.push(formatCountSummary(totalMatches, counts.length, redactedSensitive));
}
if (page.truncated) {
sideMessages.push(
`Results truncated to ${String(args.head_limit ?? DEFAULT_HEAD_LIMIT)} lines (total: ${String(page.total)}). Use offset=${String(page.nextOffset)} to see more.`,
);
}
return {
output: combineBody(body, inlineMessages, emptyMessage(redactedSensitive)),
message: sideMessages.length > 0 ? sideMessages.join('\n') : undefined,
};
}
function appendPaginationNotice(messages: string[], page: Page<unknown>): void {
if (!page.truncated) return;
messages.push(
`Results truncated to ${String(page.visible.length)} lines (total: ${String(page.total)}). Use offset=${String(page.nextOffset)} to see more.`,
);
}
function emptyMessage(redactedSensitive: boolean): string {
return redactedSensitive ? 'No non-sensitive matches found' : 'No matches found';
}
function combineBody(body: string, messages: readonly string[], empty: string): string {
const base = body === '' ? empty : body;
if (messages.length === 0) return base;
return `${base}\n${messages.join('\n')}`;
}
function formatCountSummary(
totalMatches: number,
totalFiles: number,
redactedSensitive: boolean,
): string {
const occurrenceWord = totalMatches === 1 ? 'occurrence' : 'occurrences';
const fileWord = totalFiles === 1 ? 'file' : 'files';
const scope = redactedSensitive ? 'total non-sensitive' : 'total';
return `Found ${String(totalMatches)} ${scope} ${occurrenceWord} across ${String(totalFiles)} ${fileWord}.`;
}

View file

@ -0,0 +1,59 @@
/**
* `fileTools` domain model-text line-ending helpers.
*
* Ported from v1 (`packages/agent-core/src/tools/builtin/file/line-endings.ts`).
* Normalizes CRLF LF for display and re-materializes CRLF on write, so the
* model sees a consistent view while the on-disk bytes stay faithful.
*/
export type LineEndingStyle = 'lf' | 'crlf' | 'mixed';
export interface ModelTextView {
text: string;
lineEndingStyle: LineEndingStyle;
}
export function detectLineEndingStyle(text: string): LineEndingStyle {
let hasCrLf = false;
let hasLf = false;
let hasLoneCr = false;
for (let i = 0; i < text.length; i++) {
const code = text.codePointAt(i);
if (code === 13) {
if (text.codePointAt(i + 1) === 10) {
hasCrLf = true;
i++;
} else {
hasLoneCr = true;
}
} else if (code === 10) {
hasLf = true;
}
}
if (hasLoneCr || (hasCrLf && hasLf)) return 'mixed';
if (hasCrLf) return 'crlf';
return 'lf';
}
export function toModelTextView(raw: string): ModelTextView {
const lineEndingStyle = detectLineEndingStyle(raw);
if (lineEndingStyle !== 'crlf') {
return { text: raw, lineEndingStyle };
}
return {
text: raw.replaceAll('\r\n', '\n'),
lineEndingStyle,
};
}
export function materializeModelText(text: string, lineEndingStyle: LineEndingStyle): string {
if (lineEndingStyle !== 'crlf') return text;
return text.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n');
}
export function makeCarriageReturnsVisible(text: string): string {
return text.replaceAll('\r', '\\r');
}

View file

@ -0,0 +1,17 @@
Read a text file from the local filesystem.
If the user provides a concrete file path to a text file, call Read directly. Do not `Glob`, `ls`, or otherwise pre-check known text file paths; missing or invalid file paths return errors you can handle. Do not use Read for directories; use `ls` via Bash for a known directory, or Glob when you need files/directories matching a pattern. Use `Grep` only when the task is to search for unknown content or locations.
When you need several files, prefer to read them in parallel: emit multiple `Read` calls in a single response instead of reading one file per turn.
- Relative paths resolve against the working directory; a path outside the working directory must be absolute.
- Returns up to {{ MAX_LINES }} lines or {{ MAX_BYTES_KB }} KB per call, whichever comes first; lines longer than {{ MAX_LINE_LENGTH }} chars are truncated mid-line.
- Page larger files with `line_offset` (1-based start line) and `n_lines`. Omit `n_lines` to read up to the {{ MAX_LINES }}-line cap.
- Sensitive files (`.env` files, credential stores, SSH keys, and similar secrets) are refused to protect secrets; do not attempt to read them.
- Only UTF-8 text files can be read. Non-UTF-8 encodings, binary files, and files containing NUL bytes are refused; use `ReadMediaFile` for images or video, and Bash or an MCP tool for other binary formats.
- Negative line_offset reads from the end of the file (for example, -100 reads the last 100 lines); the absolute value cannot exceed {{ MAX_LINES }}.
- Output format: `<line-number>\t<content>` per line.
- A `<system>...</system>` status block is appended after the file content; it summarizes how much was read (line and byte counts, truncation, line-ending notes) and is not part of the file itself.
- Pure CRLF files are displayed with LF line endings; `Edit` matches this output and preserves CRLF when writing back.
- Mixed or lone carriage-return line endings are shown as `\r` and require exact `Edit.old_string` escapes.
- After a successful `Edit`/`Write`, do not re-read solely to prove the write landed. When the task depends on an exact file, API, or output shape, inspect the final external contract before finishing.

View file

@ -0,0 +1,500 @@
/**
* `fileTools` domain ReadTool, the model's UTF-8 text file reader.
*
* Renders a text file as `<line-number>\t<content>` per line and appends a
* `<system>…</system>` status block summarizing how much was read (line and
* byte counts, truncation, and line-ending notes). Pure CRLF files are
* displayed with LF line endings; mixed or lone carriage returns are shown as
* `\r` so the model can reproduce them exactly.
*
* Binary, non-UTF-8, NUL-containing, image and video files are refused;
* images/videos are redirected to ReadMediaFile. Supports one-based
* `line_offset` / `n_lines` pagination and a negative `line_offset` tail mode,
* bounded by the per-call line/byte caps.
*
* Path safety goes through the shared path access resolver used by
* Read/Write/Edit. Read access flows through the `agentFs` domain; path
* semantics (home expansion, path class) come from the `kaos` domain.
*
* Ported from v1 (`packages/agent-core/src/tools/builtin/file/read.ts`). The
* optional `scanTextFile` / `readLineRange` / `readTailLines` fast-paths are
* intentionally dropped: `IAgentFileSystem` streams through `readLines` only.
*/
import { z } from 'zod';
import { IAgentFileSystem } from '#/agentFs';
import { IKaos } from '#/kaos';
import { ToolAccesses } from '#/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/tool';
import { resolvePathAccessPath } from '#/_base/tools/policies/path-access';
import { MEDIA_SNIFF_BYTES, detectFileType } from '#/_base/tools/support/file-type';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { literalRulePattern, matchesPathRuleSubject } from '#/_base/tools/support/rule-match';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import { renderPrompt } from '#/_base/utils/render-prompt';
import { makeCarriageReturnsVisible, type LineEndingStyle } from './line-endings';
import readDescriptionTemplate from './read.md?raw';
export const MAX_LINES: number = 1000;
export const MAX_LINE_LENGTH: number = 2000;
export const MAX_BYTES: number = 100 * 1024;
const PositiveLineOffsetSchema = z.number().int().min(1);
const TailLineOffsetSchema = z.number().int().min(-MAX_LINES).max(-1);
export const ReadInputSchema = z.object({
path: z
.string()
.describe(
'Path to a text file. Relative paths resolve against the working directory; a path outside the working directory must be absolute. Directories are not supported; use `ls` via Bash for a known directory, or Glob for pattern search.',
),
line_offset: z
.union([PositiveLineOffsetSchema, TailLineOffsetSchema])
.optional()
.describe(
`The line number to start reading from. Omit to start at line 1. Negative values read from the end of the file; the absolute value cannot exceed ${String(MAX_LINES)}.`,
),
n_lines: z
.number()
.int()
.positive()
.optional()
.describe(
`The number of lines to read; the tool also applies its internal cap. Omit to read up to the internal cap of ${String(MAX_LINES)} lines.`,
),
});
export const ReadOutputSchema = z.object({
content: z.string(),
lineCount: z.number().int().nonnegative(),
});
export type ReadInput = z.infer<typeof ReadInputSchema>;
export type ReadOutput = z.infer<typeof ReadOutputSchema>;
interface LineEndingFlags {
hasCrLf: boolean;
hasLf: boolean;
hasLoneCr: boolean;
}
interface ReadLineEntry {
readonly lineNo: number;
readonly rawContent: string;
}
interface RenderedLine {
readonly line: string;
readonly wasTruncated: boolean;
}
interface FinishReadResultInput {
readonly renderedLines: readonly string[];
readonly truncatedLineNumbers: readonly number[];
readonly maxLinesReached: boolean;
readonly maxBytesReached: boolean;
readonly lineEndingStyle: LineEndingStyle;
readonly startLine: number;
readonly totalLines: number;
readonly requestedLines: number;
}
function truncateLine(line: string, maxLength: number): string {
if (line.length <= maxLength) return line;
const marker = '...';
const target = Math.max(maxLength, marker.length);
return line.slice(0, target - marker.length) + marker;
}
function stripTrailingLf(line: string): string {
return line.endsWith('\n') ? line.slice(0, -1) : line;
}
function updateLineEndingFlags(flags: LineEndingFlags, text: string): void {
for (let i = 0; i < text.length; i += 1) {
const code = text.codePointAt(i);
if (code === 13) {
if (text.codePointAt(i + 1) === 10) {
flags.hasCrLf = true;
i += 1;
} else {
flags.hasLoneCr = true;
}
} else if (code === 10) {
flags.hasLf = true;
}
}
}
function lineEndingStyleFromFlags(flags: LineEndingFlags): LineEndingStyle {
if (flags.hasLoneCr || (flags.hasCrLf && flags.hasLf)) return 'mixed';
if (flags.hasCrLf) return 'crlf';
return 'lf';
}
function renderLine(entry: ReadLineEntry, lineEndingStyle: LineEndingStyle): RenderedLine {
const modelContent =
lineEndingStyle === 'crlf' && entry.rawContent.endsWith('\r')
? entry.rawContent.slice(0, -1)
: entry.rawContent;
const truncated = truncateLine(modelContent, MAX_LINE_LENGTH);
const renderedContent =
lineEndingStyle === 'mixed' ? makeCarriageReturnsVisible(truncated) : truncated;
return {
line: `${String(entry.lineNo)}\t${renderedContent}`,
wasTruncated: truncated !== modelContent,
};
}
function renderedLineBytes(renderedLine: string, isFirst: boolean): number {
return (isFirst ? 0 : 1) + Buffer.byteLength(renderedLine, 'utf8');
}
function renderEntries(
entries: readonly ReadLineEntry[],
lineEndingStyle: LineEndingStyle,
): {
renderedLines: string[];
truncatedLineNumbers: number[];
maxBytesReached: boolean;
} {
const renderedLines: string[] = [];
const truncatedLineNumbers: number[] = [];
let bytes = 0;
let maxBytesReached = false;
for (const entry of entries) {
const rendered = renderLine(entry, lineEndingStyle);
const lineBytes = renderedLineBytes(rendered.line, renderedLines.length === 0);
if (renderedLines.length > 0 && bytes + lineBytes > MAX_BYTES) {
maxBytesReached = true;
break;
}
if (rendered.wasTruncated) {
truncatedLineNumbers.push(entry.lineNo);
}
renderedLines.push(rendered.line);
bytes += lineBytes;
if (bytes >= MAX_BYTES) {
maxBytesReached = true;
break;
}
}
return { renderedLines, truncatedLineNumbers, maxBytesReached };
}
function isFileNotFoundError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const code = (error as { code?: unknown })['code'];
return code === 'ENOENT' || code === 'ENOTDIR';
}
function isTextDecodeError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const code = (error as { code?: unknown })['code'];
if (code === 'ERR_ENCODING_INVALID_ENCODED_DATA') return true;
if (!(error instanceof Error)) return false;
return /encoded data was not valid|invalid.*encoding|invalid.*utf-?8/i.test(error.message);
}
function containsNulByte(text: string): boolean {
return text.includes('\u0000');
}
function notReadableFileOutput(path: string): string {
return (
`"${path}" is not readable as UTF-8 text. ` +
'If it is an image or video, use ReadMediaFile. ' +
'For other binary formats, use Bash or an MCP tool if available.'
);
}
const READ_DESCRIPTION = renderPrompt(readDescriptionTemplate, {
MAX_LINES,
MAX_BYTES_KB: MAX_BYTES / 1024,
MAX_LINE_LENGTH,
});
export class ReadTool implements BuiltinTool<ReadInput> {
readonly name = 'Read' as const;
readonly description = READ_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(ReadInputSchema);
constructor(
private readonly fs: IAgentFileSystem,
private readonly kaos: IKaos,
private readonly workspace: WorkspaceConfig,
) {}
resolveExecution(args: ReadInput): ToolExecution {
const path = resolvePathAccessPath(args.path, {
kaos: this.kaos,
workspace: this.workspace,
operation: 'read',
});
return {
accesses: ToolAccesses.readFile(path),
description: `Reading ${args.path}`,
display: { kind: 'file_io', operation: 'read', path },
approvalRule: literalRulePattern(this.name, path),
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
pathClass: this.kaos.pathClass(),
homeDir: this.kaos.gethome(),
}),
execute: () => this.execution(args, path),
};
}
private async execution(args: ReadInput, safePath: string): Promise<ExecutableToolResult> {
try {
let stat: Awaited<ReturnType<IAgentFileSystem['stat']>>;
try {
stat = await this.fs.stat(safePath);
} catch (error) {
if (isFileNotFoundError(error)) {
return { isError: true, output: `"${args.path}" does not exist.` };
}
throw error;
}
if (!stat.isFile) {
return { isError: true, output: `"${args.path}" is not a file.` };
}
const header = await this.fs.readBytes(safePath, MEDIA_SNIFF_BYTES);
const fileType = detectFileType(safePath, header);
if (fileType.kind === 'image' || fileType.kind === 'video') {
return {
isError: true,
output: `"${args.path}" is a ${fileType.kind} file. Use ReadMediaFile to read image or video files.`,
};
}
if (fileType.kind === 'unknown') {
return {
isError: true,
output: notReadableFileOutput(args.path),
};
}
const lineOffset = args.line_offset ?? 1;
const requestedLines = args.n_lines ?? MAX_LINES;
const effectiveLimit = Math.min(requestedLines, MAX_LINES);
if (lineOffset < 0) {
return await this.readTail(
safePath,
args.path,
lineOffset,
effectiveLimit,
requestedLines,
);
}
return await this.readForward(
safePath,
args.path,
lineOffset,
effectiveLimit,
requestedLines,
);
} catch (error) {
if (isTextDecodeError(error)) {
return { isError: true, output: notReadableFileOutput(args.path) };
}
return {
isError: true,
output: error instanceof Error ? error.message : String(error),
};
}
}
private async readForward(
safePath: string,
displayPath: string,
lineOffset: number,
effectiveLimit: number,
requestedLines: number,
): Promise<ExecutableToolResult> {
const selectedEntries: ReadLineEntry[] = [];
const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false };
let currentLineNo = 0;
let maxLinesReached = false;
let collectionClosed = false;
for await (const rawLine of this.fs.readLines(safePath, { errors: 'strict' })) {
if (containsNulByte(rawLine)) {
return { isError: true, output: notReadableFileOutput(displayPath) };
}
currentLineNo += 1;
updateLineEndingFlags(flags, rawLine);
if (collectionClosed) {
if (effectiveLimit >= MAX_LINES && currentLineNo >= lineOffset) {
maxLinesReached = true;
}
continue;
}
if (currentLineNo < lineOffset) continue;
if (selectedEntries.length >= effectiveLimit) {
if (effectiveLimit >= MAX_LINES) {
maxLinesReached = true;
}
collectionClosed = true;
continue;
}
selectedEntries.push({
lineNo: currentLineNo,
rawContent: stripTrailingLf(rawLine),
});
if (selectedEntries.length >= effectiveLimit) {
collectionClosed = true;
}
}
const lineEndingStyle = lineEndingStyleFromFlags(flags);
const rendered = renderEntries(selectedEntries, lineEndingStyle);
return this.finishReadResult({
renderedLines: rendered.renderedLines,
truncatedLineNumbers: rendered.truncatedLineNumbers,
maxLinesReached,
maxBytesReached: rendered.maxBytesReached,
lineEndingStyle,
startLine: selectedEntries.length > 0 ? lineOffset : 0,
totalLines: currentLineNo,
requestedLines,
});
}
private async readTail(
safePath: string,
displayPath: string,
lineOffset: number,
effectiveLimit: number,
requestedLines: number,
): Promise<ExecutableToolResult> {
const tailCount = Math.abs(lineOffset);
const entries: ReadLineEntry[] = [];
const flags: LineEndingFlags = { hasCrLf: false, hasLf: false, hasLoneCr: false };
let currentLineNo = 0;
for await (const rawLine of this.fs.readLines(safePath, { errors: 'strict' })) {
if (containsNulByte(rawLine)) {
return { isError: true, output: notReadableFileOutput(displayPath) };
}
currentLineNo += 1;
updateLineEndingFlags(flags, rawLine);
entries.push({
lineNo: currentLineNo,
rawContent: stripTrailingLf(rawLine),
});
if (entries.length > tailCount) {
entries.shift();
}
}
return this.finishTailEntries({
entries,
lineEndingFlags: flags,
effectiveLimit,
totalLines: currentLineNo,
requestedLines,
});
}
private finishTailEntries(input: {
entries: readonly ReadLineEntry[];
lineEndingFlags: LineEndingFlags;
effectiveLimit: number;
totalLines: number;
requestedLines: number;
}): ExecutableToolResult {
const lineEndingStyle = lineEndingStyleFromFlags(input.lineEndingFlags);
let renderedCandidates = input.entries.slice(0, input.effectiveLimit).map((entry) => {
return { entry, rendered: renderLine(entry, lineEndingStyle) };
});
let totalBytes = 0;
for (const [index, candidate] of renderedCandidates.entries()) {
totalBytes += renderedLineBytes(candidate.rendered.line, index === 0);
}
let maxBytesReached = false;
if (totalBytes > MAX_BYTES) {
maxBytesReached = true;
const kept: typeof renderedCandidates = [];
let bytes = 0;
for (let i = renderedCandidates.length - 1; i >= 0; i -= 1) {
const candidate = renderedCandidates[i];
if (candidate === undefined) continue;
const lineBytes = renderedLineBytes(candidate.rendered.line, kept.length === 0);
if (bytes + lineBytes > MAX_BYTES) break;
kept.unshift(candidate);
bytes += lineBytes;
}
renderedCandidates = kept;
}
const renderedLines: string[] = [];
const truncatedLineNumbers: number[] = [];
for (const candidate of renderedCandidates) {
renderedLines.push(candidate.rendered.line);
if (candidate.rendered.wasTruncated) {
truncatedLineNumbers.push(candidate.entry.lineNo);
}
}
return this.finishReadResult({
renderedLines,
truncatedLineNumbers,
maxLinesReached: false,
maxBytesReached,
lineEndingStyle,
startLine: renderedCandidates[0]?.entry.lineNo ?? 0,
totalLines: input.totalLines,
requestedLines: input.requestedLines,
});
}
private finishReadResult(input: FinishReadResultInput): ExecutableToolResult {
return {
output: this.finishOutput(input.renderedLines, this.finishMessage(input)),
};
}
private finishOutput(renderedLines: readonly string[], message: string): string {
const rendered = renderedLines.join('\n');
const status = `<system>${message}</system>`;
return rendered.length > 0 ? `${rendered}\n${status}` : status;
}
private finishMessage(input: FinishReadResultInput): string {
const lineCount = input.renderedLines.length;
const lineWord = lineCount === 1 ? 'line' : 'lines';
const parts =
lineCount > 0
? [
`${String(lineCount)} ${lineWord} read from file starting from line ${String(input.startLine)}.`,
]
: ['No lines read from file.'];
parts.push(`Total lines in file: ${String(input.totalLines)}.`);
if (input.maxLinesReached) {
parts.push(`Max ${String(MAX_LINES)} lines reached.`);
} else if (input.maxBytesReached) {
parts.push(`Max ${String(MAX_BYTES)} bytes reached.`);
} else if (lineCount < input.requestedLines) {
parts.push('End of file reached.');
}
if (input.truncatedLineNumbers.length > 0) {
parts.push(`Lines [${input.truncatedLineNumbers.join(', ')}] were truncated.`);
}
if (input.lineEndingStyle === 'mixed') {
parts.push(
'Mixed or lone carriage-return line endings are shown as \\r. Use exact \\r\\n or \\r escapes in Edit.old_string for those lines.',
);
}
return parts.join(' ');
}
}

View file

@ -0,0 +1,10 @@
Create, append to, or replace a file entirely.
- Missing parent directories are created automatically (like `mkdir(parents=True, exist_ok=True)`).
- Mode defaults to overwrite; append adds content at EOF without adding a newline.
- Write is NOT ALLOWED for incremental changes to existing files, including trivial, one-line, quick, or cosmetic edits. Use Edit instead.
- Use Write only when the file does not exist, you intend a complete replacement, or the new contents have little continuity with the old contents.
- Read before overwriting an existing file.
- Write ignores the Read/Edit line-number view. NEVER include line prefixes.
- Write outputs content literally, including supplied line endings: \n stays LF, \r\n stays CRLF.
- For new content too large for one call, overwrite the first chunk, then append subsequent chunks. Never chunk Write to modify an existing file.

View file

@ -0,0 +1,170 @@
/**
* `fileTools` domain WriteTool, the model's UTF-8 text file writer.
*
* Overwrites a file entirely or appends content to its end. Creates the file
* if it does not exist, and creates missing parent directories automatically
* (mirroring `mkdir(parents=True, exist_ok=True)`). Path access policy is
* resolved before any filesystem I/O.
*
* v2's `IAgentFileSystem.writeText` has no mode flag: overwrite maps to a
* direct write, while append reads the existing content first (treating a
* missing file as empty) and writes the concatenation back.
*
* Write access flows through the `agentFs` domain; path semantics (home
* expansion, path class) come from the `kaos` domain.
*
* Ported from v1 (`packages/agent-core/src/tools/builtin/file/write.ts`).
*/
import { dirname } from 'pathe';
import { z } from 'zod';
import type { AgentFileStat, IAgentFileSystem } from '#/agentFs';
import { IKaos } from '#/kaos';
import { ToolAccesses } from '#/tool';
import type { BuiltinTool, ExecutableToolResult, ToolExecution } from '#/tool';
import { resolvePathAccessPath } from '#/_base/tools/policies/path-access';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { literalRulePattern, matchesPathRuleSubject } from '#/_base/tools/support/rule-match';
import type { WorkspaceConfig } from '#/_base/tools/support/workspace';
import WRITE_DESCRIPTION from './write.md?raw';
export const WriteInputSchema = z.object({
path: z
.string()
.describe(
'Path to the file to create, append to, or completely overwrite. Relative paths resolve against the working directory; a path outside the working directory must be absolute. Missing parent directories are created automatically.',
),
content: z
.string()
.describe(
'Raw full file content to write exactly as provided. This does not use the Read/Edit text view.',
),
mode: z
.enum(['overwrite', 'append'])
.optional()
.describe(
'Write mode. Defaults to overwrite. append adds content to the end exactly as provided and does not add a newline.',
),
});
export const WriteOutputSchema = z.object({
/** Number of UTF-8 bytes written to disk by this call. */
bytesWritten: z.number().int().nonnegative(),
});
export type WriteInput = z.infer<typeof WriteInputSchema>;
export type WriteOutput = z.infer<typeof WriteOutputSchema>;
export class WriteTool implements BuiltinTool<WriteInput> {
readonly name = 'Write' as const;
readonly description = WRITE_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(WriteInputSchema);
constructor(
private readonly fs: IAgentFileSystem,
private readonly kaos: IKaos,
private readonly workspace: WorkspaceConfig,
) {}
resolveExecution(args: WriteInput): ToolExecution {
const path = resolvePathAccessPath(args.path, {
kaos: this.kaos,
workspace: this.workspace,
operation: 'write',
});
return {
accesses: ToolAccesses.writeFile(path),
description: `Writing ${args.path}`,
display: { kind: 'file_io', operation: 'write', path, content: args.content },
approvalRule: literalRulePattern(this.name, path),
matchesRule: (ruleArgs) =>
matchesPathRuleSubject(ruleArgs, path, {
cwd: this.workspace.workspaceDir,
pathClass: this.kaos.pathClass(),
homeDir: this.kaos.gethome(),
}),
execute: () => this.execution(args, path),
};
}
private async execution(args: WriteInput, safePath: string): Promise<ExecutableToolResult> {
const parentError = await this.ensureParentDirectory(safePath);
if (parentError !== undefined) {
return { isError: true, output: parentError };
}
try {
const mode = args.mode ?? 'overwrite';
if (mode === 'append') {
let existing = '';
try {
existing = await this.fs.readText(safePath);
} catch (error) {
const code = (error as { code?: unknown } | null)?.code;
if (code !== 'ENOENT') throw error;
}
await this.fs.writeText(safePath, existing + args.content);
} else {
await this.fs.writeText(safePath, args.content);
}
// Report the number of UTF-8 bytes this call wrote to disk. The string
// length would only equal the byte count for pure ASCII content, so it
// is not used here.
const bytesWritten = Buffer.byteLength(args.content, 'utf8');
return {
output: `${mode === 'append' ? 'Appended' : 'Wrote'} ${String(bytesWritten)} bytes to ${args.path}`,
};
} catch (error) {
const code = (error as { code?: unknown } | null)?.code;
if (code === 'ENOENT') {
return {
isError: true,
output: `Failed to write ${args.path}: parent directory does not exist.`,
};
}
return {
isError: true,
output: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Best-effort check that the parent directory is usable, creating it when
* it is missing.
*
* If the parent (or any ancestor) does not exist, it is created
* recursively mirroring Python's `Path.mkdir(parents=True,
* exist_ok=True)` — so the agent does not need a separate `mkdir` round
* trip before writing into a fresh subfolder. An existing parent that is
* not a directory is still a hard error. Any other `stat` failure
* (permissions, an environment without `stat`) is treated as
* inconclusive: the check is skipped and the write proceeds, surfacing
* the real I/O error if any.
*
* Returns an error string when the precondition is definitively violated,
* or `undefined` otherwise.
*/
private async ensureParentDirectory(safePath: string): Promise<string | undefined> {
const parent = dirname(safePath);
let stat: AgentFileStat;
try {
stat = await this.fs.stat(parent);
} catch (error) {
if ((error as { code?: unknown } | null)?.code === 'ENOENT') {
try {
await this.fs.mkdir(parent);
return undefined;
} catch (mkdirError) {
return mkdirError instanceof Error ? mkdirError.message : String(mkdirError);
}
}
return undefined;
}
if (!stat.isDirectory) {
return `Parent path is not a directory: ${parent}.`;
}
return undefined;
}
}

View file

@ -92,3 +92,5 @@ export { IToolRegistry } from './toolRegistry/index';
export * from './toolStore/index';
export * from './userTool/index';
export * from './wireRecord/index';
export * from './fileTools/index';
export * from './shellTools/index';

View file

@ -3,6 +3,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IBackgroundService } from '#/background';
import { IContextMemory } from '#/contextMemory';
import { IContextSizeService } from '#/contextSize';
import { IFileToolsService } from '#/fileTools';
import { IFullCompaction } from '#/fullCompaction';
import { IGoalService } from '#/goal';
import { IPermissionGate } from '#/permission/permission';
@ -10,6 +11,7 @@ import { IPermissionModeService } from '#/permissionMode/permissionMode';
import { IPlanService } from '../plan';
import { IProfileService } from '#/profile';
import { IPromptService } from '#/prompt';
import { IShellToolsService } from '#/shellTools';
import { IAgentSkillService } from '#/skill';
import { ISubagentHost } from '#/subagentHost';
import { ISwarmService } from '../swarm';
@ -56,6 +58,8 @@ export class AgentRPCService implements IAgentRPCService {
@IFullCompaction private readonly fullCompaction: IFullCompaction,
@IUserToolService private readonly userTools: IUserToolService,
@IToolRegistry private readonly toolRegistry: IToolRegistry,
@IFileToolsService private readonly fileTools: IFileToolsService,
@IShellToolsService private readonly shellTools: IShellToolsService,
@IBackgroundService private readonly background: IBackgroundService,
@IContextMemory private readonly context: IContextMemory,
@IContextSizeService private readonly contextSize: IContextSizeService,

View file

@ -0,0 +1,11 @@
/**
* `shellTools` domain barrel re-exports the built-in Bash tool, the shared
* output `ToolResultBuilder`, and the `IShellToolsService` registration
* contract + service. Importing this barrel registers the `IShellToolsService`
* binding into the scope registry.
*/
export * from './shellTools';
export * from './shellToolsService';
export * from './tools/bash';
export * from './tools/result-builder';

View file

@ -0,0 +1,16 @@
/**
* `shellTools` domain (L4) built-in shell tool registration contract.
*
* `IShellToolsService` is a marker: its implementation registers the built-in
* Bash tool into the agent `IToolRegistry` on construction. Bound at Agent
* scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IShellToolsService {
readonly _serviceBrand: undefined;
}
export const IShellToolsService: ServiceIdentifier<IShellToolsService> =
createDecorator<IShellToolsService>('shellToolsService');

View file

@ -0,0 +1,39 @@
/**
* `shellTools` domain (L4) `IShellToolsService` implementation.
*
* Registers the built-in Bash tool into the agent `IToolRegistry` on
* construction, wiring it to the session `IProcessRunner` (process spawn),
* `IKaos` (cwd + OS/shell probe) and `IBackgroundService` (background-task
* lifecycle). Bound at Agent scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IBackgroundService } from '#/background';
import { IKaos } from '#/kaos';
import { IProcessRunner } from '#/process';
import { IToolRegistry } from '#/toolRegistry';
import { IShellToolsService } from './shellTools';
import { BashTool } from './tools/bash';
export class ShellToolsService implements IShellToolsService {
declare readonly _serviceBrand: undefined;
constructor(
@IToolRegistry toolRegistry: IToolRegistry,
@IProcessRunner runner: IProcessRunner,
@IKaos kaos: IKaos,
@IBackgroundService background: IBackgroundService,
) {
toolRegistry.register(new BashTool(runner, kaos, background));
}
}
registerScopedService(
LifecycleScope.Agent,
IShellToolsService,
ShellToolsService,
InstantiationType.Delayed,
'shellTools',
);

View file

@ -0,0 +1,43 @@
Execute a `{{ SHELL_NAME }}` command. Use this for shell semantics — pipes, env, processes, git, package managers, build/test runners, anything genuinely interactive or multi-step.
**Translate these to a dedicated tool instead:**
- `cat` / `head` / `tail` (known path) → `Read`
- `sed` / `awk` (in-place edit) → `Edit`
- `echo > file` / `cat <<EOF``Write`
- `find` / recursive `ls` to locate files by name pattern → `Glob` (plain `ls <known-directory>` is fine for listing a directory)
- `grep` / `rg` (search file contents) → `Grep`
- `echo` / `printf` (talk to the user) → just output text directly
The dedicated tools render in the per-tool permission UI and keep raw stdout out of the conversation; that is why they are worth reaching for whenever one fits.
**Output:**
The stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command failed, the output will end with a `Command failed with exit code: N` line stating the non-zero exit code.
If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a {{ DEFAULT_BACKGROUND_TIMEOUT_S }}s timeout and `timeout` is capped at {{ MAX_BACKGROUND_TIMEOUT_S }}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. Use `TaskOutput` for a non-blocking status/output snapshot, and only set `block=true` when you explicitly want to wait for completion. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the `/tasks` command, which opens an interactive panel; it has no subcommands.
**Guidelines for safety and security:**
- Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls.
- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running foreground commands, set the `timeout` argument in seconds. Foreground commands default to {{ DEFAULT_TIMEOUT_S }}s and allow up to {{ MAX_TIMEOUT_S }}s.
- Avoid using `..` to access files or directories outside of the working directory.
- Avoid modifying files outside of the working directory unless explicitly instructed to do so.
- Never run commands that require superuser privileges unless explicitly instructed to do so.
**Guidelines for efficiency:**
- For multiple related commands, use `&&` to chain them in a single call, e.g. `cd /path && ls -la`
- Use `;` to run commands sequentially regardless of success/failure
- Use `||` for conditional execution (run second command only if first fails)
- Use pipe operations (`|`) and redirections (`>`, `>>`) to chain input and output between commands
- Always quote file paths containing spaces with double quotes (e.g., cd "/path with spaces/")
- Compose multi-step logic in a single call with `if` / `case` / `for` / `while` control flows.
- Prefer `run_in_background=true` for long-running builds, tests, watchers, or servers when you need the conversation to continue before the command finishes.
**Commands available:**
The following common command categories are usually available. Availability still depends on the host, so when in doubt run `which <command>` first to confirm a command exists before relying on it.
- Navigation and inspection: `ls`, `pwd`, `cd`, `stat`, `file`, `du`, `df`, `tree`
- File and directory management: `cp`, `mv`, `rm`, `mkdir`, `touch`, `ln`, `chmod`, `chown`
- Text and data processing: `wc`, `sort`, `uniq`, `cut`, `tr`, `diff`, `xargs`
- Archives and compression: `tar`, `gzip`, `gunzip`, `zip`, `unzip`
- Networking and transfer: `curl`, `wget`, `ping`, `ssh`, `scp`
- Version control: `git`
- Process and system: `ps`, `kill`, `top`, `env`, `date`, `uname`, `whoami`
- Language and package toolchains: `node`, `npm`, `pnpm`, `yarn`, `python`, `pip` (use whichever the project actually relies on)

View file

@ -0,0 +1,483 @@
/**
* `shellTools` domain BashTool, the model's shell command runner.
*
* Invokes the execution-environment shell (POSIX bash; Git Bash on Windows)
* through the injected `IProcessRunner`. The command runs as
* `cd <cwd> && <command>` inside the environment's working directory.
*
* Dependencies injected via constructor:
* - `runner` `IProcessRunner`, spawns the shell process
* - `kaos` `IKaos`, the execution environment (cwd / osEnv / shellPath)
* - `background` `IBackgroundService`, owns foreground/background task
* lifecycle (timeouts, detach, user interrupt)
*
* Execution goes through `IProcessRunner`, never directly via
* `node:child_process`.
*
* Hardening:
* - `args.timeout` (seconds) and the ambient `signal` both stop the
* manager-owned process task on either edge.
* - stdin is closed immediately so interactive commands (`cat`, `read`,
* `python -c 'input()'`) receive EOF instead of hanging.
* - Two-phase kill is owned by `IBackgroundService`: SIGTERM grace SIGKILL.
* - stdout/stderr are captured by `ProcessBackgroundTask` for task output;
* foreground runs pass a callback to collect chunks for this call.
*
* Ported from v1 (`packages/agent-core/src/tools/builtin/shell/bash.ts`). The
* v1 `process.env` spread is intentionally dropped: v2's `IProcessRunner.exec`
* already overlays the per-call `env` on `process.env`, so only the
* noninteractive knobs are passed here.
*/
import { z } from 'zod';
import { ProcessBackgroundTask } from '#/background';
import type { IBackgroundService } from '#/background';
import type { IKaos } from '#/kaos';
import type { IProcess, IProcessRunner } from '#/process';
import type { BuiltinTool, ExecutableToolResult, ToolExecution, ToolUpdate } from '#/tool';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '#/_base/tools/support/rule-match';
import { renderPrompt } from '#/_base/utils/render-prompt';
import bashDescriptionTemplate from './bash.md?raw';
import { ToolResultBuilder } from './result-builder';
const MS_PER_SECOND = 1000;
const DEFAULT_TIMEOUT_S = 60;
const MAX_TIMEOUT_S = 5 * 60;
const DEFAULT_BACKGROUND_TIMEOUT_S = 10 * 60;
const MAX_BACKGROUND_TIMEOUT_S = 24 * 60 * 60;
const USER_INTERRUPT_REASON = 'Interrupted by user';
export const BashInputSchema = z
.object({
command: z.string().min(1, 'Command cannot be empty.').describe('The command to execute.'),
cwd: z
.string()
.optional()
.describe(
"The working directory in which to run the command. When omitted, the command runs in the session's working directory.",
),
timeout: z
.number()
.int()
.positive()
.default(DEFAULT_TIMEOUT_S)
.describe(
`Optional timeout in seconds for the command to execute. Foreground default ${String(DEFAULT_TIMEOUT_S)}s, max ${String(MAX_TIMEOUT_S)}s. Background default ${String(DEFAULT_BACKGROUND_TIMEOUT_S)}s, max ${String(MAX_BACKGROUND_TIMEOUT_S)}s. Ignored for background commands when disable_timeout=true.`,
)
.optional(),
description: z
.string()
.optional()
.describe(
'A short description for the background task. Required when run_in_background is true.',
),
run_in_background: z
.boolean()
.optional()
.describe('Whether to run the command as a background task.'),
disable_timeout: z
.boolean()
.optional()
.describe(
'If true, do not apply a timeout to the command. Only applies when run_in_background is true.',
),
})
.superRefine((val, ctx) => {
if (val.timeout === undefined) return;
const isBackground = val.run_in_background === true;
if (!isValidTimeoutValue(val.timeout, isBackground)) {
const cap = isBackground ? MAX_BACKGROUND_TIMEOUT_S : MAX_TIMEOUT_S;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['timeout'],
message: `timeout must be ≤ ${String(cap)}s (${isBackground ? 'background' : 'foreground'})`,
});
}
});
export const BashOutputSchema = z.object({
exitCode: z.number().int(),
stdout: z.string(),
stderr: z.string(),
});
export type BashInput = z.infer<typeof BashInputSchema>;
export type BashOutput = z.infer<typeof BashOutputSchema>;
const SHELL_TIMEOUT_VARS = {
DEFAULT_TIMEOUT_S,
DEFAULT_BACKGROUND_TIMEOUT_S,
MAX_TIMEOUT_S,
MAX_BACKGROUND_TIMEOUT_S,
};
function timeoutCapS(isBackground: boolean): number {
return isBackground ? MAX_BACKGROUND_TIMEOUT_S : MAX_TIMEOUT_S;
}
function isValidTimeoutValue(timeout: number, isBackground: boolean): boolean {
return timeout <= timeoutCapS(isBackground);
}
function normalizeTimeoutMs(timeout: number | undefined, isBackground: boolean): number {
const defaultSeconds = isBackground ? DEFAULT_BACKGROUND_TIMEOUT_S : DEFAULT_TIMEOUT_S;
const value = timeout ?? defaultSeconds;
return Math.min(value, timeoutCapS(isBackground)) * MS_PER_SECOND;
}
async function disposeProcess(proc: IProcess): Promise<void> {
try {
await proc.dispose();
} catch {
/* best-effort cleanup */
}
}
function renderBashDescription(shellName: string): string {
return renderPrompt(bashDescriptionTemplate, { ...SHELL_TIMEOUT_VARS, SHELL_NAME: shellName });
}
function withoutBackgroundDescription(description: string): string {
return description
.replace(
/\n\nIf `run_in_background=true`,[\s\S]*?point them to the `\/tasks` command, which opens an interactive panel; it has no subcommands\./,
'\n\nBackground execution is disabled for this agent. Do not set `run_in_background=true`.',
)
.replace(
` For possibly long-running foreground commands, set the \`timeout\` argument in seconds. Foreground commands default to ${String(DEFAULT_TIMEOUT_S)}s and allow up to ${String(MAX_TIMEOUT_S)}s.`,
` For possibly long-running commands, set the \`timeout\` argument in seconds. The default is ${String(DEFAULT_TIMEOUT_S)}s; foreground commands allow up to ${String(MAX_TIMEOUT_S)}s.`,
)
.replace(
/\n- Prefer `run_in_background=true`[\s\S]*?conversation to continue before the command finishes\./,
'\n- Do not set `run_in_background=true`; background task management tools are not available.',
);
}
export class BashTool implements BuiltinTool<BashInput> {
readonly name = 'Bash' as const;
readonly description: string;
readonly parameters: Record<string, unknown> = toInputJsonSchema(BashInputSchema);
private readonly isWindowsBash: boolean;
private readonly allowBackground: boolean;
constructor(
private readonly runner: IProcessRunner,
private readonly kaos: IKaos,
private readonly background: IBackgroundService,
options?: {
allowBackground?: boolean;
},
) {
this.isWindowsBash = this.kaos.osEnv.osKind === 'Windows';
this.allowBackground = options?.allowBackground ?? true;
const rendered = renderBashDescription(this.kaos.osEnv.shellName);
this.description = this.allowBackground ? rendered : withoutBackgroundDescription(rendered);
}
resolveExecution(args: BashInput): ToolExecution {
const preview = args.command.length > 50 ? `${args.command.slice(0, 50)}` : args.command;
return {
description: args.run_in_background
? `Starting background: ${preview}`
: `Running: ${preview}`,
display: {
kind: 'command',
command: args.command,
cwd: args.cwd ?? this.kaos.cwd,
description: args.description,
language: 'bash',
},
approvalRule: literalRulePattern(this.name, args.command),
matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command),
execute: ({ signal, onUpdate }) => this.execution(args, signal, onUpdate),
};
}
private spawn(effectiveCwd: string, command: string): Promise<IProcess> {
const shellCwd = this.isWindowsBash ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd;
const shellArgs = [
this.kaos.osEnv.shellPath,
'-c',
`cd ${shellQuote(shellCwd)} && ${command}`,
];
const noninteractiveEnv: Record<string, string> = {
NO_COLOR: '1',
TERM: 'dumb',
// Default to '0' so git fails fast on private remotes if a TTY happens
// to be inherited; honour an explicit ambient value when the user has
// set one.
GIT_TERMINAL_PROMPT: process.env['GIT_TERMINAL_PROMPT'] ?? '0',
SHELL: this.kaos.osEnv.shellPath,
};
// v2's IProcessRunner.exec overlays this env on process.env, so we pass
// only the noninteractive knobs (the v1 spread of process.env is handled
// by the runner).
return this.runner.exec(shellArgs, { env: noninteractiveEnv });
}
private async execution(
args: BashInput,
signal: AbortSignal,
onUpdate?: (update: ToolUpdate) => void,
): Promise<ExecutableToolResult> {
const validationError = this.validateRunRequest(args, signal);
if (validationError !== undefined) return validationError;
const startsInBackground = args.run_in_background === true;
const foregroundTimeoutMs = normalizeTimeoutMs(args.timeout, false);
const command = this.isWindowsBash ? rewriteWindowsNullRedirect(args.command) : args.command;
const effectiveCwd = args.cwd ?? this.kaos.cwd;
const description = startsInBackground ? args.description!.trim() : foregroundDescription(args);
const timeoutMs = startsInBackground
? args.disable_timeout
? undefined
: normalizeTimeoutMs(args.timeout, true)
: foregroundTimeoutMs;
const builder = new ToolResultBuilder();
let proc: IProcess;
try {
proc = await this.spawn(effectiveCwd, command);
} catch (error) {
return {
isError: true,
output: error instanceof Error ? error.message : String(error),
};
}
closeProcessStdin(proc);
let collectForegroundOutput = !startsInBackground;
const onProcessOutput = startsInBackground
? undefined
: (kind: 'stdout' | 'stderr', text: string): void => {
if (!collectForegroundOutput) return;
onUpdate?.({ kind, text });
builder.write(text);
};
let taskId: string;
try {
taskId = this.background.registerTask(
new ProcessBackgroundTask(proc, command, description, onProcessOutput),
{
detached: startsInBackground,
timeoutMs,
signal: startsInBackground ? undefined : signal,
},
);
} catch (error) {
collectForegroundOutput = false;
await killSpawnedProcess(proc);
return {
isError: true,
output: error instanceof Error ? error.message : String(error),
};
}
if (startsInBackground) {
return this.backgroundStartedResult(taskId, proc, description, {
title: 'Background task started',
});
}
try {
const release = await this.background.waitForForegroundRelease(taskId);
if (release === 'detached') {
collectForegroundOutput = false;
return this.backgroundStartedResult(
taskId,
proc,
description,
{
title: 'Task moved to background',
},
builder,
'foreground_detached',
);
}
return this.foregroundCompletionResult(taskId, proc, builder, foregroundTimeoutMs);
} finally {
collectForegroundOutput = false;
}
}
private validateRunRequest(
args: BashInput,
signal: AbortSignal,
): ExecutableToolResult | undefined {
if (signal.aborted) return { isError: true, output: 'Aborted before command started' };
if (args.command.length === 0) return { isError: true, output: 'Command cannot be empty.' };
if (args.run_in_background !== true) return undefined;
if (!this.allowBackground) {
return {
isError: true,
output:
'Background execution is not available for this agent because TaskOutput and TaskStop are not enabled.',
};
}
if (!args.description?.trim()) {
return {
isError: true,
output: 'description is required when run_in_background is true.',
};
}
return undefined;
}
private foregroundCompletionResult(
taskId: string,
proc: IProcess,
builder: ToolResultBuilder,
foregroundTimeoutMs: number,
): ExecutableToolResult {
const current = this.background.getTask(taskId);
const exitCode = current?.kind === 'process' ? current.exitCode : proc.exitCode;
if (current?.status === 'timed_out') {
const timeoutLabel = formatTimeoutLabel(foregroundTimeoutMs);
return builder.error(`Command killed by timeout (${timeoutLabel})`);
}
if (current?.status === 'killed' && current.stopReason === USER_INTERRUPT_REASON) {
return builder.error(USER_INTERRUPT_REASON);
}
if (
(current?.status === 'failed' || current?.status === 'killed') &&
current.stopReason !== undefined
) {
return builder.error(current.stopReason);
}
const isError = exitCode !== 0;
if (isError && builder.nChars === 0) {
builder.write(`Process exited with code ${String(exitCode)}`);
}
if (!isError) {
return builder.ok('Command executed successfully.');
}
return builder.error(`Command failed with exit code: ${String(exitCode)}.`);
}
private backgroundStartedResult(
taskId: string,
proc: IProcess,
description: string,
labels: { title: string },
builder = new ToolResultBuilder(),
scenario: 'background_started' | 'foreground_detached' = 'background_started',
): ExecutableToolResult {
const status = this.background.getTask(taskId)?.status ?? 'running';
const metadata =
`task_id: ${taskId}\n` +
`pid: ${String(proc.pid)}\n` +
`description: ${description}\n` +
`status: ${status}\n` +
`automatic_notification: true\n` +
this.nextStepLines(taskId, scenario) +
'human_shell_hint: Tell the human to run /tasks to open the interactive background-task panel.';
const foregroundResult = builder.ok('');
const foregroundOutput = foregroundResult.output.length > 0 ? foregroundResult.output : '';
const message = backgroundResultMessage(labels.title, foregroundResult.message);
return {
isError: false,
output:
foregroundOutput.length === 0
? metadata
: `${metadata}\n\nforeground_output:\n${foregroundOutput}`,
message,
truncated: foregroundResult.truncated,
};
}
private nextStepLines(
taskId: string,
scenario: 'background_started' | 'foreground_detached',
): string {
if (scenario === 'foreground_detached') {
// The user explicitly moved a foreground call to the background to avoid
// blocking the current turn. Steer the model away from waiting on it.
// Only mention TaskOutput when the tool is actually available.
const avoid = this.allowBackground ? 'do NOT wait, poll, or call TaskOutput on it' : 'do NOT wait or poll';
return (
'next_step: The task now runs in the background. You will be automatically notified ' +
`when it completes — ${avoid}; continue with your current work.\n`
);
}
// background_started: the model chose to launch in the background.
if (!this.allowBackground) {
return 'next_step: You will be automatically notified when it completes.\n';
}
return (
'next_step: The completion arrives automatically in a later turn — no polling needed. ' +
`To peek at progress without blocking, call TaskOutput(task_id="${taskId}", block=false).\n` +
'next_step: Use TaskStop only if the task must be cancelled.\n'
);
}
}
function backgroundResultMessage(title: string, suffix: string): string {
const normalized = title.endsWith('.') ? title : `${title}.`;
if (suffix.length === 0) return normalized;
return suffix.endsWith('.') ? `${normalized} ${suffix}` : `${normalized} ${suffix}.`;
}
function formatTimeoutLabel(timeoutMs: number): string {
return timeoutMs % 1000 === 0 ? `${String(timeoutMs / 1000)}s` : `${String(timeoutMs)}ms`;
}
function foregroundDescription(args: BashInput): string {
const explicit = args.description?.trim();
if (explicit !== undefined && explicit.length > 0) return explicit;
const preview = args.command.length > 60 ? `${args.command.slice(0, 60)}` : args.command;
return `Bash: ${preview}`;
}
function closeProcessStdin(proc: IProcess): void {
try {
proc.stdin.end();
} catch {
/* process already gone */
}
}
async function killSpawnedProcess(proc: IProcess): Promise<void> {
try {
await proc.kill('SIGTERM');
} catch {
/* process already gone */
} finally {
await disposeProcess(proc);
}
}
function shellQuote(s: string): string {
return `'${s.replaceAll("'", "'\\''")}'`;
}
function windowsPathToPosixPath(path: string): string {
if (path.startsWith('\\\\')) {
return path.replaceAll('\\', '/');
}
const driveMatch = /^([A-Za-z]):(?:[\\/]|$)/.exec(path);
if (driveMatch !== null) {
const drive = driveMatch[1]!.toLowerCase();
const rest = path.slice(2).replaceAll('\\', '/');
return `/${drive}${rest.startsWith('/') ? rest : `/${rest}`}`;
}
return path.replaceAll('\\', '/');
}
const WINDOWS_NUL_REDIRECT = /(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g;
function rewriteWindowsNullRedirect(command: string): string {
return command.replace(WINDOWS_NUL_REDIRECT, '$1/dev/null');
}

View file

@ -0,0 +1,140 @@
import type { ExecutableToolErrorResult, ExecutableToolSuccessResult } from '#/tool';
const DEFAULT_MAX_CHARS = 50_000;
const DEFAULT_MAX_LINE_LENGTH = 2000;
const TRUNCATION_MARKER = '[...truncated]';
const TRUNCATION_MESSAGE = 'Output is truncated to fit in the message.';
export interface ToolResultBuilderOptions {
readonly maxChars?: number;
readonly maxLineLength?: number | null;
}
export type ExecutableToolResultBuilderResult = (
| ExecutableToolSuccessResult
| ExecutableToolErrorResult
) & {
readonly output: string;
readonly message: string;
readonly truncated: boolean;
};
export class ToolResultBuilder {
private readonly maxChars: number;
private readonly maxLineLength: number | null;
private readonly buffer: string[] = [];
private nCharsValue = 0;
private truncationHappened = false;
constructor(options: ToolResultBuilderOptions = {}) {
this.maxChars = options.maxChars ?? DEFAULT_MAX_CHARS;
this.maxLineLength =
options.maxLineLength === undefined ? DEFAULT_MAX_LINE_LENGTH : options.maxLineLength;
if (this.maxLineLength !== null && this.maxLineLength <= TRUNCATION_MARKER.length) {
throw new Error('maxLineLength must be greater than the truncation marker length.');
}
}
get nChars(): number {
return this.nCharsValue;
}
write(text: string): number {
if (this.nCharsValue >= this.maxChars) {
if (text.length > 0 && !this.truncationHappened) {
this.buffer.push(TRUNCATION_MARKER);
this.nCharsValue += TRUNCATION_MARKER.length;
this.truncationHappened = true;
}
return 0;
}
const lines = text.match(/[^\r\n]*(?:\r\n|[\n\r])|[^\r\n]+/g) ?? [];
if (lines.length === 0) return 0;
let charsWritten = 0;
for (const originalLine of lines) {
if (this.nCharsValue >= this.maxChars) {
if (!this.truncationHappened) {
this.buffer.push(TRUNCATION_MARKER);
this.nCharsValue += TRUNCATION_MARKER.length;
this.truncationHappened = true;
}
break;
}
const remainingChars = this.maxChars - this.nCharsValue;
const limit =
this.maxLineLength === null
? remainingChars
: Math.min(remainingChars, this.maxLineLength);
let line = originalLine;
if (line.length > limit) {
const lineBreak = /[\r\n]+$/.exec(line)?.[0] ?? '';
const suffix = TRUNCATION_MARKER + lineBreak;
const effectiveMaxLength = Math.max(limit, suffix.length);
line = line.slice(0, effectiveMaxLength - suffix.length) + suffix;
}
if (line !== originalLine) {
this.truncationHappened = true;
}
this.buffer.push(line);
charsWritten += line.length;
this.nCharsValue += line.length;
}
return charsWritten;
}
ok(message = ''): ExecutableToolResultBuilderResult {
let finalMessage = message;
if (finalMessage.length > 0 && !finalMessage.endsWith('.')) {
finalMessage += '.';
}
if (this.truncationHappened) {
finalMessage =
finalMessage.length === 0 ? TRUNCATION_MESSAGE : `${finalMessage} ${TRUNCATION_MESSAGE}`;
}
const output = this.buffer.join('');
const shouldAppendMessage =
finalMessage.length > 0 && (this.truncationHappened || output.length === 0);
return {
isError: false,
output: shouldAppendMessage
? output.length === 0
? finalMessage
: output.endsWith('\n')
? `${output}${finalMessage}`
: `${output}\n${finalMessage}`
: output,
message: finalMessage,
truncated: this.truncationHappened,
};
}
error(message: string): ExecutableToolResultBuilderResult {
const finalMessage = this.truncationHappened
? message.length === 0
? TRUNCATION_MESSAGE
: `${message} ${TRUNCATION_MESSAGE}`
: message;
const output = this.buffer.join('');
return {
isError: true,
output:
finalMessage.length === 0
? output
: output.length === 0
? finalMessage
: output.endsWith('\n')
? `${output}${finalMessage}`
: `${output}\n${finalMessage}`,
message: finalMessage,
truncated: this.truncationHappened,
};
}
}

View file

@ -0,0 +1,524 @@
/**
* EditTool tests for the v2 fileTools domain.
*
* Ported from v1 (`packages/agent-core/test/tools/edit.test.ts`) and adapted
* to the v2 constructor `(fs, kaos, workspace)`. Self-contained: builds minimal
* fake `IAgentFileSystem` (spied readText/writeText) and `IKaos` inline so the
* tool can be exercised without the composition root, mirroring
* `test/fileTools/read.test.ts`.
*/
import { describe, expect, it, vi } from 'vitest';
import { PathSecurityError } from '../../src/_base/tools/policies/path-access';
import type { WorkspaceConfig } from '../../src/_base/tools/support/workspace';
import type { IAgentFileSystem } from '../../src/agentFs';
import { type EditInput, EditInputSchema, EditTool } from '../../src/fileTools/tools/edit';
import type { IKaos } from '../../src/kaos';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../src/tool';
const signal = new AbortController().signal;
const PERMISSIVE_WORKSPACE: WorkspaceConfig = { workspaceDir: '/', additionalDirs: [] };
function createTestKaos(home = '/home'): IKaos {
return {
pathClass: () => 'posix',
gethome: () => home,
} as unknown as IKaos;
}
/**
* Fake fs with spied `readText` / `writeText`. Defaults read to empty content
* and write to a no-op; tests pass their own `vi.fn()` mocks to drive content
* and assert on write calls.
*/
function createSpiedEditFs(
options: {
readText?: ReturnType<typeof vi.fn>;
writeText?: ReturnType<typeof vi.fn>;
} = {},
) {
const readText = options.readText ?? vi.fn(async () => '');
const writeText = options.writeText ?? vi.fn(async () => undefined);
const stat = vi.fn(async () => ({ isFile: true, isDirectory: false, size: 0 }));
const fs = { cwd: '/', readText, writeText, stat } as unknown as IAgentFileSystem;
return { fs, readText, writeText };
}
function isPromiseLike(
value: ToolExecution | Promise<ToolExecution>,
): value is Promise<ToolExecution> {
return typeof (value as Promise<ToolExecution>).then === 'function';
}
async function execute(tool: EditTool, args: EditInput): Promise<ExecutableToolResult> {
let execution: ToolExecution;
try {
const resolved = tool.resolveExecution(args);
execution = isPromiseLike(resolved) ? await resolved : resolved;
} catch (error) {
const output =
error instanceof PathSecurityError
? error.message
: `Tool "${tool.name}" failed to resolve execution: ${
error instanceof Error ? error.message : String(error)
}`;
return { isError: true, output };
}
if (execution.isError === true) return execution;
const ctx: ExecutableToolContext = {
turnId: '0',
toolCallId: 'call_edit',
signal,
};
return execution.execute(ctx);
}
describe('EditTool', () => {
it('exposes before/after on the file_io display so the approval panel can render a diff', () => {
const tool = new EditTool(createSpiedEditFs().fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const execution = tool.resolveExecution({
path: '/tmp/foo.ts',
old_string: 'a\nb\nc',
new_string: 'a\nB\nc',
});
if (execution.isError === true) {
throw new TypeError('expected runnable execution');
}
expect(execution.display).toEqual({
kind: 'file_io',
operation: 'edit',
path: '/tmp/foo.ts',
before: 'a\nb\nc',
after: 'a\nB\nc',
});
});
it('declares writeFile access for the edited path', () => {
const tool = new EditTool(createSpiedEditFs().fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const execution = tool.resolveExecution({
path: '/tmp/foo.ts',
old_string: 'a',
new_string: 'b',
});
if (execution.isError === true) {
throw new TypeError('expected runnable execution');
}
expect(execution.accesses).toEqual([{ kind: 'file', operation: 'write', path: '/tmp/foo.ts' }]);
});
it('exposes current metadata and schema', () => {
const tool = new EditTool(createSpiedEditFs().fs, createTestKaos(), PERMISSIVE_WORKSPACE);
expect(tool.name).toBe('Edit');
expect(tool.description).toContain('Read the target file before every Edit');
expect(tool.description).toContain('DO NOT call Edit from memory');
expect(tool.description).toContain('Read output view');
expect(tool.description).toContain('line-number prefix');
expect(tool.description).toContain('`old_string` must be unique');
expect(tool.description).toContain('only when they do not target the same file');
expect(tool.description).toContain('DO NOT issue consecutive Edit calls on the same file');
// Editing files should go through Edit, not Write and not a Bash `sed`
// command. The prompt names both alternatives explicitly.
expect(tool.description).toContain('DO NOT use Write or Bash `sed`');
// Parallel Edit calls on the same file are serialized and applied in
// response order; mismatched old_string fails explicitly.
expect(tool.description).toContain('same-file edits in response order');
expect(tool.description).toContain('old_string not found');
expect(tool.parameters).toMatchObject({
type: 'object',
properties: {
path: {
type: 'string',
description: expect.stringContaining('working directory'),
},
old_string: {
type: 'string',
description: expect.stringContaining('without the line-number prefix'),
},
new_string: {
type: 'string',
description: expect.stringContaining('same Read output view'),
},
},
});
expect(
EditInputSchema.safeParse({
path: '/tmp/a.txt',
old_string: 'old',
new_string: 'new',
}).success,
).toBe(true);
expect(
EditInputSchema.safeParse({
path: '/tmp/a.txt',
old_string: '',
new_string: 'new',
}).success,
).toBe(false);
});
it('replaces a unique first occurrence and writes the updated content', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({
readText: vi.fn().mockResolvedValue('alpha beta'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
old_string: 'beta',
new_string: 'gamma',
});
expect(result.output).toContain('Replaced 1 occurrence');
expect(writeText).toHaveBeenCalledWith('/tmp/a.txt', 'alpha gamma');
});
it('expands leading tilde paths using the kaos home directory', async () => {
const readText = vi.fn().mockResolvedValue('alpha beta');
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({ readText, writeText });
const tool = new EditTool(fs, createTestKaos('/home/test'), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '~/notes/today.txt',
old_string: 'beta',
new_string: 'gamma',
});
expect(result.output).toContain('Replaced 1 occurrence');
expect(readText).toHaveBeenCalledWith('/home/test/notes/today.txt');
expect(writeText).toHaveBeenCalledWith('/home/test/notes/today.txt', 'alpha gamma');
});
it('treats replacement dollar sequences literally for single edits', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({
readText: vi.fn().mockResolvedValue('alpha beta gamma'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
old_string: 'beta',
new_string: "$& $$ $` $'",
});
expect(result.output).toContain('Replaced 1 occurrence');
expect(writeText).toHaveBeenCalledWith('/tmp/a.txt', "alpha $& $$ $` $' gamma");
});
it('treats replacement dollar sequences literally for replace_all edits', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({
readText: vi.fn().mockResolvedValue('a b a'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
old_string: 'a',
new_string: '$&',
replace_all: true,
});
expect(result.output).toContain('Replaced 2 occurrences');
expect(writeText).toHaveBeenCalledWith('/tmp/a.txt', '$& b $&');
});
it('matches pure CRLF files through the LF model view and writes back CRLF', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\r\ngamma\r\n'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
old_string: 'alpha\nbeta',
new_string: 'one\ntwo',
});
expect(result.output).toContain('Replaced 1 occurrence');
expect(writeText).toHaveBeenCalledWith('/tmp/a.txt', 'one\r\ntwo\r\ngamma\r\n');
});
it('does not double carriage returns when editing pure CRLF files', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\r\n'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
old_string: 'alpha\nbeta',
new_string: 'one\r\ntwo',
});
expect(result.output).toContain('Replaced 1 occurrence');
expect(writeText).toHaveBeenCalledWith('/tmp/a.txt', 'one\r\ntwo\r\n');
});
it('keeps mixed line ending files on the raw exact path', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\ngamma\r\n'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
old_string: 'alpha\nbeta',
new_string: 'one\ntwo',
});
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('old_string not found');
expect(writeText).not.toHaveBeenCalled();
});
it('allows exact raw edits in mixed line ending files without normalizing the rest', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({
readText: vi.fn().mockResolvedValue('alpha\r\nbeta\ngamma\r\n'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
old_string: 'alpha\r\nbeta',
new_string: 'one\r\ntwo',
});
expect(result.output).toContain('Replaced 1 occurrence');
expect(writeText).toHaveBeenCalledWith('/tmp/a.txt', 'one\r\ntwo\ngamma\r\n');
});
it('replace_all replaces every occurrence', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({
readText: vi.fn().mockResolvedValue('a b a'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
old_string: 'a',
new_string: 'x',
replace_all: true,
});
expect(result.output).toContain('Replaced 2 occurrences');
expect(writeText).toHaveBeenCalledWith('/tmp/a.txt', 'x b x');
});
it('rejects no-op edits before file I/O', async () => {
const readText = vi.fn().mockResolvedValue('same');
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({ readText, writeText });
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
old_string: 'same',
new_string: 'same',
replace_all: true,
});
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('No changes to make');
expect(readText).not.toHaveBeenCalled();
expect(writeText).not.toHaveBeenCalled();
});
it('errors when old_string is missing', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({
readText: vi.fn().mockResolvedValue('alpha beta'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
old_string: 'delta',
new_string: 'gamma',
});
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('old_string not found');
expect(writeText).not.toHaveBeenCalled();
});
it('errors when old_string is not unique and replace_all is false', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({
readText: vi.fn().mockResolvedValue('same same'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/a.txt',
old_string: 'same',
new_string: 'other',
});
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('not unique');
expect(result.output).toContain('set replace_all=true');
expect(result.output).toContain('include more surrounding context');
expect(writeText).not.toHaveBeenCalled();
});
it('rejects relative traversal edits before reading', async () => {
const readText = vi.fn().mockResolvedValue('secret');
const { fs } = createSpiedEditFs({ readText });
const tool = new EditTool(fs, createTestKaos(), {
workspaceDir: '/workspace/project',
additionalDirs: [],
});
const result = await execute(tool, {
path: '../outside.txt',
old_string: 'secret',
new_string: 'x',
});
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('absolute path');
expect(readText).not.toHaveBeenCalled();
});
it('replaces unicode strings (CJK) and round-trips the surrounding text', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({
readText: vi.fn().mockResolvedValue('Hello 世界! café'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/u.txt',
old_string: '世界',
new_string: '地球',
});
expect(result.output).toContain('Replaced 1 occurrence');
expect(writeText).toHaveBeenCalledWith('/tmp/u.txt', 'Hello 地球! café');
});
it('leaves the file byte-identical when old_string is not present', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const original = 'Hello world!';
const { fs } = createSpiedEditFs({
readText: vi.fn().mockResolvedValue(original),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/n.txt',
old_string: 'notfound',
new_string: 'replacement',
});
expect(result.isError).toBe(true);
// Lockdown the negative side-effect: no write should have been issued.
expect(writeText).not.toHaveBeenCalled();
});
it('errors with an is-not-a-file phrasing when the path resolves to a directory', async () => {
// The edit tool relies on readText to surface the directory error; an
// EISDIR-coded rejection maps to the "is not a file" output.
const { fs } = createSpiedEditFs({
readText: vi.fn().mockRejectedValue(
Object.assign(new Error('EISDIR: illegal operation on a directory'), {
code: 'EISDIR',
}),
),
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/dir',
old_string: 'old',
new_string: 'new',
});
expect(result.isError).toBe(true);
expect(result.output).toContain('is not a file');
});
it('replaces a substring with an empty new_string (deletion)', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({
readText: vi.fn().mockResolvedValue('Hello world!'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, {
path: '/tmp/e.txt',
old_string: 'world',
new_string: '',
});
expect(result.output).toContain('Replaced 1 occurrence');
expect(writeText).toHaveBeenCalledWith('/tmp/e.txt', 'Hello !');
});
it('allows absolute edits outside the workspace under default policy', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({
readText: vi.fn().mockResolvedValue('old content'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const result = await execute(tool, {
path: '/tmp/outside.txt',
old_string: 'old',
new_string: 'new',
});
expect(result.isError).toBeFalsy();
expect(writeText).toHaveBeenCalledWith('/tmp/outside.txt', 'new content');
});
it('allows absolute edits to a sibling dir that merely shares the work-dir prefix', async () => {
// /workspace-sneaky/* is outside /workspace — string prefix check must not
// mistake "shares a prefix" for "inside workspace".
const writeText = vi.fn().mockResolvedValue(undefined);
const { fs } = createSpiedEditFs({
readText: vi.fn().mockResolvedValue('content'),
writeText,
});
const tool = new EditTool(fs, createTestKaos(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const result = await execute(tool, {
path: '/workspace-sneaky/test.txt',
old_string: 'content',
new_string: 'new',
});
expect(result.isError).toBeFalsy();
expect(writeText).toHaveBeenCalledWith('/workspace-sneaky/test.txt', 'new');
});
});

View file

@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
import type { IAgentFileSystem, IFsService } from '#/agentFs';
import { FileToolsService } from '#/fileTools';
import type { IKaos } from '#/kaos';
import type { IDisposable } from '#/_base/di';
import type { IToolRegistry } from '#/toolRegistry';
import type { IWorkspaceContext } from '#/workspaceContext';
function fakeToolRegistry(): { registry: IToolRegistry; names: () => string[] } {
const tools = new Map<string, unknown>();
const registry: IToolRegistry = {
_serviceBrand: undefined,
register: vi.fn((tool: { name: string }): IDisposable => {
tools.set(tool.name, tool);
return { dispose: () => tools.delete(tool.name) };
}),
list: () => [...tools.values()] as never,
} as unknown as IToolRegistry;
return { registry, names: () => [...tools.keys()].sort() };
}
const fakeFs = { cwd: '/workspace' } as unknown as IAgentFileSystem;
const fakeFsService = {} as unknown as IFsService;
const fakeKaos = {
cwd: '/workspace',
pathClass: () => 'posix',
gethome: () => '/home',
} as unknown as IKaos;
const fakeWorkspace = {
workDir: '/workspace',
additionalDirs: [],
} as unknown as IWorkspaceContext;
describe('FileToolsService', () => {
it('registers Read/Write/Edit/Grep/Glob into the tool registry', () => {
const { registry, names } = fakeToolRegistry();
new FileToolsService(registry, fakeFs, fakeKaos, fakeWorkspace, fakeFsService);
expect(names()).toEqual(['Edit', 'Glob', 'Grep', 'Read', 'Write']);
});
});

View file

@ -0,0 +1,737 @@
/**
* GlobTool tests for the v2 fileTools domain.
*
* Ported from v1 (`packages/agent-core/test/tools/glob.test.ts`) and adapted
* to the v2 constructor `(fs, kaos, workspace)`. Self-contained: builds minimal
* fake `IAgentFileSystem` (map/spied `glob` + `stat` + `readdir` + `withCwd`)
* and `IKaos` inline so the tool can be exercised without the composition root.
*
* v2 `IAgentFileSystem.glob(pattern)` searches from `fs.cwd` and returns a
* collected array (no per-root async generator), so the v1 `(root, pattern)`
* call assertions become `withCwd(root)` + `glob(pattern)` pairs. v2
* `AgentFileStat` carries no mtime, so the mtime-sort test is adapted to
* assert walk order instead.
*/
import { describe, expect, it, vi } from 'vitest';
import { PathSecurityError, type PathClass } from '../../src/_base/tools/policies/path-access';
import type { WorkspaceConfig } from '../../src/_base/tools/support/workspace';
import type { AgentFileStat, IAgentFileSystem } from '../../src/agentFs';
import {
expandBraces,
type GlobInput,
GlobInputSchema,
GlobTool,
MAX_MATCHES,
} from '../../src/fileTools/tools/glob';
import type { IKaos } from '../../src/kaos';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../src/tool';
const signal = new AbortController().signal;
const workspace: WorkspaceConfig = { workspaceDir: '/workspace', additionalDirs: ['/extra'] };
function fileStat(size = 0): AgentFileStat {
return { isFile: true, isDirectory: false, size };
}
function dirStat(size = 0): AgentFileStat {
return { isFile: false, isDirectory: true, size };
}
function createTestKaos(opts: { home?: string; pathClass?: PathClass } = {}): IKaos {
return {
pathClass: () => opts.pathClass ?? 'posix',
gethome: () => opts.home ?? '/home/test',
} as unknown as IKaos;
}
/**
* Fake fs with spied `glob` / `stat` / `readdir` / `withCwd`. `withCwd` returns
* a derived fs that shares the same spied IO mocks but carries the new `cwd`,
* mirroring the real `AgentFileSystem.withCwd` semantics. The base fs's
* `withCwd` is exposed so tests can assert the resolved search root.
*/
function createSpiedGlobFs(opts: {
cwd?: string;
glob?: ReturnType<typeof vi.fn>;
stat?: ReturnType<typeof vi.fn>;
readdir?: ReturnType<typeof vi.fn>;
} = {}) {
const glob = opts.glob ?? vi.fn(async (): Promise<readonly string[]> => []);
const stat = opts.stat ?? vi.fn(async (): Promise<AgentFileStat> => fileStat());
const readdir = opts.readdir ?? vi.fn(async (): Promise<readonly string[]> => []);
function build(cwd: string): { fs: IAgentFileSystem; withCwd: ReturnType<typeof vi.fn> } {
const withCwd = vi.fn((nextCwd: string) => build(nextCwd).fs);
const fs = { cwd, glob, stat, readdir, withCwd } as unknown as IAgentFileSystem;
return { fs, withCwd };
}
const { fs, withCwd } = build(opts.cwd ?? '/workspace');
return { fs, glob, stat, readdir, withCwd };
}
function isPromiseLike(value: ToolExecution | Promise<ToolExecution>): value is Promise<ToolExecution> {
return typeof (value as Promise<ToolExecution>).then === 'function';
}
async function execute(tool: GlobTool, args: GlobInput): Promise<ExecutableToolResult> {
let execution: ToolExecution;
try {
const resolved = tool.resolveExecution(args);
execution = isPromiseLike(resolved) ? await resolved : resolved;
} catch (error) {
const output =
error instanceof PathSecurityError
? error.message
: `Tool "${tool.name}" failed to resolve execution: ${
error instanceof Error ? error.message : String(error)
}`;
return { isError: true, output };
}
if (execution.isError === true) return execution;
const ctx: ExecutableToolContext = {
turnId: '0',
toolCallId: 'call_glob',
signal,
};
return execution.execute(ctx);
}
function toolContentString(result: ExecutableToolResult): string {
const c = result.output;
if (typeof c !== 'string') {
throw new TypeError(`expected string content, got ${typeof c}`);
}
return c;
}
describe('GlobTool', () => {
it('exposes current metadata and schema', () => {
const { fs } = createSpiedGlobFs();
const tool = new GlobTool(fs, createTestKaos(), workspace);
expect(tool.name).toBe('Glob');
expect(tool.parameters).toMatchObject({
type: 'object',
properties: { pattern: { type: 'string' } },
});
expect(GlobInputSchema.safeParse({ pattern: 'src/**/*.ts' }).success).toBe(true);
expect(GlobInputSchema.safeParse({ pattern: '*.js', path: '/src' }).success).toBe(true);
});
it('exposes the include_dirs default in its JSON Schema without making it required', () => {
const { fs } = createSpiedGlobFs();
const tool = new GlobTool(fs, createTestKaos(), workspace);
const schema = tool.parameters as {
properties: { include_dirs: { default?: unknown } };
required?: string[];
};
// The default must be structurally visible to the model, not only
// described in prose, so it survives without an explicit argument.
expect(schema.properties.include_dirs.default).toBe(true);
// A default value must not promote include_dirs into `required`.
expect(schema.required ?? []).not.toContain('include_dirs');
});
it('injects the Windows path hint into the description on a win32 backend', () => {
const { fs } = createSpiedGlobFs();
const tool = new GlobTool(fs, createTestKaos({ pathClass: 'win32' }), workspace);
expect(tool.description).toContain('Windows');
expect(tool.description).toContain('forward slashes');
expect(tool.description).toContain('Bash');
});
it('omits the Windows path hint from the description on a non-Windows backend', () => {
const { fs } = createSpiedGlobFs();
const tool = new GlobTool(fs, createTestKaos({ pathClass: 'posix' }), workspace);
expect(tool.description).not.toContain('forward slashes');
});
it('returns matching paths in walk order, relative to an explicit search root', async () => {
// v1 sorted by mtime; v2 `AgentFileStat` carries no mtime, so the
// result order is the glob yield order.
const glob = vi.fn(async () => ['/workspace/src/old.ts', '/workspace/src/new.ts']);
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'src/**/*.ts', path: '/workspace' });
expect(result.output).toBe('src/old.ts\nsrc/new.ts');
expect(withCwd).toHaveBeenCalledWith('/workspace');
expect(glob).toHaveBeenCalledWith('src/**/*.ts');
});
it('uses the backend path class when displaying paths relative to a windows root', async () => {
const glob = vi.fn(async () => ['C:\\workspace\\src\\old.ts']);
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos({ pathClass: 'win32' }), {
workspaceDir: 'C:\\workspace',
additionalDirs: [],
});
const result = await execute(tool, { pattern: 'src/**/*.ts', path: 'C:\\WORKSPACE' });
expect(result.output).toBe('src/old.ts');
expect(withCwd).toHaveBeenCalledWith('C:/WORKSPACE');
expect(glob).toHaveBeenCalledWith('src/**/*.ts');
});
it('walks pure-wildcard patterns instead of rejecting them, capping at MAX_MATCHES', async () => {
// Previously rejected up-front; now the 100-match cap is the only
// safety. Verifies the pattern reaches the filesystem and the cap fires.
const paths = Array.from({ length: MAX_MATCHES + 5 }, (_, i) => `/workspace/${String(i)}.ts`);
const glob = vi.fn(async () => paths);
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: '**' });
expect(result.isError).toBeFalsy();
expect(withCwd).toHaveBeenCalledWith('/workspace');
expect(glob).toHaveBeenCalledWith('**');
expect(result.output).toContain(`[Truncated at ${String(MAX_MATCHES)} matches`);
});
it('expands brace patterns into multiple sub-pattern walks and dedups paths', async () => {
// `*.{ts,tsx}` → two glob calls with `*.ts` and `*.tsx`. Shared hits
// are deduped so the same file does not appear twice.
const glob = vi.fn(async (pattern: string): Promise<readonly string[]> => {
if (pattern === '*.ts') return ['/workspace/a.ts', '/workspace/shared.ts'];
if (pattern === '*.tsx') return ['/workspace/shared.tsx', '/workspace/shared.ts'];
return [];
});
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: '*.{ts,tsx}' });
expect(result.isError).toBeFalsy();
expect(withCwd).toHaveBeenCalledWith('/workspace');
expect(glob).toHaveBeenCalledWith('*.ts');
expect(glob).toHaveBeenCalledWith('*.tsx');
const output = toolContentString(result);
const lines = output.split('\n').filter((l) => l.endsWith('.ts') || l.endsWith('.tsx'));
expect(lines).toContain('a.ts');
expect(lines).toContain('shared.ts');
expect(lines).toContain('shared.tsx');
// Dedup: shared.ts appears only once even though both sub-patterns yielded it.
expect(lines.filter((l) => l === 'shared.ts')).toHaveLength(1);
});
it('collapses redundant separators after brace expansion', async () => {
// `src//*.{ts,tsx}` → expandBraces → `src//*.ts` / `src//*.tsx` →
// normalize → `src/*.ts` / `src/*.tsx`.
const glob = vi.fn(async (pattern: string): Promise<readonly string[]> => {
if (pattern === 'src/*.ts') return ['/workspace/src/a.ts'];
if (pattern === 'src/*.tsx') return ['/workspace/src/b.tsx'];
return [];
});
const { fs } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'src//*.{ts,tsx}' });
expect(result.isError).toBeFalsy();
expect(glob).toHaveBeenCalledWith('src/*.ts');
expect(glob).toHaveBeenCalledWith('src/*.tsx');
});
it('removes a leading ./ after brace expansion', async () => {
// `./src/*.{ts,tsx}` → expandBraces → `./src/*.ts` / `./src/*.tsx` →
// normalize → `src/*.ts` / `src/*.tsx`.
const glob = vi.fn(async (pattern: string): Promise<readonly string[]> => {
if (pattern === 'src/*.ts') return ['/workspace/src/a.ts'];
if (pattern === 'src/*.tsx') return ['/workspace/src/b.tsx'];
return [];
});
const { fs } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: './src/*.{ts,tsx}' });
expect(result.isError).toBeFalsy();
expect(glob).toHaveBeenCalledWith('src/*.ts');
expect(glob).toHaveBeenCalledWith('src/*.tsx');
});
it('normalizes `..` inside a brace alternative without collapsing across the braces', async () => {
// `src/{foo/../bar,baz}/*.ts` must first split on the brace group,
// *then* normalize each alternative — otherwise pathe collapses
// `foo/../bar,baz}` together and the whole brace structure is lost.
const glob = vi.fn(async (pattern: string): Promise<readonly string[]> => {
if (pattern === 'src/bar/*.ts') return ['/workspace/src/bar/a.ts'];
if (pattern === 'src/baz/*.ts') return ['/workspace/src/baz/b.ts'];
return [];
});
const { fs } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'src/{foo/../bar,baz}/*.ts' });
expect(result.isError).toBeFalsy();
expect(glob).toHaveBeenCalledWith('src/bar/*.ts');
expect(glob).toHaveBeenCalledWith('src/baz/*.ts');
});
it('preserves backslash-escaped glob metacharacters end-to-end', async () => {
// `\{a,b\}.ts` opts out of brace expansion (the user wants to match a
// file literally named `{a,b}.ts`). glob must receive the pattern
// unchanged — running pathe.normalize over it would rewrite the escape
// backslashes into path separators and break the intent.
const glob = vi.fn(async (pattern: string): Promise<readonly string[]> => {
if (pattern === '\\{a,b\\}.ts') return ['/workspace/{a,b}.ts'];
return [];
});
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: '\\{a,b\\}.ts' });
expect(result.isError).toBeFalsy();
expect(withCwd).toHaveBeenCalledWith('/workspace');
expect(glob).toHaveBeenCalledWith('\\{a,b\\}.ts');
// And it must *not* have been called with any brace-expanded form.
expect(glob).not.toHaveBeenCalledWith(expect.stringContaining('/'));
});
it('searches only the current workspace when path is omitted', async () => {
const glob = vi.fn(async () => ['/workspace/a.ts', '/workspace/shared.ts']);
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: '*.ts' });
expect(glob).toHaveBeenCalledTimes(1);
expect(withCwd).toHaveBeenCalledWith('/workspace');
expect(glob).toHaveBeenCalledWith('*.ts');
expect(result.output).toBe('a.ts\nshared.ts');
});
it('can search an additional directory when path is explicit', async () => {
const glob = vi.fn(async () => ['/extra/pkg/a.ts']);
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'pkg/**/*.ts', path: '/extra' });
expect(result.output).toBe('/extra/pkg/a.ts');
expect(glob).toHaveBeenCalledTimes(1);
expect(withCwd).toHaveBeenCalledWith('/extra');
expect(glob).toHaveBeenCalledWith('pkg/**/*.ts');
});
it('filters directories when include_dirs is false', async () => {
const glob = vi.fn(async () => ['/workspace/src', '/workspace/src/a.ts']);
const stat = vi
.fn()
.mockResolvedValueOnce(dirStat(2))
.mockResolvedValueOnce(fileStat(1));
const { fs } = createSpiedGlobFs({ glob, stat });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, {
pattern: 'src*',
path: '/workspace',
include_dirs: false,
});
expect(result.output).toBe('src/a.ts');
});
it('caps returned matches and surfaces the truncation header', async () => {
const paths = Array.from({ length: MAX_MATCHES + 1 }, (_, i) => `/workspace/${String(i)}.ts`);
const glob = vi.fn(async () => paths);
const { fs } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const result = await execute(tool, { pattern: '*.ts' });
expect(result.output).toContain(
`[Truncated at ${String(MAX_MATCHES)} matches — ${String(MAX_MATCHES)} matched so far, use a more specific pattern]`,
);
expect(result.output).toContain('0.ts');
expect(result.output).not.toContain(`${String(MAX_MATCHES)}.ts`);
});
describe('skills / additional dirs', () => {
const skillsWorkspace: WorkspaceConfig = {
workspaceDir: '/workspace',
additionalDirs: ['/skills'],
};
it('searches inside a registered additionalDir entry', async () => {
const glob = vi.fn(async () => ['/skills/read_content.py', '/skills/utils.py']);
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), skillsWorkspace);
const result = await execute(tool, { pattern: '*.py', path: '/skills' });
expect(result.output).toContain('/skills/read_content.py');
expect(result.output).toContain('/skills/utils.py');
expect(withCwd).toHaveBeenCalledWith('/skills');
expect(glob).toHaveBeenCalledWith('*.py');
});
it('searches inside a subdirectory of an additionalDir entry', async () => {
const glob = vi.fn(async () => ['/skills/feishu/scripts/read_content.py']);
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), skillsWorkspace);
const result = await execute(tool, {
pattern: '*.py',
path: '/skills/feishu/scripts',
});
expect(result.output).toContain('/skills/feishu/scripts/read_content.py');
expect(withCwd).toHaveBeenCalledWith('/skills/feishu/scripts');
});
it('rejects a relative path that escapes both workspace and additionalDirs', async () => {
const glob = vi.fn(async (): Promise<readonly string[]> => []);
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), {
workspaceDir: '/workspace/project',
additionalDirs: ['/skills'],
});
const result = await execute(tool, { pattern: '*.py', path: '../../tmp/evil' });
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('absolute path');
expect(glob).not.toHaveBeenCalled();
expect(withCwd).not.toHaveBeenCalled();
});
it('accepts a path inside a deeply nested additionalDir entry', async () => {
const glob = vi.fn(async () => ['/skills/my-skill/scripts/helper.py']);
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), skillsWorkspace);
const result = await execute(tool, {
pattern: '*.py',
path: '/skills/my-skill/scripts',
});
expect(result.output).toContain('/skills/my-skill/scripts/helper.py');
expect(withCwd).toHaveBeenCalledWith('/skills/my-skill/scripts');
});
});
it('walks "**/" prefix patterns with a literal anchor instead of rejecting them', async () => {
// Previously a hard reject; now `**/*.py` reaches the filesystem like
// any other pattern and the 100-match cap is the only safety.
const glob = vi.fn(async () => ['/workspace/a.py', '/workspace/sub/b.py']);
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: '**/*.py' });
expect(result.isError).toBeFalsy();
expect(withCwd).toHaveBeenCalledWith('/workspace');
expect(glob).toHaveBeenCalledWith('**/*.py');
expect(result.output).toContain('a.py');
expect(result.output).toContain('sub/b.py');
});
it('walks safe recursive patterns with a literal subdirectory anchor', async () => {
const glob = vi.fn(async () => [
'/workspace/src/main.py',
'/workspace/src/utils.py',
'/workspace/src/main/app.py',
'/workspace/src/main/config.py',
'/workspace/src/test/test_app.py',
'/workspace/src/test/test_config.py',
]);
const { fs } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'src/**/*.py', path: '/workspace' });
expect(result.output).toContain('src/main.py');
expect(result.output).toContain('src/utils.py');
expect(result.output).toContain('src/main/app.py');
expect(result.output).toContain('src/main/config.py');
expect(result.output).toContain('src/test/test_app.py');
expect(result.output).toContain('src/test/test_config.py');
});
it('surfaces an explicit no-match message when no paths are yielded', async () => {
const glob = vi.fn(async (): Promise<readonly string[]> => []);
const { fs } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: '*.xyz', path: '/workspace' });
expect(result.isError).toBeFalsy();
expect(result.output).toContain('No matches found');
});
it('reports "does not exist" when the search directory is missing', async () => {
// Real fs.glob silently returns empty for a missing root because its
// kaos walker catches readdir failures. The tool pre-checks with
// readdir so ENOENT surfaces before glob runs. Realistic mock: readdir
// throws ENOENT, glob is never called.
const readdir = vi.fn(async (): Promise<readonly string[]> => {
throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' });
});
const glob = vi.fn(async (): Promise<readonly string[]> => []);
const { fs, withCwd } = createSpiedGlobFs({ readdir, glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: '*.py', path: '/workspace/nonexistent' });
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('does not exist');
expect(glob).not.toHaveBeenCalled();
expect(withCwd).not.toHaveBeenCalled();
});
it('reports "is not a directory" when the search target is a file', async () => {
// Real fs.glob silently returns empty when the root is a regular file
// because its kaos walker's readdir hits ENOTDIR and exits. The
// pre-check uses readdir, which raises ENOTDIR on file-as-dir.
// Realistic mock: readdir throws ENOTDIR, glob is never called.
const readdir = vi.fn(async (): Promise<readonly string[]> => {
throw Object.assign(new Error('ENOTDIR: not a directory'), { code: 'ENOTDIR' });
});
const glob = vi.fn(async (): Promise<readonly string[]> => []);
const { fs, withCwd } = createSpiedGlobFs({ readdir, glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: '*.py', path: '/workspace/file.txt' });
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('is not a directory');
expect(glob).not.toHaveBeenCalled();
expect(withCwd).not.toHaveBeenCalled();
});
it('surfaces a "first N matches" header when matches exceed MAX_MATCHES', async () => {
const paths = Array.from(
{ length: MAX_MATCHES + 50 },
(_, i) => `/workspace/file_${String(i)}.txt`,
);
const glob = vi.fn(async () => paths);
const { fs } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const result = await execute(tool, { pattern: '*.txt' });
expect(result.output).toContain(`Only the first ${String(MAX_MATCHES)} matches are returned`);
});
it('returns a "Found N matches" footer at exactly MAX_MATCHES without truncation', async () => {
const paths = Array.from({ length: MAX_MATCHES }, (_, i) => `/workspace/test_${String(i)}.py`);
const glob = vi.fn(async () => paths);
const { fs } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const result = await execute(tool, { pattern: '*.py' });
expect(result.output).not.toContain('Only the first');
expect(result.output).toContain(`Found ${String(MAX_MATCHES)} matches`);
});
it('walks "**/" patterns with literal subdirectory anchors after the prefix', async () => {
// Previously rejected up-front; now `**/main/*.py` walks like any
// other anchored pattern.
const glob = vi.fn(async () => ['/workspace/src/main/app.py']);
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: '**/main/*.py' });
expect(result.isError).toBeFalsy();
expect(withCwd).toHaveBeenCalledWith('/workspace');
expect(glob).toHaveBeenCalledWith('**/main/*.py');
expect(result.output).toContain('src/main/app.py');
});
it('matches dotfiles like .gitlab-ci.yml under a simple "*.yml" pattern', async () => {
const glob = vi.fn(async () => ['/workspace/.gitlab-ci.yml', '/workspace/config.yml']);
const { fs } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: '*.yml' });
expect(result.output).toContain('.gitlab-ci.yml');
expect(result.output).toContain('config.yml');
});
it('descends into hidden directories under a recursive pattern', async () => {
const glob = vi.fn(async () => ['/workspace/src/.config/settings.yml']);
const { fs } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'src/**/*.yml' });
expect(result.output).toContain('src/.config/settings.yml');
});
it('matches files inside an explicitly addressed hidden directory', async () => {
const glob = vi.fn(async () => ['/workspace/.github/workflows/ci.yml']);
const { fs } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: '.github/**/*.yml' });
expect(result.output).toContain('.github/workflows/ci.yml');
});
it('shows absolute paths when explicit search root is outside all workspace roots', async () => {
// When the search root is not inside workspaceDir, matches must stay
// absolute in the output. Otherwise the model would resolve a
// relativized path against the workspace cwd and hit the wrong file.
const glob = vi.fn(async () => ['/extra/test.py']);
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const result = await execute(tool, { pattern: '*.py', path: '/extra' });
expect(result.isError).toBeFalsy();
expect(result.output).toBe('/extra/test.py');
expect(withCwd).toHaveBeenCalledWith('/extra');
});
it('keeps absolute paths when explicit search root is an additionalDir', async () => {
// AdditionalDirs are searchable, but model-visible relative paths
// still resolve against workspaceDir in follow-up Read/Edit calls, so
// matches under an additionalDir stay absolute.
const registered: WorkspaceConfig = { workspaceDir: '/workspace', additionalDirs: ['/extra'] };
const glob = vi.fn(async () => ['/extra/test.py']);
const { fs } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), registered);
const result = await execute(tool, { pattern: '*.py', path: '/extra' });
expect(result.isError).toBeFalsy();
expect(result.output).toBe('/extra/test.py');
});
it('allows a relative path argument that resolves inside the workspace', async () => {
const glob = vi.fn(async () => ['/workspace/relative/path/test.py']);
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: '*.py', path: 'relative/path' });
expect(result.isError).toBeFalsy();
expect(result.output).toContain('test.py');
expect(withCwd).toHaveBeenCalledWith('/workspace/relative/path');
expect(glob).toHaveBeenCalledWith('*.py');
});
it('expands a leading "~/" path before searching outside the workspace', async () => {
const glob = vi.fn(async (): Promise<readonly string[]> => []);
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos({ home: '/home/test' }), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const result = await execute(tool, { pattern: '*.py', path: '~/' });
expect(result.isError).toBeFalsy();
expect(result.output).toBe('No matches found');
expect(withCwd).toHaveBeenCalledWith('/home/test');
expect(glob).toHaveBeenCalledWith('*.py');
});
it('allows a path sharing the workspace prefix when it is absolute', async () => {
const glob = vi.fn(async (): Promise<readonly string[]> => []);
const { fs, withCwd } = createSpiedGlobFs({ glob });
const tool = new GlobTool(fs, createTestKaos(), {
workspaceDir: '/parent/workdir',
additionalDirs: [],
});
const result = await execute(tool, { pattern: '*.py', path: '/parent/workdir-sneaky' });
expect(result.isError).toBeFalsy();
expect(result.output).toBe('No matches found');
expect(withCwd).toHaveBeenCalledWith('/parent/workdir-sneaky');
expect(glob).toHaveBeenCalledWith('*.py');
});
it('locks down brace-expansion mention and large-directory caveats in the description', () => {
const { fs } = createSpiedGlobFs();
const tool = new GlobTool(fs, createTestKaos(), workspace);
expect(tool.description).toContain('**');
expect(tool.description).toMatch(/\*\*\/\*\.py/);
expect(tool.description).toContain('brace expansion');
expect(tool.description).toContain('node_modules');
expect(tool.description).not.toContain('On Windows');
});
it('mentions Windows path forms in the description on win32 backends', () => {
const { fs } = createSpiedGlobFs();
const tool = new GlobTool(fs, createTestKaos({ pathClass: 'win32' }), {
workspaceDir: 'C:\\workspace',
additionalDirs: [],
});
expect(tool.description).toContain('C:\\Users\\foo');
expect(tool.description).toContain('/c/Users/foo');
});
});
describe('expandBraces', () => {
it('returns the original pattern unchanged when there is no brace group', () => {
expect(expandBraces('src/**/*.ts')).toEqual(['src/**/*.ts']);
});
it('expands a single top-level brace group into one pattern per alternative', () => {
expect(expandBraces('*.{ts,tsx}')).toEqual(['*.ts', '*.tsx']);
});
it('produces the cartesian product when more than one brace group appears', () => {
expect(expandBraces('{src,test}/{a,b}.ts')).toEqual([
'src/a.ts',
'src/b.ts',
'test/a.ts',
'test/b.ts',
]);
});
it('recursively expands nested brace groups', () => {
expect(expandBraces('{a,{b,c}}.ts')).toEqual(['a.ts', 'b.ts', 'c.ts']);
});
it('falls through with the literal pattern when a brace group has no top-level comma', () => {
// bash also treats `{abc}` as a literal; we follow the same rule.
expect(expandBraces('{abc}.ts')).toEqual(['{abc}.ts']);
});
it('falls through with the literal pattern when braces are unbalanced', () => {
expect(expandBraces('{a,b.ts')).toEqual(['{a,b.ts']);
expect(expandBraces('a,b}.ts')).toEqual(['a,b}.ts']);
});
it('treats backslash-escaped braces as literals and does not expand them', () => {
expect(expandBraces('\\{a,b\\}.ts')).toEqual(['\\{a,b\\}.ts']);
});
it('falls back to the original pattern when expansion would exceed the fan-out cap', () => {
// Seven groups of 3 alternatives = 3^7 = 2187 patterns, well above
// the MAX_BRACE_EXPANSIONS = 64 cap. Falling back is preferred over
// silently dropping alternatives.
const pathological = '{a,b,c}{d,e,f}{g,h,i}{j,k,l}{m,n,o}{p,q,r}{s,t,u}';
expect(expandBraces(pathological)).toEqual([pathological]);
});
});

View file

@ -0,0 +1,393 @@
/**
* GrepTool tests for the v2 fileTools domain.
*
* Ported from v1 (`packages/agent-core/test/tools/grep.test.ts`) and adapted
* to the v2 constructor `(fs, kaos, workspace)`. Self-contained: builds a
* minimal fake `IFsService` returning canned `FsGrepResponse`s so the tool's
* argument mapping and result rendering can be exercised without the
* composition root or a real ripgrep. The v1 tests that asserted on the exact
* `rg` argv (which the tool no longer builds `IFsService.grep` owns the
* subprocess) are intentionally dropped here.
*/
import type { FsGrepFileHit, FsGrepRequest, FsGrepResponse } from '@moonshot-ai/protocol';
import { describe, expect, it, vi } from 'vitest';
import type { WorkspaceConfig } from '../../src/_base/tools/support/workspace';
import type { IFsService } from '../../src/agentFs';
import {
type GrepInput,
GrepInputSchema,
GrepTool,
} from '../../src/fileTools/tools/grep';
import type { IKaos } from '../../src/kaos';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../src/tool';
const signal = new AbortController().signal;
const workspace: WorkspaceConfig = { workspaceDir: '/workspace', additionalDirs: ['/extra'] };
function fileHit(path: string, lines: number[] = [1], text = 'hit'): FsGrepFileHit {
return {
path,
matches: lines.map((line) => ({ line, col: 1, text, before: [], after: [] })),
};
}
function emptyResponse(overrides: Partial<FsGrepResponse> = {}): FsGrepResponse {
return { files: [], files_scanned: 0, truncated: false, elapsed_ms: 1, ...overrides };
}
function createFakeFs(
response: FsGrepResponse | ((req: FsGrepRequest) => FsGrepResponse | Promise<FsGrepResponse>),
) {
const grep = vi.fn(async (req: FsGrepRequest) =>
typeof response === 'function' ? response(req) : response,
);
const fs = { grep } as unknown as IFsService;
return { fs, grep };
}
function createTestKaos(home = '/home'): IKaos {
return {
pathClass: () => 'posix',
gethome: () => home,
} as unknown as IKaos;
}
function isPromiseLike(value: ToolExecution | Promise<ToolExecution>): value is Promise<ToolExecution> {
return typeof (value as Promise<ToolExecution>).then === 'function';
}
async function execute(tool: GrepTool, args: GrepInput): Promise<ExecutableToolResult> {
const resolved = tool.resolveExecution(args);
const execution = isPromiseLike(resolved) ? await resolved : resolved;
if (execution.isError === true) return execution;
const ctx: ExecutableToolContext = {
turnId: '0',
toolCallId: 'call_grep',
signal,
};
return execution.execute(ctx);
}
function toolContentString(result: ExecutableToolResult): string {
const c = result.output;
if (typeof c !== 'string') {
throw new TypeError(`expected string content, got ${typeof c}`);
}
return c;
}
describe('GrepTool', () => {
it('exposes current metadata and schema', () => {
const { fs } = createFakeFs(emptyResponse());
const tool = new GrepTool(fs, createTestKaos(), workspace);
expect(tool.name).toBe('Grep');
expect(tool.description).toContain('unknown content or unknown file locations');
expect(tool.description).toContain('Do not use shell `grep` or `rg` directly');
expect(tool.parameters).toMatchObject({
type: 'object',
properties: {
pattern: {
type: 'string',
description: expect.stringContaining('Regular expression'),
},
path: {
description: expect.stringContaining('Use Read instead'),
},
},
});
expect(GrepInputSchema.safeParse({ pattern: 'needle' }).success).toBe(true);
expect(GrepInputSchema.safeParse({ pattern: 'needle', output_mode: 'content' }).success).toBe(
true,
);
expect(GrepInputSchema.safeParse({ pattern: 'needle', output_mode: 'bad' }).success).toBe(
false,
);
expect(
GrepInputSchema.safeParse({ pattern: 'needle', output_mode: 'count_matches' }).success,
).toBe(true);
});
it('returns matching files in the default files_with_matches mode', async () => {
const { fs, grep } = createFakeFs(
emptyResponse({ files: [fileHit('src/a.ts'), fileHit('src/b.ts')] }),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'hit' });
expect(toolContentString(result)).toBe('src/a.ts\nsrc/b.ts');
expect(grep).toHaveBeenCalledTimes(1);
});
it('renders content matches as path:line:text', async () => {
const { fs } = createFakeFs(
emptyResponse({ files: [fileHit('src/a.ts', [10, 20])] }),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'hit', output_mode: 'content' });
expect(toolContentString(result)).toBe('src/a.ts:10:hit\nsrc/a.ts:20:hit');
});
it('treats the pattern as a regex when calling the fs layer', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
await execute(tool, { pattern: 'foo|bar' });
const req = grep.mock.calls[0]?.[0] as FsGrepRequest;
expect(req.pattern).toBe('foo|bar');
expect(req.regex).toBe(true);
});
it('maps -i to a case-insensitive request', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
await execute(tool, { pattern: 'Hit', '-i': true });
const req = grep.mock.calls[0]?.[0] as FsGrepRequest;
expect(req.case_sensitive).toBe(false);
});
it('is case-sensitive by default', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
await execute(tool, { pattern: 'Hit' });
const req = grep.mock.calls[0]?.[0] as FsGrepRequest;
expect(req.case_sensitive).toBe(true);
});
it('maps glob to include_globs and leaves exclude_globs empty', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
await execute(tool, { pattern: 'hit', glob: '*.ts' });
const req = grep.mock.calls[0]?.[0] as FsGrepRequest;
expect(req.include_globs).toEqual(['*.ts']);
expect(req.exclude_globs).toBeUndefined();
});
it('passes an exclude-style glob through include_globs verbatim', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
await execute(tool, { pattern: 'hit', glob: '!**/*.test.ts' });
const req = grep.mock.calls[0]?.[0] as FsGrepRequest;
expect(req.include_globs).toEqual(['!**/*.test.ts']);
});
it('maps type to a recursive include glob', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
await execute(tool, { pattern: 'hit', type: 'ts' });
const req = grep.mock.calls[0]?.[0] as FsGrepRequest;
expect(req.include_globs).toEqual(['**/*.ts']);
});
it('maps include_ignored to follow_gitignore=false', async () => {
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
await execute(tool, { pattern: 'hit', include_ignored: true });
const req = grep.mock.calls[0]?.[0] as FsGrepRequest;
expect(req.follow_gitignore).toBe(false);
});
it('surfaces fs-layer truncation as a warning', async () => {
const { fs } = createFakeFs(
emptyResponse({ files: [fileHit('src/a.ts')], truncated: true }),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'hit' });
const output = toolContentString(result);
expect(output).toContain('src/a.ts');
expect(output).toContain('stopped early');
expect(output).toContain('incomplete');
});
it('returns a clean no-match result', async () => {
const { fs, grep } = createFakeFs(emptyResponse());
const tool = new GrepTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'missing' });
expect(result.isError).toBeFalsy();
expect(toolContentString(result)).toBe('No matches found');
expect(grep).toHaveBeenCalledTimes(1);
});
it('applies offset and head_limit pagination in files_with_matches mode', async () => {
const { fs } = createFakeFs(
emptyResponse({
files: [fileHit('a.ts'), fileHit('b.ts'), fileHit('c.ts'), fileHit('d.ts')],
}),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'hit', offset: 1, head_limit: 2 });
const output = toolContentString(result);
expect(output).toContain('b.ts');
expect(output).toContain('c.ts');
expect(output).not.toContain('a.ts');
expect(output).not.toContain('d.ts');
expect(output).toContain('Results truncated to 2 lines (total: 4). Use offset=3 to see more.');
});
it('treats head_limit zero as unlimited', async () => {
const files = Array.from({ length: 260 }, (_, i) => fileHit(`src/${String(i)}.ts`));
const { fs } = createFakeFs(emptyResponse({ files }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'hit', head_limit: 0 });
const output = toolContentString(result);
expect(output.split('\n')).toHaveLength(260);
expect(output).not.toContain('Results truncated');
});
it('limits files_with_matches output to 250 lines by default', async () => {
const files = Array.from({ length: 251 }, (_, i) => fileHit(`src/${String(i)}.ts`));
const { fs } = createFakeFs(emptyResponse({ files }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'hit' });
const output = toolContentString(result);
expect(output).toContain('src/0.ts');
expect(output).toContain('src/249.ts');
expect(output).not.toContain('src/250.ts');
expect(output).toContain(
'Results truncated to 250 lines (total: 251). Use offset=250 to see more.',
);
});
it('summarizes count_matches on the message channel', async () => {
const { fs } = createFakeFs(
emptyResponse({ files: [fileHit('src/a.ts', [1, 2, 3]), fileHit('src/b.ts', [1, 2])] }),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'hit', output_mode: 'count_matches' });
expect(toolContentString(result)).toBe('src/a.ts:3\nsrc/b.ts:2');
expect(result.message).toBe('Found 5 total occurrences across 2 files.');
});
it('keeps count data pure and routes pagination to the message channel', async () => {
const { fs } = createFakeFs(
emptyResponse({ files: [fileHit('a.ts', [1]), fileHit('b.ts', [1]), fileHit('c.ts', [1])] }),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const result = await execute(tool, {
pattern: 'hit',
output_mode: 'count_matches',
head_limit: 2,
});
const output = toolContentString(result);
expect(output).toBe('a.ts:1\nb.ts:1');
expect(result.message).toContain('Found 3 total occurrences across 3 files.');
expect(result.message).toContain('Results truncated to 2 lines (total: 3). Use offset=2 to see more.');
});
it('filters sensitive files and appends a warning', async () => {
const { fs } = createFakeFs(
emptyResponse({ files: [fileHit('src/main.ts'), fileHit('.env')] }),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'hit' });
const output = toolContentString(result);
expect(output).toContain('src/main.ts');
expect(output).not.toContain('.env:');
expect(output).toContain('Filtered 1 sensitive file(s): .env');
});
it('reports no non-sensitive matches when every result is sensitive', async () => {
const { fs } = createFakeFs(emptyResponse({ files: [fileHit('.env')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'hit', output_mode: 'content' });
const output = toolContentString(result);
expect(output).toContain('No non-sensitive matches found');
expect(output).toContain('Filtered 1 sensitive file(s): .env');
});
it('renders context lines with computed line numbers in content mode', async () => {
const { fs } = createFakeFs(
emptyResponse({
files: [
{
path: 'src/a.ts',
matches: [
{
line: 5,
col: 1,
text: 'match',
before: ['pre'],
after: ['post'],
},
],
},
],
}),
);
const tool = new GrepTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'match', output_mode: 'content', '-C': 1 });
expect(toolContentString(result)).toBe('src/a.ts-4-pre\nsrc/a.ts:5:match\nsrc/a.ts-6-post');
});
it('aborts before searching when the signal is already aborted', async () => {
const controller = new AbortController();
controller.abort();
const { fs, grep } = createFakeFs(emptyResponse({ files: [fileHit('src/a.ts')] }));
const tool = new GrepTool(fs, createTestKaos(), workspace);
const resolved = tool.resolveExecution({ pattern: 'hit' });
const execution = isPromiseLike(resolved) ? await resolved : resolved;
if (execution.isError === true) throw new TypeError('expected runnable execution');
const result = await execution.execute({
turnId: '0',
toolCallId: 'call_grep',
signal: controller.signal,
});
expect(result).toEqual({ isError: true, output: 'Aborted before search started' });
expect(grep).not.toHaveBeenCalled();
});
it('maps an fs timeout error to a friendly message', async () => {
const { KimiError, ErrorCodes } = await import('../../src/errors');
const { fs } = createFakeFs(() => {
throw new KimiError(ErrorCodes.FS_GREP_TIMEOUT, 'grep timed out after 30000ms');
});
const tool = new GrepTool(fs, createTestKaos(), workspace);
const result = await execute(tool, { pattern: 'slow' });
expect(result).toEqual({
isError: true,
output: 'Grep timed out. Try a more specific path or pattern.',
});
});
});

View file

@ -0,0 +1,842 @@
/**
* ReadTool tests for the v2 fileTools domain.
*
* Ported from v1 (`packages/agent-core/test/tools/read.test.ts`) and adapted
* to the v2 constructor `(fs, kaos, workspace)`. Self-contained: builds minimal
* fake `IAgentFileSystem` and `IKaos` inline so the tool can be exercised
* without the composition root.
*
* The v1 fast-path tests (`scanTextFile` / `readLineRange` / `readTailLines` /
* `readTextPreview`) are intentionally dropped: `IAgentFileSystem` streams
* through `readLines` only, so `readForward` / `readTail` always take the
* line-iteration path.
*/
import { describe, expect, it, vi } from 'vitest';
import { PathSecurityError } from '../../src/_base/tools/policies/path-access';
import { MEDIA_SNIFF_BYTES } from '../../src/_base/tools/support/file-type';
import type { WorkspaceConfig } from '../../src/_base/tools/support/workspace';
import type { IAgentFileSystem } from '../../src/agentFs';
import {
MAX_BYTES,
MAX_LINE_LENGTH,
MAX_LINES,
type ReadInput,
ReadInputSchema,
ReadTool,
} from '../../src/fileTools/tools/read';
import type { IKaos } from '../../src/kaos';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../src/tool';
const signal = new AbortController().signal;
const PERMISSIVE_WORKSPACE: WorkspaceConfig = { workspaceDir: '/', additionalDirs: [] };
function linesFromContent(content: string): string[] {
if (content === '') return [];
const rawLines = content.split('\n');
return rawLines.flatMap((line, index) => {
if (index < rawLines.length - 1) return [`${line}\n`];
return line === '' ? [] : [line];
});
}
async function* generateLines(content: string): AsyncGenerator<string> {
for (const line of linesFromContent(content)) {
yield line;
}
}
function withReadStatus(output: string, status: string): string {
const note = `<system>${status}</system>`;
return output.length > 0 ? `${output}\n${note}` : note;
}
function toolContentString(result: ExecutableToolResult): string {
const c = result.output;
if (typeof c !== 'string') {
throw new TypeError(`expected string content, got ${typeof c}`);
}
return c;
}
function createTestKaos(home = '/home'): IKaos {
return {
pathClass: () => 'posix',
gethome: () => home,
} as unknown as IKaos;
}
/**
* Fake fs backed by a single text content for any path. All IO methods are
* vi.fn() spies so tests can assert on the sniff/readLines/readText calls.
*/
function createSpiedFs(content: string) {
const bytes = Buffer.from(content, 'utf8');
const readBytes = vi.fn(async (_path: string, n?: number) =>
n === undefined ? bytes : bytes.subarray(0, n),
);
const readLines = vi.fn().mockImplementation(() => generateLines(content));
const readText = vi.fn(async () => content);
const stat = vi.fn(async () => ({ isFile: true, isDirectory: false, size: bytes.length }));
const fs = { cwd: '/', readBytes, readLines, readText, stat } as unknown as IAgentFileSystem;
return { fs, readBytes, readLines, readText, stat };
}
interface FakeFile {
readonly bytes: Buffer;
readonly isFile?: boolean;
readonly isDirectory?: boolean;
readonly size?: number;
readonly readLines?: (
path: string,
options?: { errors?: 'strict' | 'replace' | 'ignore' },
) => AsyncGenerator<string>;
}
/**
* Fake fs backed by an in-memory path file map. `stat` throws ENOENT for
* unknown paths; IO methods are vi.fn() spies for call assertions.
*/
function createSpiedMapFs(files: Record<string, FakeFile>) {
const lookup = (path: string): FakeFile | undefined => files[path];
const readBytes = vi.fn(async (path: string, n?: number) => {
const data = lookup(path)?.bytes ?? Buffer.alloc(0);
return n === undefined ? data : data.subarray(0, n);
});
const readLines = vi
.fn()
.mockImplementation((path: string, options?: { errors?: 'strict' | 'replace' | 'ignore' }) => {
const file = lookup(path);
if (file?.readLines !== undefined) return file.readLines(path, options);
return generateLines((file?.bytes ?? Buffer.alloc(0)).toString('utf8'));
});
const readText = vi.fn(async (path: string) =>
(lookup(path)?.bytes ?? Buffer.alloc(0)).toString('utf8'),
);
const stat = vi.fn(async (path: string) => {
const file = lookup(path);
if (file === undefined) {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
}
return {
isFile: file.isFile ?? true,
isDirectory: file.isDirectory ?? false,
size: file.size ?? file.bytes.length,
};
});
const fs = { cwd: '/', readBytes, readLines, readText, stat } as unknown as IAgentFileSystem;
return { fs, readBytes, readLines, readText, stat };
}
function toolWithContent(content: string, workspace: WorkspaceConfig = PERMISSIVE_WORKSPACE) {
return new ReadTool(createSpiedFs(content).fs, createTestKaos(), workspace);
}
function isPromiseLike(value: ToolExecution | Promise<ToolExecution>): value is Promise<ToolExecution> {
return typeof (value as Promise<ToolExecution>).then === 'function';
}
async function execute(tool: ReadTool, args: ReadInput): Promise<ExecutableToolResult> {
let execution: ToolExecution;
try {
const resolved = tool.resolveExecution(args);
execution = isPromiseLike(resolved) ? await resolved : resolved;
} catch (error) {
const output =
error instanceof PathSecurityError
? error.message
: `Tool "${tool.name}" failed to resolve execution: ${
error instanceof Error ? error.message : String(error)
}`;
return { isError: true, output };
}
if (execution.isError === true) return execution;
const ctx: ExecutableToolContext = {
turnId: '0',
toolCallId: 'call_read',
signal,
};
return execution.execute(ctx);
}
describe('ReadTool', () => {
it('exposes current metadata and schema', () => {
const tool = toolWithContent('');
expect(tool.name).toBe('Read');
expect(tool.description).toContain('concrete file path');
expect(tool.description).toContain('Pure CRLF files are displayed with LF');
expect(tool.description).not.toContain('skip the verification re-read');
expect(tool.description).toContain('final external contract');
expect(tool.parameters).toMatchObject({
type: 'object',
properties: {
path: {
type: 'string',
description: expect.stringContaining('working directory'),
},
line_offset: {
description: expect.stringContaining('line number to start reading from'),
},
n_lines: {
description: expect.stringContaining('number of lines to read'),
},
},
});
expect(ReadInputSchema.safeParse({ path: '/tmp/test.txt' }).success).toBe(true);
expect(
ReadInputSchema.safeParse({ path: '/tmp/test.txt', line_offset: 1, n_lines: 2 }).success,
).toBe(true);
expect(ReadInputSchema.safeParse({ path: '/tmp/test.txt', line_offset: 0 }).success).toBe(
false,
);
expect(
ReadInputSchema.safeParse({ path: '/tmp/test.txt', line_offset: -(MAX_LINES + 1) }).success,
).toBe(false);
});
it('matches permission args with glob path semantics', () => {
const tool = toolWithContent('');
const execution = tool.resolveExecution({ path: '/etc/passwd' });
if (execution.isError === true) throw new TypeError('expected runnable execution');
expect(execution.matchesRule?.('/etc/**')).toBe(true);
expect(execution.matchesRule?.('/var/**')).toBe(false);
});
it('reads text content with stable one-based line numbers', async () => {
const tool = toolWithContent('alpha\nbeta\n');
const result = await execute(tool, { path: '/tmp/a.txt' });
expect(result).toEqual({
output: withReadStatus(
'1\talpha\n2\tbeta',
'2 lines read from file starting from line 1. Total lines in file: 2. End of file reached.',
),
});
});
it('normalizes pure CRLF files to the LF model view', async () => {
const tool = toolWithContent('alpha\r\nbeta\r\n');
const result = await execute(tool, { path: '/tmp/a.txt' });
expect(result.output).toBe(
withReadStatus(
['1\talpha', '2\tbeta'].join('\n'),
'2 lines read from file starting from line 1. Total lines in file: 2. End of file reached.',
),
);
});
it('makes mixed carriage returns visible instead of normalizing them', async () => {
const tool = toolWithContent('alpha\r\nbeta\ngamma\rdone');
const result = await execute(tool, { path: '/tmp/a.txt' });
expect(result.output).toBe(
withReadStatus(
['1\talpha\\r', '2\tbeta', '3\tgamma\\rdone'].join('\n'),
'3 lines read from file starting from line 1. Total lines in file: 3. End of file reached. Mixed or lone carriage-return line endings are shown as \\r. Use exact \\r\\n or \\r escapes in Edit.old_string for those lines.',
),
);
});
it('respects one-based line_offset and positive n_lines', async () => {
const tool = toolWithContent('a\nb\nc\nd\ne');
const result = await execute(tool, { path: '/tmp/a.txt', line_offset: 2, n_lines: 2 });
expect(result).toEqual({
output: withReadStatus(
'2\tb\n3\tc',
'2 lines read from file starting from line 2. Total lines in file: 5.',
),
});
});
it('returns an empty successful output when line_offset is beyond EOF', async () => {
const tool = toolWithContent('a\nb');
const result = await execute(tool, { path: '/tmp/a.txt', line_offset: 20 });
expect(result).toEqual({
output: withReadStatus(
'',
'No lines read from file. Total lines in file: 2. End of file reached.',
),
});
});
it('supports negative line_offset as tail mode with absolute line numbers', async () => {
const tool = toolWithContent('a\nb\nc\nd\ne');
const result = await execute(tool, { path: '/tmp/a.txt', line_offset: -3 });
expect(result).toEqual({
output: withReadStatus(
'3\tc\n4\td\n5\te',
'3 lines read from file starting from line 3. Total lines in file: 5. End of file reached.',
),
});
});
it('applies n_lines from the start of the negative line_offset tail window', async () => {
const tool = toolWithContent('a\nb\nc\nd\ne');
const result = await execute(tool, { path: '/tmp/a.txt', line_offset: -5, n_lines: 2 });
expect(result.output).toBe(
withReadStatus(
'1\ta\n2\tb',
'2 lines read from file starting from line 1. Total lines in file: 5.',
),
);
});
it('rejects relative traversal before reading', async () => {
const { fs, readText } = createSpiedFs('secret');
const tool = new ReadTool(fs, createTestKaos(), {
workspaceDir: '/workspace/project',
additionalDirs: [],
});
const result = await execute(tool, { path: '../../outside.txt' });
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('absolute path');
expect(readText).not.toHaveBeenCalled();
});
it('allows explicit absolute paths outside the workspace', async () => {
const { fs, readBytes, readLines } = createSpiedFs('external');
const tool = new ReadTool(fs, createTestKaos(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const result = await execute(tool, { path: '/tmp/external.txt' });
expect(result.output).toBe(
withReadStatus(
'1\texternal',
'1 line read from file starting from line 1. Total lines in file: 1. End of file reached.',
),
);
expect(readBytes).toHaveBeenCalledWith('/tmp/external.txt', MEDIA_SNIFF_BYTES);
expect(readLines).toHaveBeenCalledWith('/tmp/external.txt', { errors: 'strict' });
});
it('returns a friendly error for missing files before sniffing bytes', async () => {
const { fs, readBytes, readLines } = createSpiedMapFs({});
const tool = new ReadTool(fs, createTestKaos(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const result = await execute(tool, { path: '/workspace/missing.txt' });
expect(result).toEqual({
isError: true,
output: '"/workspace/missing.txt" does not exist.',
});
expect(readBytes).not.toHaveBeenCalled();
expect(readLines).not.toHaveBeenCalled();
});
it('returns a friendly error for directories before sniffing bytes', async () => {
const { fs, readBytes, readLines } = createSpiedMapFs({
'/workspace/src': { bytes: Buffer.alloc(0), isFile: false, isDirectory: true },
});
const tool = new ReadTool(fs, createTestKaos(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const result = await execute(tool, { path: '/workspace/src' });
expect(result).toEqual({
isError: true,
output: '"/workspace/src" is not a file.',
});
expect(readBytes).not.toHaveBeenCalled();
expect(readLines).not.toHaveBeenCalled();
});
it('expands leading tilde paths using the kaos home directory', async () => {
const { fs, readBytes, readLines } = createSpiedFs('home note');
const tool = new ReadTool(fs, createTestKaos('/home/test'), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const result = await execute(tool, { path: '~/notes/today.txt' });
expect(result.output).toBe(
withReadStatus(
'1\thome note',
'1 line read from file starting from line 1. Total lines in file: 1. End of file reached.',
),
);
expect(readBytes).toHaveBeenCalledWith('/home/test/notes/today.txt', MEDIA_SNIFF_BYTES);
expect(readLines).toHaveBeenCalledWith('/home/test/notes/today.txt', { errors: 'strict' });
});
it('blocks sensitive files independently from workspace access', async () => {
const { fs, readText } = createSpiedFs('SECRET=value');
const tool = new ReadTool(fs, createTestKaos(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const result = await execute(tool, { path: '/workspace/.env' });
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('sensitive-file pattern');
expect(readText).not.toHaveBeenCalled();
});
it('rejects image files before text decoding and points to ReadMediaFile', async () => {
const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const { fs, readText } = createSpiedMapFs({
'/tmp/sample.png': { bytes: pngHeader },
});
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/sample.png' });
const output = toolContentString(result);
expect(result.isError).toBe(true);
expect(output).toMatch(/image file/i);
expect(output).toMatch(/ReadMediaFile|media/i);
expect(readText).not.toHaveBeenCalled();
});
it('rejects an image-extension file whose bytes are not an image as not readable', async () => {
// A `.png` file with no recognisable image magic and no NUL byte is not a
// real image; it must fall through to the generic "not readable" error
// rather than being misidentified as an image and sent to ReadMediaFile.
const plainText = Buffer.from('this is plain ascii text, not a png');
const { fs, readText } = createSpiedMapFs({
'/tmp/fake.png': { bytes: plainText },
});
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/fake.png' });
const output = toolContentString(result);
expect(result.isError).toBe(true);
expect(output).toBe(
'"/tmp/fake.png" is not readable as UTF-8 text. If it is an image or video, use ReadMediaFile. For other binary formats, use Bash or an MCP tool if available.',
);
expect(readText).not.toHaveBeenCalled();
});
it('rejects extensionless image files using magic-byte sniffing', async () => {
const pngHeader = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const { fs, readText } = createSpiedMapFs({
'/tmp/sample': { bytes: pngHeader },
});
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/sample' });
const output = toolContentString(result);
expect(result.isError).toBe(true);
expect(output).toMatch(/image file/i);
expect(readText).not.toHaveBeenCalled();
});
it('rejects video files before text decoding', async () => {
const mp4Header = Buffer.concat([
Buffer.from([0x00, 0x00, 0x00, 0x18]),
Buffer.from('ftyp'),
Buffer.from('mp42'),
Buffer.from([0x00, 0x00, 0x00, 0x00]),
Buffer.from('mp42isom'),
]);
const { fs, readText } = createSpiedMapFs({
'/tmp/sample.mp4': { bytes: mp4Header },
});
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/sample.mp4' });
const output = toolContentString(result);
expect(result.isError).toBe(true);
expect(output).toMatch(/video file/i);
expect(output).toMatch(/ReadMediaFile|media/i);
expect(readText).not.toHaveBeenCalled();
});
it('rejects NUL-containing binary files before text decoding', async () => {
const header = Buffer.concat([Buffer.from('plain prefix'), Buffer.from([0x00, 0x01])]);
const { fs, readText } = createSpiedMapFs({
'/tmp/blob.bin': { bytes: header },
});
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/blob.bin' });
const output = toolContentString(result);
expect(result.isError).toBe(true);
expect(output).toBe(
'"/tmp/blob.bin" is not readable as UTF-8 text. If it is an image or video, use ReadMediaFile. For other binary formats, use Bash or an MCP tool if available.',
);
expect(output).not.toContain('Python tools');
expect(readText).not.toHaveBeenCalled();
});
it('rejects NUL bytes that appear after the preflight header', async () => {
const header = Buffer.from('text prefix without nul', 'utf8');
const { fs } = createSpiedMapFs({
'/tmp/blob-with-late-nul': {
bytes: header,
readLines: async function* readLines(): AsyncGenerator<string> {
yield 'safe text\n';
yield `binary${String.fromCodePoint(0)}tail\n`;
},
},
});
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/blob-with-late-nul' });
const output = toolContentString(result);
expect(result.isError).toBe(true);
expect(output).toBe(
'"/tmp/blob-with-late-nul" is not readable as UTF-8 text. If it is an image or video, use ReadMediaFile. For other binary formats, use Bash or an MCP tool if available.',
);
expect(output).not.toContain('Python tools');
});
it('rejects invalid UTF-8 instead of returning replacement characters', async () => {
const replacement = String.fromCodePoint(0xfffd);
const { fs } = createSpiedMapFs({
'/tmp/not-utf8.txt': {
bytes: Buffer.from('text header'),
readLines: async function* readLines(
_path: string,
options?: { errors?: 'strict' | 'replace' | 'ignore' },
): AsyncGenerator<string> {
if (options?.errors === 'strict') {
throw new TypeError('The encoded data was not valid for encoding utf-8');
}
yield `bad${replacement}text\n`;
},
},
});
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/not-utf8.txt' });
const output = toolContentString(result);
expect(result.isError).toBe(true);
expect(output).toBe(
'"/tmp/not-utf8.txt" is not readable as UTF-8 text. If it is an image or video, use ReadMediaFile. For other binary formats, use Bash or an MCP tool if available.',
);
expect(output).not.toContain('Python tools');
expect(output).not.toContain(replacement);
expect(output).not.toContain('encoded data was not valid');
});
it('truncates long lines and surfaces the affected line numbers', async () => {
const long = 'x'.repeat(MAX_LINE_LENGTH + 10);
const tool = toolWithContent([long, 'short', long].join('\n'));
const result = await execute(tool, { path: '/tmp/long.txt' });
expect(result.output).toContain('Lines [1, 3] were truncated.');
expect(result.output).toContain('...');
});
it('checks the byte cap before adding the next rendered line', async () => {
const line = 'x'.repeat(MAX_LINE_LENGTH);
const content = Array.from({ length: 80 }, () => line).join('\n');
const tool = toolWithContent(content);
const result = await execute(tool, { path: '/tmp/bytes.txt' });
const output = toolContentString(result);
const marker = '\n<system>';
const markerIndex = output.indexOf(marker);
expect(markerIndex).toBeGreaterThan(0);
const body = output.slice(0, markerIndex);
expect(Buffer.byteLength(body, 'utf8')).toBeLessThanOrEqual(MAX_BYTES);
expect(output).toContain(`Max ${String(MAX_BYTES)} bytes reached.`);
});
it('reads through bounded byte preflight and streams line iteration without full readText', async () => {
const bytes = Buffer.from(
Array.from({ length: MAX_LINES + 5 }, (_, i) => `line ${String(i + 1)}`).join('\n'),
'utf8',
);
const readText = vi.fn(async () => {
throw new Error('full readText should not be called');
});
let consumed = 0;
const readLines = vi.fn().mockImplementation(async function* (): AsyncGenerator<string> {
for (let i = 1; i <= MAX_LINES + 5; i += 1) {
consumed = i;
yield `line ${String(i)}\n`;
}
});
const readBytes = vi.fn(async (_path: string, n?: number) =>
n === undefined ? bytes : bytes.subarray(0, n),
);
const stat = vi.fn(async () => ({ isFile: true, isDirectory: false, size: bytes.length }));
const fs = { cwd: '/', readBytes, readLines, readText, stat } as unknown as IAgentFileSystem;
const tool = new ReadTool(fs, createTestKaos(), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '/tmp/large.txt' });
const output = toolContentString(result);
expect(result.isError).toBeFalsy();
expect(output).toContain('1\tline 1');
expect(output).toContain(`${String(MAX_LINES)}\tline ${String(MAX_LINES)}`);
expect(output).toContain(`Total lines in file: ${String(MAX_LINES + 5)}.`);
expect(output).toContain(`Max ${String(MAX_LINES)} lines reached.`);
expect(consumed).toBe(MAX_LINES + 5);
expect(readBytes).toHaveBeenCalledWith('/tmp/large.txt', MEDIA_SNIFF_BYTES);
expect(readText).not.toHaveBeenCalled();
});
it('caps default reads at MAX_LINES', async () => {
const content = Array.from({ length: MAX_LINES + 1 }, (_, i) => `line ${String(i + 1)}`).join(
'\n',
);
const tool = toolWithContent(content);
const result = await execute(tool, { path: '/tmp/big.txt' });
expect(result.output).toContain(`Max ${String(MAX_LINES)} lines reached.`);
expect(result.output).toContain(`${String(MAX_LINES)}\tline ${String(MAX_LINES)}`);
expect(result.output).not.toContain(`${String(MAX_LINES + 1)}\tline ${String(MAX_LINES + 1)}`);
});
it('tail byte truncation keeps the newest lines closest to EOF', async () => {
const numLines = Math.floor(MAX_BYTES / 1001) + 20;
const content = Array.from({ length: numLines }, (_, i) => {
return `${String(i + 1).padStart(4, '0')}${'B'.repeat(996)}`;
}).join('\n');
const tool = toolWithContent(content);
const result = await execute(tool, { path: '/tmp/tail-bytes.txt', line_offset: -1000 });
const output = toolContentString(result);
const outputLines = output
.split('\n')
.filter((line) => line.includes('\t') && !line.startsWith('<system>'));
expect(output).toContain(`Max ${String(MAX_BYTES)} bytes reached.`);
expect(outputLines.at(-1)).toContain(String(numLines).padStart(4, '0'));
expect(outputLines[0]).not.toContain('0001');
});
it('tail n_lines is applied before byte truncation', async () => {
const numLines = 500;
const content = Array.from({ length: numLines }, (_, i) => {
return `${String(i + 1).padStart(4, '0')}${'X'.repeat(1996)}`;
}).join('\n');
const tool = toolWithContent(content);
const result = await execute(tool, {
path: '/tmp/tail-small-window.txt',
line_offset: -200,
n_lines: 1,
});
const output = toolContentString(result);
expect(output).toMatch(/^301\t0301/);
expect(output).not.toContain('Max');
});
it('description pins line/byte caps, tail mode, and the Grep-over-Read preference', () => {
const tool = toolWithContent('');
// Numeric caps are part of the stable contract.
expect(tool.description).toContain(String(MAX_LINES));
expect(tool.description).toContain(String(MAX_LINE_LENGTH));
// Tail mode (negative line_offset) is documented.
expect(tool.description).toMatch(/negative line_offset|reads from the end/i);
// Recommend Grep when searching for unknown content.
expect(tool.description).toContain('Grep');
});
it('reads files inside additional_dirs via absolute path', async () => {
const { fs } = createSpiedFs('extra-dir note');
const tool = new ReadTool(fs, createTestKaos(), {
workspaceDir: '/workspace',
additionalDirs: ['/extra'],
});
const result = await execute(tool, { path: '/extra/notes.txt' });
expect(result.isError).toBeFalsy();
expect(result.output).toContain('1\textra-dir note');
});
it('reports nonexistent files with the expected does-not-exist phrasing', async () => {
const { fs } = createSpiedMapFs({});
const tool = new ReadTool(fs, createTestKaos(), {
workspaceDir: '/workspace',
additionalDirs: [],
});
const result = await execute(tool, { path: '/workspace/ghost.txt' });
expect(result.isError).toBe(true);
expect(result.output).toContain('does not exist');
expect(result.output).toMatch(/not found|does not exist/i);
});
it('returns empty output and Total lines: 0 for an empty file', async () => {
const tool = toolWithContent('');
const result = await execute(tool, { path: '/tmp/empty.txt' });
expect(result.isError).toBeFalsy();
expect(result.output).toBe(
withReadStatus('', 'No lines read from file. Total lines in file: 0. End of file reached.'),
);
});
it('reads unicode (CJK + emoji + accented Latin) without loss', async () => {
const tool = toolWithContent('Hello 世界 🌍\nUnicode test: café, naïve, résumé');
const result = await execute(tool, { path: '/tmp/unicode.txt' });
expect(result.isError).toBeFalsy();
expect(result.output).toContain('1\tHello 世界 🌍');
expect(result.output).toContain('2\tUnicode test: café, naïve, résumé');
});
it('schema validation rejects n_lines=0 and n_lines=-1 with an n_lines-keyed error', () => {
const zero = ReadInputSchema.safeParse({ path: '/tmp/a.txt', n_lines: 0 });
expect(zero.success).toBe(false);
if (!zero.success) {
const message = JSON.stringify(zero.error.issues);
expect(message).toContain('n_lines');
}
const negative = ReadInputSchema.safeParse({ path: '/tmp/a.txt', n_lines: -1 });
expect(negative.success).toBe(false);
if (!negative.success) {
const message = JSON.stringify(negative.error.issues);
expect(message).toContain('n_lines');
}
});
it('schema validation accepts -1 and -MAX_LINES but rejects -(MAX_LINES + 1)', () => {
expect(ReadInputSchema.safeParse({ path: '/tmp/a.txt', line_offset: -1 }).success).toBe(true);
expect(ReadInputSchema.safeParse({ path: '/tmp/a.txt', line_offset: -MAX_LINES }).success).toBe(
true,
);
expect(
ReadInputSchema.safeParse({ path: '/tmp/a.txt', line_offset: -(MAX_LINES + 1) }).success,
).toBe(false);
});
it('reads non-sensitive dotfiles like .gitignore successfully', async () => {
const tool = toolWithContent('node_modules/\n');
const result = await execute(tool, { path: '/workspace/.gitignore' });
expect(result.isError).toBeFalsy();
expect(result.output).toContain('node_modules/');
});
it('negative line_offset exceeding total lines returns the entire file', async () => {
const tool = toolWithContent('a\nb\nc\nd\ne');
const result = await execute(tool, { path: '/tmp/short.txt', line_offset: -100 });
expect(result.isError).toBeFalsy();
expect(result.output).toContain('1\ta');
expect(result.output).toContain('5\te');
expect(result.output).toContain('Total lines in file: 5.');
});
it('tail mode on an empty file returns empty output without erroring', async () => {
const tool = toolWithContent('');
const result = await execute(tool, { path: '/tmp/empty-tail.txt', line_offset: -10 });
expect(result.isError).toBeFalsy();
expect(result.output).toContain('Total lines in file: 0.');
});
it('line_offset=-1 returns only the last line with its absolute line number', async () => {
const tool = toolWithContent('a\nb\nc\nd\ne');
const result = await execute(tool, { path: '/tmp/last.txt', line_offset: -1 });
expect(result.isError).toBeFalsy();
expect(result.output).toContain('5\te');
expect(result.output).toContain('1 line read from file starting from line 5.');
});
it('tail mode reports absolute line numbers when long lines are truncated', async () => {
const shortLine = 'short';
const longLine = 'X'.repeat(MAX_LINE_LENGTH + 500);
const content = [shortLine, longLine, shortLine, longLine, shortLine].join('\n');
const tool = toolWithContent(content);
const result = await execute(tool, { path: '/tmp/tail-trunc.txt', line_offset: -3 });
expect(result.isError).toBeFalsy();
// Last 3 lines = 3, 4, 5; line 4 is the long one.
expect(result.output).toContain('Total lines in file: 5.');
expect(result.output).toContain('Lines [4] were truncated.');
});
});
describe('ReadTool description and schema parity', () => {
it('encourages reading multiple files in parallel', () => {
const tool = toolWithContent('');
expect(tool.description).toMatch(/parallel/i);
expect(tool.description).toMatch(/multiple `Read` calls in a single response/i);
});
it('explains the trailing <system> status block', () => {
const tool = toolWithContent('');
expect(tool.description).toContain('<system>');
// The TS implementation appends the status block after the content.
expect(tool.description).toMatch(/after the file content/i);
});
it('describes the path parameter with accurate working-directory semantics', () => {
const tool = toolWithContent('');
const pathProperty = (tool.parameters as { properties: { path: { description: string } } })
.properties.path;
expect(pathProperty.description).toContain('working directory');
expect(pathProperty.description).not.toMatch(/^Absolute path/);
});
it('documents the default for n_lines when omitted', () => {
const tool = toolWithContent('');
const nLinesProperty = (tool.parameters as { properties: { n_lines: { description: string } } })
.properties.n_lines;
// Omitting n_lines reads up to MAX_LINES; the schema description must say so.
expect(nLinesProperty.description).toMatch(/omit/i);
expect(nLinesProperty.description).toContain(String(MAX_LINES));
});
it('warns that sensitive files are refused', () => {
const tool = toolWithContent('');
expect(tool.description).toMatch(/refuse|reject|decline|block/i);
expect(tool.description).toMatch(/sensitive|credential|secret|\.env|SSH key/i);
});
it('explains that non-UTF-8 and binary files are refused', () => {
const tool = toolWithContent('');
expect(tool.description).toMatch(/UTF-?8/i);
expect(tool.description).toMatch(/binary/i);
});
});

View file

@ -0,0 +1,436 @@
/**
* WriteTool tests for the v2 fileTools domain.
*
* Ported from v1 (`packages/agent-core/test/tools/write.test.ts`) and adapted
* to the v2 constructor `(fs, kaos, workspace)`. Self-contained: builds minimal
* fake `IAgentFileSystem` and `IKaos` inline so the tool can be exercised
* without the composition root.
*
* The v1 append path used `kaos.writeText(path, data, { mode: 'a' })`. v2's
* `IAgentFileSystem.writeText` has no mode flag, so append is implemented as
* `readText` (treating a missing file as empty) followed by `writeText` of the
* concatenation. The append-call assertions below reflect that mechanic.
*/
import { describe, expect, it, vi } from 'vitest';
import { PathSecurityError } from '../../src/_base/tools/policies/path-access';
import type { AgentFileStat, IAgentFileSystem } from '../../src/agentFs';
import type { WorkspaceConfig } from '../../src/_base/tools/support/workspace';
import { type WriteInput, WriteInputSchema, WriteTool } from '../../src/fileTools/tools/write';
import type { IKaos } from '../../src/kaos';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../src/tool';
const signal = new AbortController().signal;
const PERMISSIVE_WORKSPACE: WorkspaceConfig = { workspaceDir: '/', additionalDirs: [] };
function toolContentString(result: ExecutableToolResult): string {
const c = result.output;
if (typeof c !== 'string') {
throw new TypeError(`expected string content, got ${typeof c}`);
}
return c;
}
function createTestKaos(home = '/home'): IKaos {
return {
pathClass: () => 'posix',
gethome: () => home,
} as unknown as IKaos;
}
interface WriteFsOptions {
/** Override readText. Default rejects with ENOENT (file missing). */
readText?: (path: string) => Promise<string>;
/** Override writeText. Default no-op. */
writeText?: (path: string, data: string) => Promise<void>;
/** Override stat. Default reports an existing directory. */
stat?: (path: string) => Promise<AgentFileStat>;
/** Override mkdir. Default no-op. */
mkdir?: (path: string) => Promise<void>;
}
/**
* Fake fs for WriteTool. All IO methods are `vi.fn()` spies so tests can
* assert on the readText/writeText/stat/mkdir calls. By default `stat`
* reports an existing directory (so `ensureParentDirectory` passes without
* creating anything) and `readText` rejects with ENOENT (so an append to a
* missing file treats existing content as empty).
*/
function createWriteFs(options: WriteFsOptions = {}) {
const readText = vi.fn(
options.readText ??
(async () => {
throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' });
}),
);
const writeText = vi.fn(options.writeText ?? (async () => {}));
const stat = vi.fn(
options.stat ?? (async () => ({ isFile: false, isDirectory: true, size: 0 })),
);
const mkdir = vi.fn(options.mkdir ?? (async () => {}));
const fs = { cwd: '/', readText, writeText, stat, mkdir } as unknown as IAgentFileSystem;
return { fs, readText, writeText, stat, mkdir };
}
function makeTool(options: WriteFsOptions = {}, workspace: WorkspaceConfig = PERMISSIVE_WORKSPACE) {
const fakes = createWriteFs(options);
const tool = new WriteTool(fakes.fs, createTestKaos(), workspace);
return { tool, ...fakes };
}
function isPromiseLike(value: ToolExecution | Promise<ToolExecution>): value is Promise<ToolExecution> {
return typeof (value as Promise<ToolExecution>).then === 'function';
}
async function execute(tool: WriteTool, args: WriteInput): Promise<ExecutableToolResult> {
let execution: ToolExecution;
try {
const resolved = tool.resolveExecution(args);
execution = isPromiseLike(resolved) ? await resolved : resolved;
} catch (error) {
const output =
error instanceof PathSecurityError
? error.message
: `Tool "${tool.name}" failed to resolve execution: ${
error instanceof Error ? error.message : String(error)
}`;
return { isError: true, output };
}
if (execution.isError === true) return execution;
const ctx: ExecutableToolContext = {
turnId: '0',
toolCallId: 'call_write',
signal,
};
return execution.execute(ctx);
}
describe('WriteTool', () => {
it('exposes current metadata and schema', () => {
const { tool } = makeTool();
expect(tool.name).toBe('Write');
expect(tool.description).toContain('append adds content at EOF without adding a newline');
expect(tool.description).toContain('\\n stays LF, \\r\\n stays CRLF');
// The prompt steers the agent toward Edit for partial changes to an
// existing file. Pin the prohibition so accidental weakening is caught.
expect(tool.description).toContain('Write is NOT ALLOWED for incremental changes');
expect(tool.parameters).toMatchObject({
type: 'object',
properties: {
content: {
type: 'string',
description: expect.stringContaining('Raw full file content'),
},
mode: {
enum: ['overwrite', 'append'],
description: expect.stringContaining('Defaults to overwrite'),
},
},
});
expect(WriteInputSchema.safeParse({ path: '/tmp/out.txt', content: 'hello' }).success).toBe(
true,
);
expect(
WriteInputSchema.safeParse({ path: '/tmp/out.txt', content: 'hello', mode: 'append' })
.success,
).toBe(true);
expect(
WriteInputSchema.safeParse({ path: '/tmp/out.txt', content: 'hello', mode: 'bad' }).success,
).toBe(false);
expect(WriteInputSchema.safeParse({ path: '/tmp/out.txt' }).success).toBe(false);
});
it('describes the working-directory rule for the path parameter', () => {
const { tool } = makeTool();
const params = tool.parameters as {
properties: { path: { description: string } };
};
expect(params.properties.path.description).toContain('working directory');
expect(params.properties.path.description).toMatch(/relative/i);
expect(params.properties.path.description).toMatch(/absolute/i);
});
it('exposes the content on the file_io display so the approval panel can preview it', () => {
const { tool } = makeTool();
const execution = tool.resolveExecution({
path: '/tmp/new.txt',
content: 'hello\nworld',
});
if (execution.isError === true) {
throw new TypeError('expected runnable execution');
}
expect(execution.display).toEqual({
kind: 'file_io',
operation: 'write',
path: '/tmp/new.txt',
content: 'hello\nworld',
});
});
it('matches permission args with negated glob path semantics', () => {
const { tool } = makeTool({}, { workspaceDir: '/workspace', additionalDirs: [] });
const insideSrc = tool.resolveExecution({ path: './src/a.ts', content: 'x' });
const outsideSrc = tool.resolveExecution({ path: './README.md', content: 'x' });
if (insideSrc.isError === true || outsideSrc.isError === true) {
throw new TypeError('expected runnable execution');
}
expect(insideSrc.matchesRule?.('!./src/**')).toBe(false);
expect(outsideSrc.matchesRule?.('!./src/**')).toBe(true);
});
it('guides batching large content across multiple write calls', () => {
const { tool } = makeTool();
// The guidance must mention that a file too large for one call should be
// chunked, and spell out the first-overwrite-then-append ordering.
expect(tool.description).toMatch(/large/i);
expect(tool.description).toContain('content too large for one call');
expect(tool.description).toMatch(/overwrite[^.]*first chunk[^.]*then[^.]*append/i);
});
it('writes content through fs and reports bytes written', async () => {
const { tool, writeText } = makeTool();
const result = await execute(tool, { path: '/tmp/new.txt', content: 'hello' });
expect(writeText).toHaveBeenCalledWith('/tmp/new.txt', 'hello');
expect(result.output).toContain('Wrote 5 bytes');
});
it('expands leading tilde paths using the kaos home directory', async () => {
const fakes = createWriteFs();
const tool = new WriteTool(fakes.fs, createTestKaos('/home/test'), PERMISSIVE_WORKSPACE);
const result = await execute(tool, { path: '~/notes/today.txt', content: 'hello' });
expect(fakes.writeText).toHaveBeenCalledWith('/home/test/notes/today.txt', 'hello');
expect(result.output).toContain('Wrote 5 bytes');
});
it('appends content by reading existing bytes then writing the concatenation', async () => {
const { tool, readText, writeText } = makeTool({ readText: async () => 'old' });
const result = await execute(tool, {
path: '/tmp/existing.txt',
content: '\nhello',
mode: 'append',
});
expect(readText).toHaveBeenCalledWith('/tmp/existing.txt');
expect(writeText).toHaveBeenCalledWith('/tmp/existing.txt', 'old\nhello');
expect(result.output).toContain('Appended 6 bytes');
});
it('reports the real UTF-8 byte count for non-ASCII content', async () => {
// Six Japanese characters: each encodes to 3 UTF-8 bytes → 18 bytes total,
// even though the JS string length is 6. The reported count must reflect
// the bytes that land on disk, not the code-unit count.
const content = 'こんにちは。';
const expectedBytes = Buffer.byteLength(content, 'utf8');
expect(expectedBytes).toBe(18);
const { tool } = makeTool();
const result = await execute(tool, { path: '/tmp/jp.txt', content });
expect(result.output).toContain('Wrote 18 bytes');
expect(result.output).not.toContain('Wrote 6 bytes');
});
it('reports the real UTF-8 byte count for content with surrogate-pair emoji', async () => {
// 'hi😀': the emoji is a single code point encoded as a UTF-16 surrogate
// pair, so JS string length is 4 (2 for 'hi' + 2 code units), but the
// UTF-8 encoding is 6 bytes (2 for 'hi' + 4 for the emoji). The reported
// count must reflect the bytes on disk, not the code-unit count — this
// is the sharpest edge of the byte-counting bug.
const content = 'hi😀';
expect(content.length).toBe(4);
const expectedBytes = Buffer.byteLength(content, 'utf8');
expect(expectedBytes).toBe(6);
const { tool } = makeTool();
const result = await execute(tool, { path: '/tmp/emoji.txt', content });
expect(result.output).toContain('Wrote 6 bytes');
expect(result.output).not.toContain('Wrote 4 bytes');
});
it('reports the real UTF-8 byte count for non-ASCII append content', async () => {
const content = 'café';
const expectedBytes = Buffer.byteLength(content, 'utf8');
expect(expectedBytes).toBe(5);
const { tool, writeText } = makeTool({ readText: async () => 'prefix' });
const result = await execute(tool, { path: '/tmp/menu.txt', content, mode: 'append' });
expect(writeText).toHaveBeenCalledWith('/tmp/menu.txt', 'prefixcafé');
expect(result.output).toContain('Appended 5 bytes');
});
it('creates missing parent directories automatically before writing', async () => {
const enoent = Object.assign(new Error('ENOENT: no such file or directory'), {
code: 'ENOENT',
});
const { tool, mkdir, writeText } = makeTool({ stat: vi.fn().mockRejectedValue(enoent) });
const result = await execute(tool, { path: '/tmp/missing-dir/file.txt', content: 'data' });
expect(result.isError).toBeFalsy();
expect(mkdir).toHaveBeenCalledWith('/tmp/missing-dir');
expect(writeText).toHaveBeenCalledWith('/tmp/missing-dir/file.txt', 'data');
});
it('surfaces mkdir failures when a missing parent cannot be created', async () => {
const enoent = Object.assign(new Error('ENOENT: no such file or directory'), {
code: 'ENOENT',
});
const { tool, writeText } = makeTool({
stat: vi.fn().mockRejectedValue(enoent),
mkdir: vi.fn().mockRejectedValue(new Error('permission denied')),
});
const result = await execute(tool, { path: '/tmp/missing-dir/file.txt', content: 'data' });
expect(result).toMatchObject({ isError: true, output: 'permission denied' });
expect(writeText).not.toHaveBeenCalled();
});
it('rejects writing when the parent path is not a directory', async () => {
// A regular file standing where a directory is expected.
const { tool, writeText } = makeTool({
stat: vi.fn().mockResolvedValue({ isFile: true, isDirectory: false, size: 0 }),
});
const result = await execute(tool, { path: '/tmp/a-file/child.txt', content: 'data' });
expect(result).toMatchObject({ isError: true });
expect(result.output).toMatch(/not a directory/i);
expect(writeText).not.toHaveBeenCalled();
});
it('writes when the parent directory exists', async () => {
const { tool, writeText } = makeTool({
stat: vi.fn().mockResolvedValue({ isFile: false, isDirectory: true, size: 0 }),
});
const result = await execute(tool, { path: '/tmp/exists/file.txt', content: 'data' });
expect(result.isError).toBeUndefined();
expect(writeText).toHaveBeenCalledWith('/tmp/exists/file.txt', 'data');
});
it('surfaces fs write failures as tool errors', async () => {
const { tool } = makeTool({
writeText: vi.fn().mockRejectedValue(new Error('disk full')),
});
const result = await execute(tool, { path: '/some/file.txt', content: 'data' });
expect(result).toMatchObject({ isError: true, output: 'disk full' });
});
it('allows explicit absolute writes outside the workspace', async () => {
const { tool, writeText } = makeTool({}, { workspaceDir: '/workspace', additionalDirs: [] });
const result = await execute(tool, { path: '/tmp/pwned.txt', content: 'x' });
expect(result.isError).toBeUndefined();
expect(writeText).toHaveBeenCalledWith('/tmp/pwned.txt', 'x');
});
it('rejects relative traversal writes before fs I/O', async () => {
const { tool, writeText } = makeTool(
{},
{ workspaceDir: '/workspace/project', additionalDirs: [] },
);
const result = await execute(tool, { path: '../outside.txt', content: 'x' });
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('absolute path');
expect(writeText).not.toHaveBeenCalled();
});
it('blocks sensitive file writes', async () => {
const { tool, writeText } = makeTool({}, { workspaceDir: '/workspace', additionalDirs: [] });
const result = await execute(tool, { path: '/workspace/id_rsa', content: 'key' });
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('sensitive-file pattern');
expect(writeText).not.toHaveBeenCalled();
});
it('round-trips unicode content (CJK + emoji + accented Latin) through fs.writeText', async () => {
const { tool, writeText } = makeTool();
const content = 'Hello 世界 🌍\nUnicode: café, naïve, résumé';
const result = await execute(tool, { path: '/tmp/unicode.txt', content });
expect(result.isError).toBeFalsy();
expect(writeText).toHaveBeenCalledWith('/tmp/unicode.txt', content);
});
it('writes empty content as a zero-byte file via fs.writeText("")', async () => {
const { tool, writeText } = makeTool();
const result = await execute(tool, { path: '/tmp/empty.txt', content: '' });
expect(result.isError).toBeFalsy();
expect(writeText).toHaveBeenCalledWith('/tmp/empty.txt', '');
});
it('still reports parent-directory ENOENT surfaced by writeText itself', async () => {
// When the proactive parent check is inconclusive (e.g. the environment
// has no `stat`) and the underlying write then fails with ENOENT — for
// example a parent directory removed between the check and the write —
// the tool still surfaces a clear "parent directory does not exist"
// message rather than a raw host error.
const { tool } = makeTool({
writeText: vi
.fn()
.mockRejectedValue(
Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }),
),
});
const result = await execute(tool, { path: '/tmp/missing-dir/file.txt', content: 'data' });
expect(result.isError).toBe(true);
expect(result.output).toContain('parent directory does not exist');
});
it('appending to a nonexistent file creates it with just the appended bytes', async () => {
// Append mode on a missing path returns success and creates the file:
// readText rejects with ENOENT, so existing content is treated as empty.
const { tool, readText, writeText } = makeTool();
const result = await execute(tool, {
path: '/tmp/new-append.txt',
content: 'New content',
mode: 'append',
});
expect(result.isError).toBeFalsy();
expect(toolContentString(result).toLowerCase()).toContain('appended');
expect(readText).toHaveBeenCalledWith('/tmp/new-append.txt');
expect(writeText).toHaveBeenCalledWith('/tmp/new-append.txt', 'New content');
});
it('allows absolute writes to a sibling dir that merely shares the work-dir prefix', async () => {
// Path policy must distinguish "shares a prefix with workspaceDir" from
// "is inside workspaceDir". /workspace-sneaky/* is outside /workspace.
const { tool, writeText } = makeTool({}, { workspaceDir: '/workspace', additionalDirs: [] });
const result = await execute(tool, { path: '/workspace-sneaky/file.txt', content: 'content' });
expect(result.isError).toBeFalsy();
expect(writeText).toHaveBeenCalledWith('/workspace-sneaky/file.txt', 'content');
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest';
import type { IBackgroundService } from '#/background';
import type { IDisposable } from '#/_base/di';
import type { IKaos } from '#/kaos';
import type { IProcessRunner } from '#/process';
import { ShellToolsService } from '#/shellTools';
import type { IToolRegistry } from '#/toolRegistry';
function fakeToolRegistry(): { registry: IToolRegistry; names: () => string[] } {
const tools = new Map<string, unknown>();
const registry: IToolRegistry = {
_serviceBrand: undefined,
register: vi.fn((tool: { name: string }): IDisposable => {
tools.set(tool.name, tool);
return { dispose: () => tools.delete(tool.name) };
}),
list: () => [...tools.values()] as never,
} as unknown as IToolRegistry;
return { registry, names: () => [...tools.keys()].sort() };
}
const fakeRunner = {} as unknown as IProcessRunner;
const fakeKaos = {
cwd: '/workspace',
osEnv: { osKind: 'Linux', osArch: 'x64', osVersion: '', shellName: 'bash', shellPath: '/bin/bash' },
pathClass: () => 'posix',
} as unknown as IKaos;
const fakeBackground = {} as unknown as IBackgroundService;
describe('ShellToolsService', () => {
it('registers Bash into the tool registry', () => {
const { registry, names } = fakeToolRegistry();
new ShellToolsService(registry, fakeRunner, fakeKaos, fakeBackground);
expect(names()).toEqual(['Bash']);
});
});