From 78320259a58e204d7171f010f0e34835408f4f46 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 30 Jun 2026 13:49:37 +0800 Subject: [PATCH] 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 --- .changeset/execution-environment-domains.md | 6 + .../scripts/check-domain-layers.mjs | 3 + packages/agent-core-v2/src/agentFs/agentFs.ts | 4 + .../src/agentFs/agentFsService.ts | 7 + .../agent-core-v2/src/fileTools/fileTools.ts | 16 + .../src/fileTools/fileToolsService.ts | 53 + packages/agent-core-v2/src/fileTools/index.ts | 15 + .../agent-core-v2/src/fileTools/tools/edit.md | 13 + .../agent-core-v2/src/fileTools/tools/edit.ts | 187 ++ .../agent-core-v2/src/fileTools/tools/glob.md | 15 + .../agent-core-v2/src/fileTools/tools/glob.ts | 416 +++++ .../agent-core-v2/src/fileTools/tools/grep.md | 9 + .../agent-core-v2/src/fileTools/tools/grep.ts | 413 +++++ .../src/fileTools/tools/line-endings.ts | 59 + .../agent-core-v2/src/fileTools/tools/read.md | 17 + .../agent-core-v2/src/fileTools/tools/read.ts | 500 ++++++ .../src/fileTools/tools/write.md | 10 + .../src/fileTools/tools/write.ts | 170 ++ packages/agent-core-v2/src/index.ts | 2 + packages/agent-core-v2/src/rpc/rpcService.ts | 4 + .../agent-core-v2/src/shellTools/index.ts | 11 + .../src/shellTools/shellTools.ts | 16 + .../src/shellTools/shellToolsService.ts | 39 + .../src/shellTools/tools/bash.md | 43 + .../src/shellTools/tools/bash.ts | 483 +++++ .../src/shellTools/tools/result-builder.ts | 140 ++ .../agent-core-v2/test/fileTools/edit.test.ts | 524 ++++++ .../test/fileTools/fileToolsService.test.ts | 41 + .../agent-core-v2/test/fileTools/glob.test.ts | 737 ++++++++ .../agent-core-v2/test/fileTools/grep.test.ts | 393 +++++ .../agent-core-v2/test/fileTools/read.test.ts | 842 +++++++++ .../test/fileTools/write.test.ts | 436 +++++ .../test/shellTools/bash.test.ts | 1549 +++++++++++++++++ .../test/shellTools/shellToolsService.test.ts | 37 + 34 files changed, 7210 insertions(+) create mode 100644 .changeset/execution-environment-domains.md create mode 100644 packages/agent-core-v2/src/fileTools/fileTools.ts create mode 100644 packages/agent-core-v2/src/fileTools/fileToolsService.ts create mode 100644 packages/agent-core-v2/src/fileTools/index.ts create mode 100644 packages/agent-core-v2/src/fileTools/tools/edit.md create mode 100644 packages/agent-core-v2/src/fileTools/tools/edit.ts create mode 100644 packages/agent-core-v2/src/fileTools/tools/glob.md create mode 100644 packages/agent-core-v2/src/fileTools/tools/glob.ts create mode 100644 packages/agent-core-v2/src/fileTools/tools/grep.md create mode 100644 packages/agent-core-v2/src/fileTools/tools/grep.ts create mode 100644 packages/agent-core-v2/src/fileTools/tools/line-endings.ts create mode 100644 packages/agent-core-v2/src/fileTools/tools/read.md create mode 100644 packages/agent-core-v2/src/fileTools/tools/read.ts create mode 100644 packages/agent-core-v2/src/fileTools/tools/write.md create mode 100644 packages/agent-core-v2/src/fileTools/tools/write.ts create mode 100644 packages/agent-core-v2/src/shellTools/index.ts create mode 100644 packages/agent-core-v2/src/shellTools/shellTools.ts create mode 100644 packages/agent-core-v2/src/shellTools/shellToolsService.ts create mode 100644 packages/agent-core-v2/src/shellTools/tools/bash.md create mode 100644 packages/agent-core-v2/src/shellTools/tools/bash.ts create mode 100644 packages/agent-core-v2/src/shellTools/tools/result-builder.ts create mode 100644 packages/agent-core-v2/test/fileTools/edit.test.ts create mode 100644 packages/agent-core-v2/test/fileTools/fileToolsService.test.ts create mode 100644 packages/agent-core-v2/test/fileTools/glob.test.ts create mode 100644 packages/agent-core-v2/test/fileTools/grep.test.ts create mode 100644 packages/agent-core-v2/test/fileTools/read.test.ts create mode 100644 packages/agent-core-v2/test/fileTools/write.test.ts create mode 100644 packages/agent-core-v2/test/shellTools/bash.test.ts create mode 100644 packages/agent-core-v2/test/shellTools/shellToolsService.test.ts diff --git a/.changeset/execution-environment-domains.md b/.changeset/execution-environment-domains.md new file mode 100644 index 000000000..660a66b66 --- /dev/null +++ b/.changeset/execution-environment-domains.md @@ -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. diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 53df41e66..3be1e1653 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -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', diff --git a/packages/agent-core-v2/src/agentFs/agentFs.ts b/packages/agent-core-v2/src/agentFs/agentFs.ts index b8fbcec09..06a5a2d4f 100644 --- a/packages/agent-core-v2/src/agentFs/agentFs.ts +++ b/packages/agent-core-v2/src/agentFs/agentFs.ts @@ -22,6 +22,10 @@ export interface IAgentFileSystem { readText(path: string): Promise; writeText(path: string, data: string): Promise; readBytes(path: string, n?: number): Promise; + readLines( + path: string, + options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, + ): AsyncGenerator; writeBytes(path: string, data: Uint8Array): Promise; stat(path: string): Promise; readdir(path: string): Promise; diff --git a/packages/agent-core-v2/src/agentFs/agentFsService.ts b/packages/agent-core-v2/src/agentFs/agentFsService.ts index 9bb138079..f9e175ef0 100644 --- a/packages/agent-core-v2/src/agentFs/agentFsService.ts +++ b/packages/agent-core-v2/src/agentFs/agentFsService.ts @@ -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 { + return this.kaos.backend.readLines(path, options); + } + writeBytes(path: string, data: Uint8Array): Promise { return this.kaos.backend.writeBytes(path, Buffer.from(data)).then(() => undefined); } diff --git a/packages/agent-core-v2/src/fileTools/fileTools.ts b/packages/agent-core-v2/src/fileTools/fileTools.ts new file mode 100644 index 000000000..0d5474ea7 --- /dev/null +++ b/packages/agent-core-v2/src/fileTools/fileTools.ts @@ -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 = + createDecorator('fileToolsService'); diff --git a/packages/agent-core-v2/src/fileTools/fileToolsService.ts b/packages/agent-core-v2/src/fileTools/fileToolsService.ts new file mode 100644 index 000000000..d092fd94d --- /dev/null +++ b/packages/agent-core-v2/src/fileTools/fileToolsService.ts @@ -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', +); diff --git a/packages/agent-core-v2/src/fileTools/index.ts b/packages/agent-core-v2/src/fileTools/index.ts new file mode 100644 index 000000000..0aa126e83 --- /dev/null +++ b/packages/agent-core-v2/src/fileTools/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/fileTools/tools/edit.md b/packages/agent-core-v2/src/fileTools/tools/edit.md new file mode 100644 index 000000000..3123f33fa --- /dev/null +++ b/packages/agent-core-v2/src/fileTools/tools/edit.md @@ -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. diff --git a/packages/agent-core-v2/src/fileTools/tools/edit.ts b/packages/agent-core-v2/src/fileTools/tools/edit.ts new file mode 100644 index 000000000..8a56907a5 --- /dev/null +++ b/packages/agent-core-v2/src/fileTools/tools/edit.ts @@ -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; + +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 { + readonly name = 'Edit' as const; + readonly description = EDIT_DESCRIPTION; + readonly parameters: Record = 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 { + 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), + }; + } + } +} diff --git a/packages/agent-core-v2/src/fileTools/tools/glob.md b/packages/agent-core-v2/src/fileTools/tools/glob.md new file mode 100644 index 000000000..769164bfe --- /dev/null +++ b/packages/agent-core-v2/src/fileTools/tools/glob.md @@ -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`. diff --git a/packages/agent-core-v2/src/fileTools/tools/glob.ts b/packages/agent-core-v2/src/fileTools/tools/glob.ts new file mode 100644 index 000000000..27c5ff905 --- /dev/null +++ b/packages/agent-core-v2/src/fileTools/tools/glob.ts @@ -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`. 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; + +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 { + readonly name = 'Glob' as const; + readonly description: string; + readonly parameters: Record = 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 { + 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(); + 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; +} diff --git a/packages/agent-core-v2/src/fileTools/tools/grep.md b/packages/agent-core-v2/src/fileTools/tools/grep.md new file mode 100644 index 000000000..c0d2776b3 --- /dev/null +++ b/packages/agent-core-v2/src/fileTools/tools/grep.md @@ -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`. diff --git a/packages/agent-core-v2/src/fileTools/tools/grep.ts b/packages/agent-core-v2/src/fileTools/tools/grep.ts new file mode 100644 index 000000000..320013182 --- /dev/null +++ b/packages/agent-core-v2/src/fileTools/tools/grep.ts @@ -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; + +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 { + readonly name = 'Grep' as const; + readonly description = GREP_DESCRIPTION; + readonly parameters: Record = 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 { + 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 { + readonly visible: readonly T[]; + readonly truncated: boolean; + readonly total: number; + readonly nextOffset: number; +} + +function paginate(items: readonly T[], args: GrepInput): Page { + 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): 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}.`; +} diff --git a/packages/agent-core-v2/src/fileTools/tools/line-endings.ts b/packages/agent-core-v2/src/fileTools/tools/line-endings.ts new file mode 100644 index 000000000..3e867b3ff --- /dev/null +++ b/packages/agent-core-v2/src/fileTools/tools/line-endings.ts @@ -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'); +} diff --git a/packages/agent-core-v2/src/fileTools/tools/read.md b/packages/agent-core-v2/src/fileTools/tools/read.md new file mode 100644 index 000000000..79bdda810 --- /dev/null +++ b/packages/agent-core-v2/src/fileTools/tools/read.md @@ -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: `\t` per line. +- A `...` 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. diff --git a/packages/agent-core-v2/src/fileTools/tools/read.ts b/packages/agent-core-v2/src/fileTools/tools/read.ts new file mode 100644 index 000000000..772ea418e --- /dev/null +++ b/packages/agent-core-v2/src/fileTools/tools/read.ts @@ -0,0 +1,500 @@ +/** + * `fileTools` domain — ReadTool, the model's UTF-8 text file reader. + * + * Renders a text file as `\t` per line and appends a + * `` 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; +export type ReadOutput = z.infer; + +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 { + readonly name = 'Read' as const; + readonly description = READ_DESCRIPTION; + readonly parameters: Record = 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 { + try { + let stat: Awaited>; + 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 { + 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 { + 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 = `${message}`; + 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(' '); + } +} diff --git a/packages/agent-core-v2/src/fileTools/tools/write.md b/packages/agent-core-v2/src/fileTools/tools/write.md new file mode 100644 index 000000000..453ab26f8 --- /dev/null +++ b/packages/agent-core-v2/src/fileTools/tools/write.md @@ -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. diff --git a/packages/agent-core-v2/src/fileTools/tools/write.ts b/packages/agent-core-v2/src/fileTools/tools/write.ts new file mode 100644 index 000000000..c101acda6 --- /dev/null +++ b/packages/agent-core-v2/src/fileTools/tools/write.ts @@ -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; +export type WriteOutput = z.infer; + +export class WriteTool implements BuiltinTool { + readonly name = 'Write' as const; + readonly description = WRITE_DESCRIPTION; + readonly parameters: Record = 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 { + 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 { + 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; + } +} diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 1f5ddb6f5..2d5d8d708 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/rpc/rpcService.ts b/packages/agent-core-v2/src/rpc/rpcService.ts index 889474c73..f7af84181 100644 --- a/packages/agent-core-v2/src/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/rpc/rpcService.ts @@ -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, diff --git a/packages/agent-core-v2/src/shellTools/index.ts b/packages/agent-core-v2/src/shellTools/index.ts new file mode 100644 index 000000000..b795bacbc --- /dev/null +++ b/packages/agent-core-v2/src/shellTools/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/shellTools/shellTools.ts b/packages/agent-core-v2/src/shellTools/shellTools.ts new file mode 100644 index 000000000..c0f306661 --- /dev/null +++ b/packages/agent-core-v2/src/shellTools/shellTools.ts @@ -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 = + createDecorator('shellToolsService'); diff --git a/packages/agent-core-v2/src/shellTools/shellToolsService.ts b/packages/agent-core-v2/src/shellTools/shellToolsService.ts new file mode 100644 index 000000000..45135520c --- /dev/null +++ b/packages/agent-core-v2/src/shellTools/shellToolsService.ts @@ -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', +); diff --git a/packages/agent-core-v2/src/shellTools/tools/bash.md b/packages/agent-core-v2/src/shellTools/tools/bash.md new file mode 100644 index 000000000..a2afff7b4 --- /dev/null +++ b/packages/agent-core-v2/src/shellTools/tools/bash.md @@ -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 <` 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 ` 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) diff --git a/packages/agent-core-v2/src/shellTools/tools/bash.ts b/packages/agent-core-v2/src/shellTools/tools/bash.ts new file mode 100644 index 000000000..746425adf --- /dev/null +++ b/packages/agent-core-v2/src/shellTools/tools/bash.ts @@ -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 && ` 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; +export type BashOutput = z.infer; + +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 { + 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 { + readonly name = 'Bash' as const; + readonly description: string; + readonly parameters: Record = 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 { + const shellCwd = this.isWindowsBash ? windowsPathToPosixPath(effectiveCwd) : effectiveCwd; + const shellArgs = [ + this.kaos.osEnv.shellPath, + '-c', + `cd ${shellQuote(shellCwd)} && ${command}`, + ]; + + const noninteractiveEnv: Record = { + 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 { + 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 { + 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'); +} diff --git a/packages/agent-core-v2/src/shellTools/tools/result-builder.ts b/packages/agent-core-v2/src/shellTools/tools/result-builder.ts new file mode 100644 index 000000000..e083465d6 --- /dev/null +++ b/packages/agent-core-v2/src/shellTools/tools/result-builder.ts @@ -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, + }; + } +} diff --git a/packages/agent-core-v2/test/fileTools/edit.test.ts b/packages/agent-core-v2/test/fileTools/edit.test.ts new file mode 100644 index 000000000..d7b2385db --- /dev/null +++ b/packages/agent-core-v2/test/fileTools/edit.test.ts @@ -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; + writeText?: ReturnType; + } = {}, +) { + 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, +): value is Promise { + return typeof (value as Promise).then === 'function'; +} + +async function execute(tool: EditTool, args: EditInput): Promise { + 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'); + }); +}); diff --git a/packages/agent-core-v2/test/fileTools/fileToolsService.test.ts b/packages/agent-core-v2/test/fileTools/fileToolsService.test.ts new file mode 100644 index 000000000..b9f5d4562 --- /dev/null +++ b/packages/agent-core-v2/test/fileTools/fileToolsService.test.ts @@ -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(); + 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']); + }); +}); diff --git a/packages/agent-core-v2/test/fileTools/glob.test.ts b/packages/agent-core-v2/test/fileTools/glob.test.ts new file mode 100644 index 000000000..a1d25ba25 --- /dev/null +++ b/packages/agent-core-v2/test/fileTools/glob.test.ts @@ -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; + stat?: ReturnType; + readdir?: ReturnType; +} = {}) { + const glob = opts.glob ?? vi.fn(async (): Promise => []); + const stat = opts.stat ?? vi.fn(async (): Promise => fileStat()); + const readdir = opts.readdir ?? vi.fn(async (): Promise => []); + + function build(cwd: string): { fs: IAgentFileSystem; withCwd: ReturnType } { + 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): value is Promise { + return typeof (value as Promise).then === 'function'; +} + +async function execute(tool: GlobTool, args: GlobInput): Promise { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => []); + 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 => []); + 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 => { + throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); + }); + const glob = vi.fn(async (): Promise => []); + 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 => { + throw Object.assign(new Error('ENOTDIR: not a directory'), { code: 'ENOTDIR' }); + }); + const glob = vi.fn(async (): Promise => []); + 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 => []); + 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 => []); + 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]); + }); +}); diff --git a/packages/agent-core-v2/test/fileTools/grep.test.ts b/packages/agent-core-v2/test/fileTools/grep.test.ts new file mode 100644 index 000000000..68b6315c7 --- /dev/null +++ b/packages/agent-core-v2/test/fileTools/grep.test.ts @@ -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 { + return { files: [], files_scanned: 0, truncated: false, elapsed_ms: 1, ...overrides }; +} + +function createFakeFs( + response: FsGrepResponse | ((req: FsGrepRequest) => FsGrepResponse | Promise), +) { + 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): value is Promise { + return typeof (value as Promise).then === 'function'; +} + +async function execute(tool: GrepTool, args: GrepInput): Promise { + 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.', + }); + }); +}); diff --git a/packages/agent-core-v2/test/fileTools/read.test.ts b/packages/agent-core-v2/test/fileTools/read.test.ts new file mode 100644 index 000000000..d7d46062e --- /dev/null +++ b/packages/agent-core-v2/test/fileTools/read.test.ts @@ -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 { + for (const line of linesFromContent(content)) { + yield line; + } +} + +function withReadStatus(output: string, status: string): string { + const note = `${status}`; + 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; +} + +/** + * 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) { + 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): value is Promise { + return typeof (value as Promise).then === 'function'; +} + +async function execute(tool: ReadTool, args: ReadInput): Promise { + 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 { + 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 { + 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'; + 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 { + 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('')); + + 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 status block', () => { + const tool = toolWithContent(''); + + expect(tool.description).toContain(''); + // 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); + }); +}); diff --git a/packages/agent-core-v2/test/fileTools/write.test.ts b/packages/agent-core-v2/test/fileTools/write.test.ts new file mode 100644 index 000000000..73db913e2 --- /dev/null +++ b/packages/agent-core-v2/test/fileTools/write.test.ts @@ -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; + /** Override writeText. Default no-op. */ + writeText?: (path: string, data: string) => Promise; + /** Override stat. Default reports an existing directory. */ + stat?: (path: string) => Promise; + /** Override mkdir. Default no-op. */ + mkdir?: (path: string) => Promise; +} + +/** + * 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): value is Promise { + return typeof (value as Promise).then === 'function'; +} + +async function execute(tool: WriteTool, args: WriteInput): Promise { + 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'); + }); +}); diff --git a/packages/agent-core-v2/test/shellTools/bash.test.ts b/packages/agent-core-v2/test/shellTools/bash.test.ts new file mode 100644 index 000000000..3035da0e0 --- /dev/null +++ b/packages/agent-core-v2/test/shellTools/bash.test.ts @@ -0,0 +1,1549 @@ +/** + * BashTool tests for the v2 shellTools domain. + * + * Ported from v1 (`packages/agent-core/test/tools/bash.test.ts`) and adapted + * to the v2 constructor `(runner, kaos, background, options)`. Self-contained: + * builds minimal fake `IProcessRunner` / `IProcess`, `IKaos`, and + * `IBackgroundService` inline so the tool can be exercised without the + * composition root. The fake `IBackgroundService` drives the real + * `ProcessBackgroundTask` so stream observation, timeout and user-interrupt + * semantics match production. + * + * Deviations from v1: + * - The `brief` result field does not exist on v2's `ExecutableToolResult`, + * so v1 assertions on `result.brief` are dropped; the `output` / `message` + * assertions are kept. + * - v1's `execWithEnv(args, env)` is now `runner.exec(args, { env })`, so + * spawn-call assertions read `options.env` from the second argument. + */ + +import { PassThrough, Readable, type Writable } from 'node:stream'; + +import { describe, expect, it, vi } from 'vitest'; + +import { + IBackgroundService, + ProcessBackgroundTask, + type BackgroundTask, + type BackgroundTaskInfo, + type BackgroundTaskOutputSnapshot, + type BackgroundTaskStatus, + type ForegroundTaskReleaseReason, + type RegisterBackgroundTaskOptions, +} from '../../src/background'; +import type { BackgroundTaskSettlement } from '../../src/background/task'; +import type { Environment, IKaos } from '../../src/kaos'; +import type { IProcess, IProcessRunner } from '../../src/process'; +import { type BashInput, BashInputSchema, BashTool } from '../../src/shellTools/tools/bash'; +import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '../../src/tool'; + +const posixEnv: Environment = { + osKind: 'Linux', + osArch: 'arm64', + osVersion: 'test', + shellPath: '/bin/bash', + shellName: 'bash', +}; + +const windowsBashEnv: Environment = { + osKind: 'Windows', + osArch: 'x64', + osVersion: 'test', + shellPath: 'C:\\Program Files\\Git\\bin\\bash.exe', + shellName: 'bash', +}; + +// ── Fake IProcess factories ────────────────────────────────────────── + +function processWithOutput( + options: { + readonly stdout?: string | Buffer; + readonly stderr?: string | Buffer; + readonly exitCode?: number | null; + readonly wait?: () => Promise; + readonly kill?: (signal?: NodeJS.Signals) => Promise; + } = {}, +): IProcess { + const exitCode = options.exitCode ?? 0; + const stdout = Readable.from(options.stdout === undefined ? [] : [options.stdout]); + const stderr = Readable.from(options.stderr === undefined ? [] : [options.stderr]); + return { + stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, + stdout, + stderr, + pid: 123, + exitCode, + wait: vi.fn(options.wait ?? (async () => exitCode)), + kill: vi.fn(options.kill ?? (async () => {})), + dispose: vi.fn(async () => { + stdout.destroy(); + stderr.destroy(); + }), + }; +} + +function processWithInterleavedOutput( + events: ReadonlyArray<{ + readonly stream: 'stdout' | 'stderr'; + readonly text: string; + readonly delayMs: number; + }>, + exitCode = 0, +): IProcess { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const lastDelay = Math.max(...events.map((event) => event.delayMs), 0); + const waitPromise = new Promise((resolve) => { + for (const event of events) { + setTimeout(() => { + const target = event.stream === 'stdout' ? stdout : stderr; + target.write(event.text); + }, event.delayMs); + } + setTimeout(() => { + stdout.end(); + stderr.end(); + resolve(exitCode); + }, lastDelay + 1); + }); + + return { + stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, + stdout, + stderr, + pid: 124, + exitCode, + wait: vi.fn(async () => waitPromise), + kill: vi.fn(async () => {}), + dispose: vi.fn(async () => { + stdout.destroy(); + stderr.destroy(); + }), + }; +} + +function pendingProcess(): { + readonly proc: IProcess; + readonly finish: (exitCode?: number) => void; +} { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let resolveWait: (exitCode: number) => void = () => {}; + let currentExitCode: number | null = null; + const waitPromise = new Promise((resolve) => { + resolveWait = resolve; + }); + const finish = (exitCode = 0): void => { + if (currentExitCode !== null) return; + currentExitCode = exitCode; + stdout.end(); + stderr.end(); + resolveWait(exitCode); + }; + return { + proc: { + stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, + stdout, + stderr, + pid: 125, + get exitCode(): number | null { + return currentExitCode; + }, + wait: vi.fn(async () => waitPromise), + kill: vi.fn(async () => { + finish(143); + }) as IProcess['kill'], + dispose: vi.fn(async () => {}), + }, + finish, + }; +} + +function processWithVisibleExitBeforeWait(exitCode = 0): { + proc: IProcess; + finishWait: () => void; + markExited: () => void; +} { + let currentExitCode: number | null = null; + let resolveWait: (code: number) => void = () => {}; + const waitPromise = new Promise((resolve) => { + resolveWait = resolve; + }); + const proc: IProcess = { + stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, + stdout: Readable.from([]), + stderr: Readable.from([]), + pid: 125, + get exitCode(): number | null { + return currentExitCode; + }, + wait: vi.fn(async () => waitPromise), + kill: vi.fn(async () => {}), + dispose: vi.fn(async () => {}), + }; + + return { + proc, + finishWait: () => { + resolveWait(exitCode); + }, + markExited: () => { + currentExitCode = exitCode; + }, + }; +} + +function processThatNeverExits(): IProcess { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + return { + stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, + stdout, + stderr, + pid: 126, + exitCode: null, + wait: vi.fn(async () => new Promise(() => {})), + kill: vi.fn(async () => {}), + dispose: vi.fn(async () => { + stdout.destroy(); + stderr.destroy(); + }), + }; +} + +function processWithStreamError(options: { + readonly stdoutError?: Error; + readonly stderrError?: Error; + readonly exitCode?: number; +} = {}): IProcess { + const exitCode = options.exitCode ?? 0; + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const waitPromise = new Promise((resolve) => { + setTimeout(() => { + if (options.stdoutError !== undefined) { + stdout.emit('error', options.stdoutError); + } else { + stdout.end(); + } + if (options.stderrError !== undefined) { + stderr.emit('error', options.stderrError); + } else { + stderr.end(); + } + resolve(exitCode); + }, 1); + }); + return { + stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, + stdout, + stderr, + pid: 128, + exitCode, + wait: vi.fn(async () => waitPromise), + kill: vi.fn(async () => {}), + dispose: vi.fn(async () => {}), + }; +} + +function processWithOpenStreamsThatExitOnKill(): IProcess { + let currentExitCode: number | null = null; + let resolveWait: (code: number) => void = () => {}; + const waitPromise = new Promise((resolve) => { + resolveWait = resolve; + }); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + + return { + stdin: { end: vi.fn(), write: vi.fn() } as unknown as Writable, + stdout, + stderr, + pid: 127, + get exitCode(): number | null { + return currentExitCode; + }, + wait: vi.fn(async () => waitPromise), + kill: vi.fn(async () => { + currentExitCode = 143; + resolveWait(143); + }), + dispose: vi.fn(async () => { + stdout.destroy(); + stderr.destroy(); + }), + }; +} + +// ── Fake IKaos ─────────────────────────────────────────────────────── + +function createTestKaos(osEnv: Environment = posixEnv, cwd = '/workspace'): IKaos { + return { + name: 'fake', + cwd, + osEnv, + pathClass: () => 'posix', + gethome: () => '/home/test', + } as unknown as IKaos; +} + +// ── Fake IProcessRunner ────────────────────────────────────────────── + +function createTestRunner(proc: IProcess | ReturnType) { + const exec = typeof proc === 'function' ? proc : vi.fn().mockResolvedValue(proc); + const runner = { exec } as unknown as IProcessRunner; + return { runner, exec }; +} + +// ── Fake IBackgroundService ────────────────────────────────────────── + +const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'completed', + 'failed', + 'timed_out', + 'killed', + 'lost', +]); +const SIGTERM_GRACE_MS = 5_000; +const USER_INTERRUPT_REASON = 'Interrupted by user'; +const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; + +interface ForegroundRelease { + readonly promise: Promise; + resolve(reason: ForegroundTaskReleaseReason): void; +} + +interface ManagedEntry { + readonly taskId: string; + readonly task: BackgroundTask; + readonly startedDetached: boolean; + readonly outputChunks: string[]; + readonly abortController: AbortController; + readonly startedAt: number; + readonly waiters: Array<() => void>; + status: BackgroundTaskStatus; + stopReason?: string; + endedAt: number | null; + foregroundRelease?: ForegroundRelease; + timeoutHandle?: ReturnType; + lifecyclePromise: Promise; + signalCleanup?: () => void; +} + +function createRelease(): ForegroundRelease { + let resolve!: (reason: ForegroundTaskReleaseReason) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function isTerminal(status: BackgroundTaskStatus): boolean { + return TERMINAL_STATUSES.has(status); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function createFakeBackgroundService(options: { maxRunningTasks?: number } = {}): { + readonly service: IBackgroundService; + readonly tasks: Map; +} { + const tasks = new Map(); + let counter = 0; + + const nextId = (prefix: string): string => { + counter += 1; + const suffix = counter.toString(TASK_ID_ALPHABET.length).padStart(8, '0'); + return `${prefix}-${suffix}`; + }; + + const entryToInfo = (entry: ManagedEntry): BackgroundTaskInfo => { + return entry.task.toInfo({ + taskId: entry.taskId, + description: entry.task.description, + status: entry.status, + detached: entry.foregroundRelease === undefined, + startedAt: entry.startedAt, + endedAt: entry.endedAt, + stopReason: entry.stopReason, + }); + }; + + const settleTask = (entry: ManagedEntry, settlement: BackgroundTaskSettlement): boolean => { + if (isTerminal(entry.status)) return false; + entry.status = settlement.status; + entry.endedAt = Date.now(); + entry.stopReason = + settlement.stopReason ?? (settlement.status === 'killed' ? entry.stopReason : undefined); + entry.signalCleanup?.(); + entry.signalCleanup = undefined; + if (entry.timeoutHandle !== undefined) { + clearTimeout(entry.timeoutHandle); + entry.timeoutHandle = undefined; + } + entry.foregroundRelease?.resolve('terminal'); + const waiters = entry.waiters.splice(0); + for (const waiter of waiters) waiter(); + return true; + }; + + const stopEntry = async ( + entry: ManagedEntry, + reason: string | undefined, + ): Promise => { + if (isTerminal(entry.status)) return entryToInfo(entry); + entry.stopReason = reason; + entry.abortController.abort(reason); + + let graceTimer: ReturnType | undefined; + const graceful = await Promise.race([ + entry.lifecyclePromise.then( + () => true, + () => true, + ), + new Promise((resolve) => { + graceTimer = setTimeout(() => { + resolve(false); + }, SIGTERM_GRACE_MS); + graceTimer.unref?.(); + }), + ]); + if (graceTimer !== undefined) clearTimeout(graceTimer); + + if (isTerminal(entry.status)) return entryToInfo(entry); + if (!graceful) { + try { + await entry.task.forceStop?.(); + } catch { + /* best effort */ + } + } + if (isTerminal(entry.status)) return entryToInfo(entry); + settleTask(entry, { status: 'killed', stopReason: reason }); + return entryToInfo(entry); + }; + + const activeDetachedCount = (): number => { + let count = 0; + for (const entry of tasks.values()) { + if (entry.startedDetached && !isTerminal(entry.status)) count += 1; + } + return count; + }; + + const service: IBackgroundService = { + _serviceBrand: undefined, + + registerTask(task: BackgroundTask, registerOptions: RegisterBackgroundTaskOptions = {}): string { + const detached = registerOptions.detached ?? true; + if (detached && options.maxRunningTasks !== undefined) { + if (activeDetachedCount() >= options.maxRunningTasks) { + throw new Error('Too many background tasks are already running.'); + } + } + + const taskId = nextId(task.idPrefix); + const abortController = new AbortController(); + const entry: ManagedEntry = { + taskId, + task, + startedDetached: detached, + outputChunks: [], + abortController, + startedAt: Date.now(), + waiters: [], + status: 'running', + endedAt: null, + foregroundRelease: detached ? undefined : createRelease(), + lifecyclePromise: Promise.resolve(), + }; + tasks.set(taskId, entry); + + const timeoutMs = registerOptions.timeoutMs; + if (timeoutMs !== undefined && timeoutMs > 0) { + entry.timeoutHandle = setTimeout(() => { + entry.abortController.abort('Timed out'); + void settleTask(entry, { status: 'timed_out' }); + }, timeoutMs); + entry.timeoutHandle.unref?.(); + } + + entry.lifecyclePromise = Promise.resolve() + .then(() => + task.start({ + signal: abortController.signal, + appendOutput: (chunk: string) => { + entry.outputChunks.push(chunk); + }, + settle: async (settlement: BackgroundTaskSettlement) => settleTask(entry, settlement), + }), + ) + .catch((error: unknown) => { + const status = abortController.signal.aborted ? 'killed' : 'failed'; + void settleTask(entry, { + status, + stopReason: status === 'failed' ? errorMessage(error) : undefined, + }); + }); + + if (!detached && registerOptions.signal !== undefined) { + const signal = registerOptions.signal; + const abortFromSignal = (): void => { + if (entry.foregroundRelease === undefined) return; + void stopEntry(entry, USER_INTERRUPT_REASON); + }; + if (signal.aborted) { + abortFromSignal(); + } else { + signal.addEventListener('abort', abortFromSignal, { once: true }); + entry.signalCleanup = () => { + signal.removeEventListener('abort', abortFromSignal); + }; + } + } + + return taskId; + }, + + getTask(taskId: string): BackgroundTaskInfo | undefined { + const entry = tasks.get(taskId); + return entry === undefined ? undefined : entryToInfo(entry); + }, + + list(activeOnly = true): readonly BackgroundTaskInfo[] { + const result: BackgroundTaskInfo[] = []; + for (const entry of tasks.values()) { + const info = entryToInfo(entry); + if (activeOnly && isTerminal(info.status)) continue; + result.push(info); + } + return result; + }, + + persistOutput(): void { + /* no-op in the fake */ + }, + + async getOutputSnapshot(taskId: string): Promise { + const entry = tasks.get(taskId); + const preview = entry === undefined ? '' : entry.outputChunks.join(''); + return { + outputSizeBytes: preview.length, + previewBytes: preview.length, + truncated: false, + fullOutputAvailable: false, + preview, + }; + }, + + async readOutput(taskId: string, tail?: number): Promise { + const entry = tasks.get(taskId); + const output = entry === undefined ? '' : entry.outputChunks.join(''); + if (tail === undefined) return output; + return output.slice(-Math.max(0, Math.trunc(tail))); + }, + + async suppressTerminalNotification(): Promise { + /* no-op in the fake */ + }, + + detach(taskId: string): BackgroundTaskInfo | undefined { + const entry = tasks.get(taskId); + if (entry === undefined) return undefined; + if (isTerminal(entry.status)) return entryToInfo(entry); + const release = entry.foregroundRelease; + if (release === undefined) return entryToInfo(entry); + entry.foregroundRelease = undefined; + entry.signalCleanup?.(); + entry.signalCleanup = undefined; + try { + entry.task.onDetach?.(); + } catch { + /* detach already succeeded */ + } + release.resolve('detached'); + return entryToInfo(entry); + }, + + async stop(taskId: string, reason?: string): Promise { + const entry = tasks.get(taskId); + if (entry === undefined) return undefined; + return stopEntry(entry, reason); + }, + + async stopAll(reason?: string): Promise { + const results = await Promise.all( + Array.from(tasks.keys()).map((taskId) => service.stop(taskId, reason)), + ); + return results.filter((info): info is BackgroundTaskInfo => info !== undefined); + }, + + async wait(taskId: string, timeoutMs = 30_000): Promise { + const entry = tasks.get(taskId); + if (entry === undefined) return undefined; + if (isTerminal(entry.status)) return entryToInfo(entry); + let waiter: (() => void) | undefined; + let timeout: ReturnType | undefined; + try { + await Promise.race([ + new Promise((resolve) => { + waiter = resolve; + entry.waiters.push(resolve); + }), + new Promise((resolve) => { + timeout = setTimeout(resolve, timeoutMs); + timeout.unref?.(); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + if (waiter !== undefined) { + const index = entry.waiters.indexOf(waiter); + if (index !== -1) entry.waiters.splice(index, 1); + } + } + return entryToInfo(entry); + }, + + async waitForForegroundRelease( + taskId: string, + ): Promise { + const entry = tasks.get(taskId); + if (entry === undefined) return undefined; + if (isTerminal(entry.status)) return 'terminal'; + const release = entry.foregroundRelease; + if (release === undefined) return 'detached'; + return Promise.race([ + release.promise, + entry.lifecyclePromise.then(() => 'terminal' as const), + ]); + }, + }; + + return { service, tasks }; +} + +// ── Test execution helper ──────────────────────────────────────────── + +function context(args: BashInput, signal = new AbortController().signal) { + return { turnId: '0', toolCallId: 'call_bash', args, signal }; +} + +function isPromiseLike(value: ToolExecution | Promise): value is Promise { + return typeof (value as Promise).then === 'function'; +} + +async function executeTool( + tool: BashTool, + ctx: ReturnType, +): Promise { + const { args, ...executionContext } = ctx; + const resolved = tool.resolveExecution(args); + const execution = isPromiseLike(resolved) ? await resolved : resolved; + if (execution.isError === true) return execution; + return execution.execute(executionContext as ExecutableToolContext); +} + +function bashTool( + runner: IProcessRunner, + kaos: IKaos = createTestKaos(), + background: IBackgroundService = createFakeBackgroundService().service, + options?: ConstructorParameters[3], +): BashTool { + return new BashTool(runner, kaos, background, options); +} + +// ── Tests ──────────────────────────────────────────────────────────── + +describe('BashTool', () => { + it('exposes current metadata and schema', () => { + const { runner } = createTestRunner(processWithOutput()); + const tool = bashTool(runner); + + expect(tool.name).toBe('Bash'); + expect(tool.parameters).toMatchObject({ + type: 'object', + properties: { command: { type: 'string' } }, + }); + expect(BashInputSchema.safeParse({ command: 'echo hello' }).success).toBe(true); + expect(BashInputSchema.safeParse({ command: '' }).success).toBe(false); + expect(BashInputSchema.safeParse({ command: 'echo x', timeout: 0 }).success).toBe(false); + expect(BashInputSchema.safeParse({ command: 'echo x', timeout: 300 }).success).toBe(true); + expect(BashInputSchema.safeParse({ command: 'echo x', timeout: 301 }).success).toBe(false); + expect(BashInputSchema.safeParse({ command: 'echo x', timeout: 300_000 }).success).toBe(false); + expect(BashInputSchema.safeParse({ command: 'echo x', timeout: 300_001 }).success).toBe(false); + expect( + BashInputSchema.safeParse({ + command: 'watch', + run_in_background: true, + description: 'watch files', + timeout: 86_400, + }).success, + ).toBe(true); + expect( + BashInputSchema.safeParse({ + command: 'watch', + run_in_background: true, + description: 'watch files', + timeout: 86_401, + }).success, + ).toBe(false); + expect( + BashInputSchema.safeParse({ + command: 'watch', + run_in_background: true, + description: 'watch files', + timeout: 600_000, + }).success, + ).toBe(false); + expect( + BashInputSchema.safeParse({ + command: 'watch', + run_in_background: true, + description: 'watch files', + disable_timeout: true, + }).success, + ).toBe(true); + }); + + it('describes the cwd, command, run_in_background, description, and disable_timeout parameters', () => { + const { runner } = createTestRunner(processWithOutput()); + const tool = bashTool(runner); + const properties = (tool.parameters as { properties: Record }) + .properties; + + for (const name of [ + 'cwd', + 'command', + 'run_in_background', + 'description', + 'disable_timeout', + ] as const) { + const description = properties[name]?.description; + expect(description, `${name} should have a non-empty description`).toBeTruthy(); + expect((description ?? '').trim().length).toBeGreaterThan(0); + } + }); + + it('exposes a default timeout in the JSON Schema', () => { + const { runner } = createTestRunner(processWithOutput()); + const tool = bashTool(runner); + const properties = (tool.parameters as { properties: Record }) + .properties; + + expect(properties['timeout']?.default).toBe(60); + }); + + it('renders the available commands section and the /tasks hint', () => { + const { runner } = createTestRunner(processWithOutput()); + const tool = bashTool(runner); + + expect(tool.description).toContain('Commands available'); + expect(tool.description).toContain('/tasks'); + }); + + it('runs through runner.exec, injects cwd, noninteractive env, and closes stdin', async () => { + const proc = processWithOutput({ stdout: 'ok\n' }); + const { runner, exec } = createTestRunner(proc); + const tool = bashTool(runner); + + const result = await executeTool(tool, context({ command: 'printf ok', timeout: 60 })); + + expect(exec).toHaveBeenCalledTimes(1); + const [argv, execOptions] = exec.mock.calls[0]!; + expect(argv).toEqual(['/bin/bash', '-c', "cd '/workspace' && printf ok"]); + expect(execOptions?.env).toMatchObject({ + NO_COLOR: '1', + TERM: 'dumb', + }); + expect(proc.stdin.end).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + output: 'ok\n', + isError: false, + message: 'Command executed successfully.', + }); + }); + + it('uses args.cwd when provided', async () => { + const { runner, exec } = createTestRunner(processWithOutput({ stdout: 'sub\n' })); + const tool = bashTool(runner); + + await executeTool(tool, context({ command: 'pwd', cwd: '/tmp/project', timeout: 60 })); + + expect(exec.mock.calls[0]?.[0]).toEqual(['/bin/bash', '-c', "cd '/tmp/project' && pwd"]); + }); + + it('uses the kaos cwd as the default working directory', async () => { + const { runner, exec } = createTestRunner(processWithOutput({ stdout: '' })); + const tool = bashTool(runner, createTestKaos(posixEnv, '/var/app')); + + await executeTool(tool, context({ command: 'pwd', timeout: 60 })); + + expect(exec.mock.calls[0]?.[0]).toEqual(['/bin/bash', '-c', "cd '/var/app' && pwd"]); + }); + + it('uses Git Bash semantics on Windows', async () => { + const proc = processWithOutput({ stdout: 'ok\n' }); + const { runner, exec } = createTestRunner(proc); + const tool = bashTool(runner, createTestKaos(windowsBashEnv, 'C:\\Users\\me\\project')); + + const result = await executeTool(tool, context({ command: 'echo ok 2>nul', timeout: 60 })); + + expect(exec).toHaveBeenCalledTimes(1); + const [argv, execOptions] = exec.mock.calls[0]!; + expect(argv).toEqual([ + 'C:\\Program Files\\Git\\bin\\bash.exe', + '-c', + "cd '/c/Users/me/project' && echo ok 2>/dev/null", + ]); + expect(execOptions?.env).toMatchObject({ SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe' }); + expect(result).toMatchObject({ + output: 'ok\n', + isError: false, + message: 'Command executed successfully.', + }); + }); + + it('returns stderr and marks non-zero exit codes as tool errors', async () => { + const { runner } = createTestRunner(processWithOutput({ stderr: 'boom\n', exitCode: 2 })); + const tool = bashTool(runner); + + const result = await executeTool(tool, context({ command: 'exit 2', timeout: 60 })); + + expect(result).toMatchObject({ + isError: true, + message: 'Command failed with exit code: 2.', + }); + expect(result.output).toContain('boom\n'); + expect(result.output).toContain('Command failed with exit code: 2.'); + }); + + it('returns both stdout and stderr when a command succeeds', async () => { + const { runner } = createTestRunner(processWithOutput({ stdout: 'out\n', stderr: 'warn\n' })); + const tool = bashTool(runner); + + const result = await executeTool(tool, context({ command: 'mixed', timeout: 60 })); + + expect(result).toMatchObject({ + output: 'out\nwarn\n', + isError: false, + message: 'Command executed successfully.', + }); + }); + + it('returns both stdout and stderr when a command fails', async () => { + const { runner } = createTestRunner( + processWithOutput({ stdout: 'partial\n', stderr: 'boom\n', exitCode: 2 }), + ); + const tool = bashTool(runner); + + const result = await executeTool(tool, context({ command: 'mixed fail', timeout: 60 })); + + expect(result).toMatchObject({ + isError: true, + message: 'Command failed with exit code: 2.', + }); + expect(result.output).toContain('partial\nboom\n'); + expect(result.output).toContain('Command failed with exit code: 2.'); + }); + + it('returns the service failure reason when foreground process wait rejects', async () => { + const { runner } = createTestRunner( + processWithOutput({ + stdout: 'partial output\n', + exitCode: null, + wait: async () => { + throw new Error('wait failed'); + }, + }), + ); + const tool = bashTool(runner); + + const result = await executeTool(tool, context({ command: 'wait fails', timeout: 60 })); + + expect(result).toMatchObject({ + isError: true, + message: 'wait failed', + }); + expect(result.output).toContain('partial output\nwait failed'); + expect(result.output).not.toContain('exit code: null'); + }); + + it('preserves foreground stdout and stderr arrival order', async () => { + vi.useFakeTimers(); + try { + const proc = processWithInterleavedOutput([ + { stream: 'stderr', text: 'err-first\n', delayMs: 0 }, + { stream: 'stdout', text: 'out-second\n', delayMs: 5 }, + { stream: 'stderr', text: 'err-third\n', delayMs: 10 }, + ]); + const { runner } = createTestRunner(proc); + const tool = bashTool(runner); + + const resultPromise = executeTool(tool, context({ command: 'mixed', timeout: 60 })); + await vi.advanceTimersByTimeAsync(11); + + const result = await resultPromise; + expect(result).toMatchObject({ + isError: false, + output: 'err-first\nout-second\nerr-third\n', + message: 'Command executed successfully.', + }); + } finally { + vi.useRealTimers(); + } + }); + + it('interprets small timeout values as seconds at runtime', async () => { + vi.useFakeTimers(); + try { + let resolveWait: (code: number) => void = () => {}; + const waitPromise = new Promise((resolve) => { + resolveWait = resolve; + }); + const proc = processWithOutput({ + wait: async () => waitPromise, + kill: async () => { + resolveWait(143); + }, + }); + const { runner } = createTestRunner(proc); + const tool = bashTool(runner); + + const running = executeTool(tool, context({ command: 'sleep 3', timeout: 2 })); + await vi.advanceTimersByTimeAsync(1_999); + expect(proc.kill).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + const result = await running; + + expect(proc.kill).toHaveBeenCalled(); + expect(result.output).toContain('Command killed by timeout (2s)'); + } finally { + vi.useRealTimers(); + } + }); + + it('reports a timed-out command with the timeout message', async () => { + vi.useFakeTimers(); + try { + let resolveWait: (code: number) => void = () => {}; + const waitPromise = new Promise((resolve) => { + resolveWait = resolve; + }); + const proc = processWithOutput({ + wait: async () => waitPromise, + kill: async () => { + resolveWait(143); + }, + }); + const { runner } = createTestRunner(proc); + const tool = bashTool(runner); + + const running = executeTool(tool, context({ command: 'sleep 2', timeout: 1 })); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(250); + const result = await running; + + expect(result).toMatchObject({ isError: true }); + expect(result.output).toContain('Command killed by timeout (1s)'); + } finally { + vi.useRealTimers(); + } + }); + + it('reports timeout instead of premature close when cleanup destroys open output streams', async () => { + vi.useFakeTimers(); + try { + const proc = processWithOpenStreamsThatExitOnKill(); + const { runner } = createTestRunner(proc); + const tool = bashTool(runner); + + const running = executeTool(tool, context({ command: 'sleep 2', timeout: 1 })); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(250); + const result = await running; + + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + expect(result).toMatchObject({ isError: true }); + expect(result.output).toContain('Command killed by timeout (1s)'); + expect(result.output).not.toContain('Premature close'); + } finally { + vi.useRealTimers(); + } + }); + + it('reports a stream read error as a tool error even when the process exits with code 0', async () => { + const proc = processWithStreamError({ + stdoutError: new Error('SSH channel read failed'), + exitCode: 0, + }); + const { runner } = createTestRunner(proc); + const tool = bashTool(runner); + + const result = await executeTool(tool, context({ command: 'remote-cmd', timeout: 60 })); + + expect(result).toMatchObject({ isError: true }); + expect(result.output).toContain('SSH channel read failed'); + }); + + it('does not spawn when the signal is already aborted', async () => { + const controller = new AbortController(); + controller.abort(); + const { runner, exec } = createTestRunner(processWithOutput()); + const tool = bashTool(runner); + + const result = await executeTool(tool, context({ command: 'echo nope' }, controller.signal)); + + expect(result).toEqual({ isError: true, output: 'Aborted before command started' }); + expect(exec).not.toHaveBeenCalled(); + }); + + it('kills the process and returns an abort result when aborted while running', async () => { + let resolveWait: (code: number) => void = () => {}; + const waitPromise = new Promise((resolve) => { + resolveWait = resolve; + }); + const proc = processWithOutput({ + wait: async () => waitPromise, + kill: async () => { + resolveWait(143); + }, + }); + const { runner } = createTestRunner(proc); + const controller = new AbortController(); + const tool = bashTool(runner); + + const running = executeTool(tool, context({ command: 'sleep 10' }, controller.signal)); + await vi.waitFor(() => { + expect(proc.stdin.end).toHaveBeenCalled(); + }); + controller.abort(); + const result = await running; + + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + expect(result).toMatchObject({ isError: true }); + expect(result.output).toContain('Interrupted by user'); + }); + + it('adds a truncation note when stdout exceeds the cap', async () => { + const huge = Buffer.alloc(10 * 1024 * 1024 + 1, 'x'); + const { runner } = createTestRunner(processWithOutput({ stdout: huge })); + const tool = bashTool(runner); + + const result = await executeTool(tool, context({ command: 'yes', timeout: 60 })); + + expect(result.output).toContain('[...truncated]'); + expect(result.output).toContain('Output is truncated'); + expect((result as { message?: string }).message).toContain('Output is truncated'); + }); + + it('marks the truncated output buffer with a "[...truncated]" sentinel at the cut point', async () => { + const huge = Buffer.alloc(10 * 1024 * 1024 + 1, 'x'); + const { runner } = createTestRunner(processWithOutput({ stdout: huge })); + const tool = bashTool(runner); + + const result = await executeTool(tool, context({ command: 'yes', timeout: 60 })); + + expect(typeof result.output).toBe('string'); + const output = result.output as string; + expect(output).toContain('[...truncated]'); + }); + + it('truncates output with the sentinel even when the command fails', async () => { + const huge = Buffer.alloc(10 * 1024 * 1024 + 1, 'E'); + const { runner } = createTestRunner(processWithOutput({ stdout: huge, exitCode: 1 })); + const tool = bashTool(runner); + + const result = await executeTool(tool, context({ command: 'fail-and-flood', timeout: 60 })); + + expect(result).toMatchObject({ isError: true }); + expect(typeof result.output).toBe('string'); + const output = result.output as string; + expect(output).toContain('[...truncated]'); + expect(output).toContain('Output is truncated'); + }); + + it('rejects empty-string commands at the schema layer', () => { + expect(BashInputSchema.safeParse({ command: '' }).success).toBe(false); + }); + + it('does not inject GIT_SSH_COMMAND into the spawn environment', async () => { + const previous = process.env['GIT_SSH_COMMAND']; + delete process.env['GIT_SSH_COMMAND']; + try { + const { runner, exec } = createTestRunner(processWithOutput({ stdout: 'ok\n' })); + const tool = bashTool(runner); + + await executeTool(tool, context({ command: 'true', timeout: 60 })); + + const env = exec.mock.calls[0]?.[1]?.env as Record; + expect(Object.prototype.hasOwnProperty.call(env, 'GIT_SSH_COMMAND')).toBe(false); + } finally { + if (previous !== undefined) process.env['GIT_SSH_COMMAND'] = previous; + } + }); + + it('rewrites nul-redirect on Windows so the spawned argv has /dev/null', async () => { + const { runner, exec } = createTestRunner(processWithOutput({ stdout: '' })); + const tool = bashTool(runner, createTestKaos(windowsBashEnv, 'C:\\Users\\me\\project')); + + await executeTool(tool, context({ command: 'ls 2>nul', timeout: 60 })); + + const argv = exec.mock.calls[0]?.[0] as readonly string[]; + expect(argv[2]).toBe("cd '/c/Users/me/project' && ls 2>/dev/null"); + }); + + it('passes nul-redirect through unchanged on Linux so the argv keeps the literal file target', async () => { + const { runner, exec } = createTestRunner(processWithOutput({ stdout: '' })); + const tool = bashTool(runner); + + await executeTool(tool, context({ command: 'ls 2>nul', timeout: 60 })); + + const argv = exec.mock.calls[0]?.[0] as readonly string[]; + expect(argv[2]).toBe("cd '/workspace' && ls 2>nul"); + }); + + it('exposes a shell description that documents /bin/bash, TaskOutput/TaskStop, safety and efficiency sections, and background semantics', () => { + const { runner } = createTestRunner(processWithOutput()); + const tool = bashTool(runner); + + const description = tool.description; + expect(description).toContain('`bash`'); + expect(description).toContain('TaskOutput'); + expect(description).toContain('TaskStop'); + expect(description).toContain('**Guidelines for safety and security:**'); + expect(description).toContain('**Guidelines for efficiency:**'); + expect(description).toContain('run_in_background=true'); + expect(description).toContain('automatically notified'); + }); +}); + +describe('BashTool background mode', () => { + it('can detach a foreground command through the background service', async () => { + const { proc, finish } = pendingProcess(); + const { runner } = createTestRunner(proc); + const { service } = createFakeBackgroundService(); + const tool = bashTool(runner, createTestKaos(), service); + + const running = executeTool(tool, context({ command: 'sleep 10', timeout: 60 })); + await vi.waitFor(() => { + expect(service.list(false)).toHaveLength(1); + }); + const task = service.list(false)[0]!; + await vi.waitFor(() => { + expect((proc.stdout as PassThrough).listenerCount('data')).toBeGreaterThanOrEqual(1); + }); + (proc.stdout as PassThrough).write('before detach\n'); + + expect(task).toMatchObject({ + kind: 'process', + detached: false, + command: 'sleep 10', + }); + + service.detach(task.taskId); + const result = await running; + (proc.stdout as PassThrough).write('after detach\n'); + + expect(result).toMatchObject({ isError: false }); + expect(result.output).toContain('before detach\n'); + expect(result.output).not.toContain('after detach\n'); + expect(result.output).toContain(`task_id: ${task.taskId}`); + expect(result.output).toContain('automatic_notification: true'); + expect(result.output).toContain('do NOT wait, poll, or call TaskOutput'); + expect(service.getTask(task.taskId)).toMatchObject({ detached: true }); + await vi.waitFor(async () => { + await expect(service.readOutput(task.taskId)).resolves.toContain('after detach\n'); + }); + + finish(); + await expect(service.wait(task.taskId)).resolves.toMatchObject({ + status: 'completed', + }); + }); + + it('does not recommend disabled task tools when a foreground command is detached', async () => { + const { proc, finish } = pendingProcess(); + const { runner } = createTestRunner(proc); + const { service } = createFakeBackgroundService(); + const tool = bashTool(runner, createTestKaos(), service, { allowBackground: false }); + + const running = executeTool(tool, context({ command: 'sleep 10', timeout: 60 })); + await vi.waitFor(() => { + expect(service.list(false)).toHaveLength(1); + }); + const task = service.list(false)[0]!; + + service.detach(task.taskId); + const result = await running; + + expect(result.output).toContain(`task_id: ${task.taskId}`); + expect(result.output).toContain('You will be automatically notified when it completes'); + expect(result.output).toContain('do NOT wait or poll'); + expect(result.output).not.toContain('TaskOutput'); + expect(result.output).not.toContain('TaskStop'); + + finish(); + await expect(service.wait(task.taskId)).resolves.toMatchObject({ + status: 'completed', + }); + }); + + it('keeps task metadata independent when noisy foreground output is capped before detach', async () => { + const { proc, finish } = pendingProcess(); + const { runner } = createTestRunner(proc); + const { service } = createFakeBackgroundService(); + const tool = bashTool(runner, createTestKaos(), service); + + const running = executeTool(tool, context({ command: 'yes noisy', timeout: 60 })); + await vi.waitFor(() => { + expect(service.list(false)).toHaveLength(1); + }); + const task = service.list(false)[0]!; + await vi.waitFor(() => { + expect((proc.stdout as PassThrough).listenerCount('data')).toBeGreaterThanOrEqual(1); + }); + + (proc.stdout as PassThrough).write( + Array.from({ length: 6000 }, (_, index) => `noisy output line ${String(index)}\n`).join(''), + ); + service.detach(task.taskId); + const result = await running; + + expect(result).toMatchObject({ isError: false }); + expect(typeof result.output).toBe('string'); + const output = result.output as string; + expect(output).toContain(`task_id: ${task.taskId}`); + expect(output).toContain('automatic_notification: true'); + expect(output).toContain('foreground_output:'); + expect(output).toContain('noisy output line 0'); + expect(output).toContain('[...truncated]'); + expect(output).toContain('Output is truncated to fit in the message.'); + expect(output.indexOf(`task_id: ${task.taskId}`)).toBeLessThan( + output.indexOf('foreground_output:'), + ); + + finish(); + await expect(service.wait(task.taskId)).resolves.toMatchObject({ + status: 'completed', + detached: true, + }); + }); + + it('requires background tools to be enabled and description for background commands', async () => { + const proc = processWithOutput(); + const { runner, exec } = createTestRunner(proc); + const backgroundDisabled = bashTool( + runner, + createTestKaos(), + createFakeBackgroundService().service, + { allowBackground: false }, + ); + + const unavailable = await executeTool( + backgroundDisabled, + context({ command: 'sleep 10', run_in_background: true, description: 'watch' }), + ); + expect(unavailable).toMatchObject({ isError: true }); + expect(unavailable.output).toContain('Background execution is not available'); + expect(exec).not.toHaveBeenCalled(); + + const { service } = createFakeBackgroundService(); + const withService = bashTool(runner, createTestKaos(), service); + const missingDescription = await executeTool( + withService, + context({ command: 'sleep 10', run_in_background: true }), + ); + + expect(missingDescription).toMatchObject({ isError: true }); + expect(missingDescription.output).toContain('description is required'); + expect(exec).not.toHaveBeenCalled(); + }); + + it('registers background commands and returns a task id', async () => { + const proc = processWithOutput(); + const { runner } = createTestRunner(proc); + const { service } = createFakeBackgroundService(); + const tool = bashTool(runner, createTestKaos(), service); + + const result = await executeTool( + tool, + context({ command: 'sleep 10', run_in_background: true, description: 'long running task' }), + ); + + expect(result.output).toMatch(/task_id: bash-[0-9a-z]{8}/); + expect(result.output).toContain('automatic_notification: true'); + expect(service.list(false)).toHaveLength(1); + }); + + it('kills a spawned background command when the task limit is reached', async () => { + const { service } = createFakeBackgroundService({ maxRunningTasks: 1 }); + service.registerTask(new ProcessBackgroundTask(processWithOutput(), 'sleep 10', 'existing task')); + const rejectedProc = processWithOutput(); + const { runner, exec } = createTestRunner(rejectedProc); + const tool = bashTool(runner, createTestKaos(), service); + + const result = await executeTool( + tool, + context({ command: 'sleep 10', run_in_background: true, description: 'second task' }), + ); + + expect(result).toMatchObject({ + isError: true, + output: 'Too many background tasks are already running.', + }); + expect(exec).toHaveBeenCalledTimes(1); + expect(rejectedProc.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('rejects one of two concurrent background commands when the task limit is reached', async () => { + const { service } = createFakeBackgroundService({ maxRunningTasks: 1 }); + const firstProc = processWithOutput({ + wait: () => new Promise(() => {}), + }); + const secondProc = processWithOutput(); + const exec = vi.fn().mockResolvedValueOnce(firstProc).mockResolvedValueOnce(secondProc); + const { runner } = createTestRunner(exec); + const tool = bashTool(runner, createTestKaos(), service); + + const first = executeTool( + tool, + context({ command: 'sleep 10', run_in_background: true, description: 'first task' }), + ); + const second = executeTool( + tool, + context({ command: 'sleep 10', run_in_background: true, description: 'second task' }), + ); + + const results = await Promise.all([first, second]); + + expect(exec).toHaveBeenCalledTimes(2); + expect(secondProc.kill).toHaveBeenCalledWith('SIGTERM'); + expect(results).toContainEqual(expect.objectContaining({ isError: false })); + expect(results).toContainEqual( + expect.objectContaining({ + isError: true, + output: 'Too many background tasks are already running.', + }), + ); + }); + + it('uses Git Bash semantics and rejects the concurrent command at the task limit', async () => { + const { service } = createFakeBackgroundService({ maxRunningTasks: 1 }); + const firstProc = processWithOutput({ + wait: () => new Promise(() => {}), + }); + const secondProc = processWithOutput(); + const exec = vi.fn().mockResolvedValueOnce(firstProc).mockResolvedValueOnce(secondProc); + const { runner } = createTestRunner(exec); + const tool = bashTool(runner, createTestKaos(windowsBashEnv, 'C:\\Users\\me\\project'), service); + + const first = executeTool( + tool, + context({ + command: 'echo ok 2>nul', + run_in_background: true, + description: 'first task', + }), + ); + const second = executeTool( + tool, + context({ + command: 'echo second', + run_in_background: true, + description: 'second task', + }), + ); + + const results = await Promise.all([first, second]); + + expect(exec).toHaveBeenCalledTimes(2); + const [argv, execOptions] = exec.mock.calls[0]!; + expect(argv).toEqual([ + 'C:\\Program Files\\Git\\bin\\bash.exe', + '-c', + "cd '/c/Users/me/project' && echo ok 2>/dev/null", + ]); + expect(execOptions?.env).toMatchObject({ SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe' }); + expect(secondProc.kill).toHaveBeenCalledWith('SIGTERM'); + expect(results).toContainEqual(expect.objectContaining({ isError: false })); + expect(results).toContainEqual( + expect.objectContaining({ + isError: true, + output: 'Too many background tasks are already running.', + }), + ); + }); + + it('timeout-stops a background task that has not settled even if process exit is visible', async () => { + vi.useFakeTimers(); + try { + const { proc, finishWait, markExited } = processWithVisibleExitBeforeWait(0); + const { runner } = createTestRunner(proc); + const { service } = createFakeBackgroundService(); + const tool = bashTool(runner, createTestKaos(), service); + + const result = await executeTool( + tool, + context({ + command: 'sleep 10', + run_in_background: true, + description: 'exit before close', + timeout: 1, + }), + ); + expect(typeof result.output).toBe('string'); + if (typeof result.output !== 'string') throw new Error('Expected string tool output.'); + const taskId = result.output.match(/task_id: (bash-[0-9a-z]{8})/)?.[1]; + expect(taskId).toBeDefined(); + + markExited(); + await vi.advanceTimersByTimeAsync(1_000); + + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + + finishWait(); + await vi.runAllTimersAsync(); + + expect(service.getTask(taskId!)?.status).toBe('timed_out'); + } finally { + vi.useRealTimers(); + } + }); + + it('timeout-stops a background task after the default 10 minute deadline', async () => { + vi.useFakeTimers(); + try { + const proc = processThatNeverExits(); + const { runner } = createTestRunner(proc); + const { service } = createFakeBackgroundService(); + const tool = bashTool(runner, createTestKaos(), service); + + const result = await executeTool( + tool, + context({ + command: 'sleep 999', + run_in_background: true, + description: 'default deadline', + }), + ); + expect(result).toMatchObject({ isError: false }); + + await vi.advanceTimersByTimeAsync(600_000); + + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + } finally { + vi.useRealTimers(); + } + }); + + it('does not timeout-stop a background task when disable_timeout is true', async () => { + vi.useFakeTimers(); + try { + const proc = processThatNeverExits(); + const { runner } = createTestRunner(proc); + const { service } = createFakeBackgroundService(); + const tool = bashTool(runner, createTestKaos(), service); + + const result = await executeTool( + tool, + context({ + command: 'sleep 999', + run_in_background: true, + description: 'no deadline', + disable_timeout: true, + }), + ); + expect(result).toMatchObject({ isError: false }); + + await vi.advanceTimersByTimeAsync(600_000 + 10_000); + + expect(proc.kill).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('reports background task startup with task_id, status, automatic_notification, and a human-shell hint', async () => { + const proc = processWithOutput(); + const { runner } = createTestRunner(proc); + const { service } = createFakeBackgroundService(); + const tool = bashTool(runner, createTestKaos(), service); + + const result = await executeTool( + tool, + context({ command: 'sleep 1', run_in_background: true, description: 'sleep task' }), + ); + + expect(typeof result.output).toBe('string'); + const output = result.output as string; + expect(output).toContain('task_id:'); + expect(output).toContain('status: running'); + expect(output).toContain('automatic_notification: true'); + expect(output).toContain('human_shell_hint:'); + expect(output).toContain('/tasks'); + }); + + it('rejects background command without description (description-required guard)', async () => { + const { service } = createFakeBackgroundService(); + const { runner, exec } = createTestRunner(processWithOutput()); + const tool = bashTool(runner, createTestKaos(), service); + + const result = await executeTool( + tool, + context({ command: 'sleep 1', run_in_background: true }), + ); + + expect(result).toMatchObject({ isError: true }); + expect(result.output).toContain('description is required'); + expect(exec).not.toHaveBeenCalled(); + }); +}); + +describe('BashTool prompt / runtime consistency', () => { + it('reports unavailable background using only tools the prompt documents', async () => { + const { runner } = createTestRunner(processWithOutput()); + + // The set of background tools the prompt actually introduces — taken from + // the background-enabled prompt, which is the only variant that documents + // any Task* tool. + const enabledTool = bashTool(runner); + const promptToolNames = new Set( + [...enabledTool.description.matchAll(/`(Task[A-Za-z]+)`/g)].map((match) => match[1]), + ); + + const tool = bashTool(runner, createTestKaos(), createFakeBackgroundService().service, { + allowBackground: false, + }); + const result = await executeTool( + tool, + context({ command: 'sleep 10', run_in_background: true, description: 'watch' }), + ); + + expect(result).toMatchObject({ isError: true }); + expect(typeof result.output).toBe('string'); + const errorToolNames = [...(result.output as string).matchAll(/\b(Task[A-Za-z]+)\b/g)].map( + (match) => match[1], + ); + + // The unavailable-background error message must not name a tool that the + // prompt never introduces, otherwise the model is told about a tool it + // has no guidance for. + for (const name of errorToolNames) { + expect(promptToolNames).toContain(name); + } + expect(errorToolNames.length).toBeGreaterThan(0); + }); + + it('does not claim failure exit codes appear in a system tag', () => { + const { runner } = createTestRunner(processWithOutput()); + const tool = bashTool(runner); + + // The implementation reports failures as plain text inside the output + // (`Command failed with exit code: N`), never via a system tag. + expect(tool.description).not.toMatch(/exit code will be provided in a system tag/); + }); +}); diff --git a/packages/agent-core-v2/test/shellTools/shellToolsService.test.ts b/packages/agent-core-v2/test/shellTools/shellToolsService.test.ts new file mode 100644 index 000000000..151c63f62 --- /dev/null +++ b/packages/agent-core-v2/test/shellTools/shellToolsService.test.ts @@ -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(); + 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']); + }); +});