mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
69084ab683
commit
78320259a5
34 changed files with 7210 additions and 0 deletions
6
.changeset/execution-environment-domains.md
Normal file
6
.changeset/execution-environment-domains.md
Normal 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.
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
16
packages/agent-core-v2/src/fileTools/fileTools.ts
Normal file
16
packages/agent-core-v2/src/fileTools/fileTools.ts
Normal 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');
|
||||
53
packages/agent-core-v2/src/fileTools/fileToolsService.ts
Normal file
53
packages/agent-core-v2/src/fileTools/fileToolsService.ts
Normal 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',
|
||||
);
|
||||
15
packages/agent-core-v2/src/fileTools/index.ts
Normal file
15
packages/agent-core-v2/src/fileTools/index.ts
Normal 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';
|
||||
13
packages/agent-core-v2/src/fileTools/tools/edit.md
Normal file
13
packages/agent-core-v2/src/fileTools/tools/edit.md
Normal 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.
|
||||
187
packages/agent-core-v2/src/fileTools/tools/edit.ts
Normal file
187
packages/agent-core-v2/src/fileTools/tools/edit.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
15
packages/agent-core-v2/src/fileTools/tools/glob.md
Normal file
15
packages/agent-core-v2/src/fileTools/tools/glob.md
Normal 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`.
|
||||
416
packages/agent-core-v2/src/fileTools/tools/glob.ts
Normal file
416
packages/agent-core-v2/src/fileTools/tools/glob.ts
Normal 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;
|
||||
}
|
||||
9
packages/agent-core-v2/src/fileTools/tools/grep.md
Normal file
9
packages/agent-core-v2/src/fileTools/tools/grep.md
Normal 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`.
|
||||
413
packages/agent-core-v2/src/fileTools/tools/grep.ts
Normal file
413
packages/agent-core-v2/src/fileTools/tools/grep.ts
Normal 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}.`;
|
||||
}
|
||||
59
packages/agent-core-v2/src/fileTools/tools/line-endings.ts
Normal file
59
packages/agent-core-v2/src/fileTools/tools/line-endings.ts
Normal 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');
|
||||
}
|
||||
17
packages/agent-core-v2/src/fileTools/tools/read.md
Normal file
17
packages/agent-core-v2/src/fileTools/tools/read.md
Normal 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.
|
||||
500
packages/agent-core-v2/src/fileTools/tools/read.ts
Normal file
500
packages/agent-core-v2/src/fileTools/tools/read.ts
Normal 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(' ');
|
||||
}
|
||||
}
|
||||
10
packages/agent-core-v2/src/fileTools/tools/write.md
Normal file
10
packages/agent-core-v2/src/fileTools/tools/write.md
Normal 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.
|
||||
170
packages/agent-core-v2/src/fileTools/tools/write.ts
Normal file
170
packages/agent-core-v2/src/fileTools/tools/write.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
11
packages/agent-core-v2/src/shellTools/index.ts
Normal file
11
packages/agent-core-v2/src/shellTools/index.ts
Normal 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';
|
||||
16
packages/agent-core-v2/src/shellTools/shellTools.ts
Normal file
16
packages/agent-core-v2/src/shellTools/shellTools.ts
Normal 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');
|
||||
39
packages/agent-core-v2/src/shellTools/shellToolsService.ts
Normal file
39
packages/agent-core-v2/src/shellTools/shellToolsService.ts
Normal 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',
|
||||
);
|
||||
43
packages/agent-core-v2/src/shellTools/tools/bash.md
Normal file
43
packages/agent-core-v2/src/shellTools/tools/bash.md
Normal 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)
|
||||
483
packages/agent-core-v2/src/shellTools/tools/bash.ts
Normal file
483
packages/agent-core-v2/src/shellTools/tools/bash.ts
Normal 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');
|
||||
}
|
||||
140
packages/agent-core-v2/src/shellTools/tools/result-builder.ts
Normal file
140
packages/agent-core-v2/src/shellTools/tools/result-builder.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
524
packages/agent-core-v2/test/fileTools/edit.test.ts
Normal file
524
packages/agent-core-v2/test/fileTools/edit.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
737
packages/agent-core-v2/test/fileTools/glob.test.ts
Normal file
737
packages/agent-core-v2/test/fileTools/glob.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
393
packages/agent-core-v2/test/fileTools/grep.test.ts
Normal file
393
packages/agent-core-v2/test/fileTools/grep.test.ts
Normal 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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
842
packages/agent-core-v2/test/fileTools/read.test.ts
Normal file
842
packages/agent-core-v2/test/fileTools/read.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
436
packages/agent-core-v2/test/fileTools/write.test.ts
Normal file
436
packages/agent-core-v2/test/fileTools/write.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
1549
packages/agent-core-v2/test/shellTools/bash.test.ts
Normal file
1549
packages/agent-core-v2/test/shellTools/bash.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue