From 58f3a67cf7814602e63a4729a3c32ff404d99f60 Mon Sep 17 00:00:00 2001 From: AlkaidSTART <2595006848@qq.com> Date: Sun, 23 Aug 2026 16:06:14 +0800 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20README?= =?UTF-8?q?=EF=BC=8C=E8=B0=83=E6=95=B4=20Node.js=20=E5=92=8C=20Bun=20?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E4=BF=A1=E6=81=AF=EF=BC=8C=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=9B=86=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 73 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 38de30d..4b1e340 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,18 @@ call-code 产品标识

-![Node.js](https://img.shields.io/badge/Node.js-20%2B-339933?logo=nodedotjs&logoColor=white) +![Node.js](https://img.shields.io/badge/Node.js-22.5%2B-339933?logo=nodedotjs&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white) -![pnpm](https://img.shields.io/badge/pnpm-F69220?logo=pnpm&logoColor=white) +![Bun](https://img.shields.io/badge/Bun-1.3.11-F69220?logo=bun&logoColor=white) ![MIT License](https://img.shields.io/badge/License-MIT-4B32C3) -call-code 是一个本地运行的终端编程 Agent(CLI coding agent),基于 Node.js、TypeScript 和 Ink 构建。它可以在用户当前工作目录中接受自然语言任务,通过工具调用读取文件、写入文件、执行命令、查看环境信息,并结合本地短/长期记忆持续完成任务。 +call-code 是一个本地运行的终端编程 Agent(CLI coding agent),基于 Node.js、TypeScript、Bun 和 Ink 构建。它可以在用户当前工作目录中接受自然语言任务,通过工具调用读取文件、写入文件、执行命令、搜索代码、查看环境信息,并结合本地短/长期记忆持续完成任务。 ## 功能特性 - 终端交互界面:基于 Ink 的命令行界面,支持首页、对话、历史选择和相关页面预览。 - 双执行模式:`PLAN` 模式只允许生成计划和读取环境,`BUILD` 模式可以写入文件、执行命令并推进任务。 -- 本地工具集:内置 `get_environment`、`read_file`、`write_file`、`bash`、`git_diff`、`ocr_image` 六个工具。 +- 本地工具集:内置 `get_environment`、`read_file`、`write_file`、`search`、`bash`、`git_diff`、`ocr_image` 七个工具。 - 结构化响应协议:模型输出统一为 `tool_call` 或 `final` 的 JSON action,循环解析并继续执行。 - 本地记忆:短期记忆按任务保存,长期记忆按主题沉淀,仅在进程内使用,不写入本地 JSON。 - 上下文预算:运行时基于 token 估算对历史消息做裁剪,减少超出模型上下文的风险。 @@ -29,15 +29,18 @@ source/app.tsx CLI 层 首页 / 对话 / 历史 / 相关页面预览 │ 用户输入、命令与活动面板操作 ▼ -agent-core 核心层 -├─ core/ agent 与 runLoop 主循环:规划 -> 执行 -> 观察 +agent-core/src/harness 核心层 +├─ core/ agent、LLM 客户端与任务状态 +├─ runtime/ runLoop 主循环、会话与工具运行时 ├─ context/ 构建上下文、历史摘要与 token 预算 +├─ compaction/ 上下文压缩与分支摘要 ├─ protocol/ 解析 tool_call / final JSON action -├─ policy/ PLAN / BUILD 模式下的工具权限 -├─ tools/ get_environment / read_file / write_file -│ bash / git_diff / ocr_image -├─ memory/ short / long 记忆(仅存内存,不落盘 JSON) -└─ prompt/ 系统提示词、工具说明与模式提示词 +├─ prompt/ 系统提示词、工具说明与模式提示词 +├─ tools/ 七个本地工具,及 PLAN / BUILD 权限 +│ └─ policy/ 模式权限守卫 +├─ session/ 会话恢复、活动查询与任务会话 +├─ memory/ 短期 / 长期记忆(仅存内存,不落盘 JSON) +└─ utils/ shell 与文本截断等通用工具 │ OpenAI chat.completions 请求(支持流式) ▼ OpenAI-compatible LLM 模型层 @@ -58,7 +61,7 @@ OpenAI-compatible LLM 模型层 1. CLI 接收自然语言任务,交给 agent 构建上下文并调用 LLM。 2. 模型返回 `tool_call` 或 `final`,由 protocol 解析为结构化 action。 -3. policy 按 `PLAN` / `BUILD` 模式校验权限,允许后由对应工具执行。 +3. tools/policy 按 `PLAN` / `BUILD` 模式校验权限,允许后由对应工具执行。 4. 工具执行结果作为 observation 回写,memory 记录关键信息,循环继续,直到返回 `final`。 ## 项目结构 @@ -69,13 +72,20 @@ source/ packages/ agent-core/ # 核心 agent、上下文、记忆、工具与协议实现 src/ - core/ # agent、runLoop、state、LLM 调用 - context/ # 上下文构建、历史摘要与 token 管理 - memory/ # 短期/长期记忆存储与检索 - protocol/ # 模型 action/observation 协议解析 - prompt/ # 系统提示词、工具说明、模式提示词 - tools/ # 环境、文件、命令等本地工具 - policy/ # PLAN/BUILD 模式下的工具权限 + harness/ + core/ # agent、LLM、任务状态 + runtime/ # runLoop、会话与工具运行时 + context/ # 上下文构建、摘要与 token 管理 + compaction/ # 上下文压缩与分支摘要 + memory/ # 短期/长期记忆存储与检索 + protocol/ # 模型 action/observation 协议解析 + prompt/ # 系统提示词、工具说明、模式提示词 + session/ # 会话恢复、活动查询与任务会话 + tools/ # 七个本地工具 + tools/policy/ # PLAN/BUILD 模式下的工具权限 + utils/ # shell 与文本截断等工具 + types/ # 领域类型 + utils/ # JSON、日志工具 web/ # GitHub Pages 客户端数据导出 client/ # TypeScript + React 会话历史界面,可部署到 GitHub Pages session-sqlite/ # 基于 node:sqlite 的会话历史与运行状态存储 @@ -94,14 +104,14 @@ tests/ # 项目统一单元测试 ## 快速开始 -1. 安装依赖(建议 Node.js 20+,并使用 pnpm)。 -2. 将 `.env.example` 复制为 `.env.local`(或 `.env`),配置 `OPENAI_API_KEY` 与 `OPENAI_MODEL`。 +1. 安装依赖(需要 Node.js 22.5+,包管理器为 Bun,版本固定为 1.3.11)。 +2. 将 `.env.example` 复制为 `.env.local`,配置 `OPENAI_API_KEY` 与 `OPENAI_MODEL`;`.env` 与 `.env.local` 都会被加载。 3. 启动 CLI,入口为 `source/app.tsx`。 ```bash cp .env.example .env.local -pnpm install -pnpm dev +bun install +bun dev ``` 进入 CLI 后可以直接输入自然语言任务。CLI 默认按当前模式执行:`PLAN` 模式先生成计划,`BUILD` 模式直接参与文件读写和命令执行。计划生成后可用 Enter 确认执行,也可以继续补充修改意见。 @@ -113,6 +123,7 @@ pnpm dev | `OPENAI_API_KEY` | 必填,OpenAI 兼容 API 的 Key。 | | `OPENAI_API_BASE_URL` | 可选,自定义 OpenAI 兼容 base URL。 | | `OPENAI_MODEL` | 必填,模型名称,无默认值;未配置时 CLI 会提示。 | +| `OPENAI_CONTEXT_WINDOW` | 可选,上下文窗口 token 数,默认 8000。 | | `AGENT_DESKTOP_DIR` | 可选,覆盖桌面目录路径,便于测试或自定义工作环境。 | | `SESSION_DB_PATH` | 可选,SQLite 会话库文件路径,默认 `.agent-sessions/sessions.db`。 | | `CALL_CODE_WEB_DATA` | 可选,CLI 内 `/export` 的输出路径,默认 `packages/client/public/data.json`。 | @@ -142,28 +153,28 @@ pnpm dev ```bash # 启动 CLI -pnpm dev +bun dev # 类型检查 -pnpm typecheck +bun run typecheck # 类型检查(session-sqlite) -pnpm exec tsc -p packages/session-sqlite/tsconfig.json --noEmit +bun x tsc -p packages/session-sqlite/tsconfig.json --noEmit # 类型检查客户端 -pnpm typecheck:client +bun run typecheck:client # 运行全部测试(推荐) -pnpm test +bun run test # 构建 agent-core -pnpm run build:agent-core +bun run build:agent-core # 导出会话历史到 packages/client/public/data.json -pnpm export:web +bun run export:web # 构建 GitHub Pages 客户端 -pnpm build:client +bun run build:client ``` ## 测试说明 From 71cb7f4c1ffdbf1a62358d401b449d422beaa9f5 Mon Sep 17 00:00:00 2001 From: AlkaidSTART <2595006848@qq.com> Date: Sun, 23 Aug 2026 16:11:47 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=B7=A5=E5=85=B7=E7=9A=84=E5=9B=9E=E9=80=80=E6=9C=BA?= =?UTF-8?q?=E5=88=B6=EF=BC=8C=E6=94=AF=E6=8C=81=E5=9C=A8=E6=9C=AA=E5=AE=89?= =?UTF-8?q?=E8=A3=85=20ripgrep=20=E6=97=B6=E4=BD=BF=E7=94=A8=20Node=20?= =?UTF-8?q?=E5=86=85=E7=BD=AE=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent-core/src/harness/prompt/tool.ts | 2 +- .../agent-core/src/harness/tools/search.ts | 315 ++++++++++++++---- tests/search-fallback.spec.ts | 91 +++++ 3 files changed, 341 insertions(+), 67 deletions(-) create mode 100644 tests/search-fallback.spec.ts diff --git a/packages/agent-core/src/harness/prompt/tool.ts b/packages/agent-core/src/harness/prompt/tool.ts index cfc0918..5fb90d3 100644 --- a/packages/agent-core/src/harness/prompt/tool.ts +++ b/packages/agent-core/src/harness/prompt/tool.ts @@ -14,7 +14,7 @@ export const toolPrompt = ` - path 支持:绝对路径、相对路径、~/...、Desktop/...、桌面/...、desktop:/... 4. search(query: string, path?: string, glob?: string, caseSensitive?: boolean, fixedStrings?: boolean, maxResults?: number) - - 使用 ripgrep 搜索文件内容,返回包含文件名、行列号和匹配文本的结果 + - 使用 ripgrep 搜索文件内容(未安装时自动回退到 Node 内置搜索),返回包含文件名、行列号和匹配文本的结果 - path 可选,支持 ~/...、Desktop/...、桌面/...、desktop:/... - 默认智能区分大小写;fixedStrings 为 true 时按纯文本搜索 diff --git a/packages/agent-core/src/harness/tools/search.ts b/packages/agent-core/src/harness/tools/search.ts index 0470c62..9a5bcc3 100644 --- a/packages/agent-core/src/harness/tools/search.ts +++ b/packages/agent-core/src/harness/tools/search.ts @@ -1,14 +1,28 @@ import { execFile } from 'node:child_process'; +import type { Dirent } from 'node:fs'; +import { readdir, readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; import { promisify } from 'node:util'; import { resolveUserPath } from './pathUtils'; const execFileAsync = promisify(execFile); const DEFAULT_MAX_RESULTS = 100; const MAX_RESULTS = 1000; +const MAX_BUFFER_BYTES = 10 * 1024 * 1024; +const IGNORED_DIRECTORIES = new Set(['node_modules']); + +interface NormalizedSearch { + query: string; + glob?: string; + caseSensitive?: boolean; + fixedStrings?: boolean; + maxResults: number; + resolvedPath: string; +} export const searchTool = { name: 'search', - description: 'Search file contents with ripgrep', + description: 'Search file contents with ripgrep or a built-in Node fallback', parameters: { type: 'object', properties: { @@ -41,80 +55,249 @@ export const searchTool = { required: ['query'], }, run: async (input: unknown) => { - const value = input as { - query?: unknown; - path?: unknown; - glob?: unknown; - caseSensitive?: unknown; - fixedStrings?: unknown; - maxResults?: unknown; - }; - const query = validateRequiredString(value.query, 'query'); - const searchPath = validateOptionalString(value.path, 'path') ?? '.'; - const glob = validateOptionalString(value.glob, 'glob'); - const caseSensitive = validateOptionalBoolean( - value.caseSensitive, - 'caseSensitive', + const normalized = normalizeSearchInput(input); + try { + return await runRipgrepSearch(normalized); + } catch (error) { + if (isCommandMissing(error)) { + return runNodeSearch(normalized); + } + throw error; + } + }, +}; + +const normalizeSearchInput = (input: unknown): NormalizedSearch => { + const value = input as { + query?: unknown; + path?: unknown; + glob?: unknown; + caseSensitive?: unknown; + fixedStrings?: unknown; + maxResults?: unknown; + }; + const query = validateRequiredString(value.query, 'query'); + const searchPath = validateOptionalString(value.path, 'path') ?? '.'; + const glob = validateOptionalString(value.glob, 'glob'); + const caseSensitive = validateOptionalBoolean( + value.caseSensitive, + 'caseSensitive', + ); + const fixedStrings = validateOptionalBoolean( + value.fixedStrings, + 'fixedStrings', + ); + const maxResults = validateMaxResults(value.maxResults); + + return { + query, + glob, + caseSensitive, + fixedStrings, + maxResults, + resolvedPath: resolveUserPath(searchPath), + }; +}; + +const runRipgrepSearch = async (options: NormalizedSearch) => { + const args = ['--line-number', '--column', '--no-heading', '--color', 'never']; + + if (options.caseSensitive === false) { + args.push('--ignore-case'); + } else if (options.caseSensitive === true) { + args.push('--case-sensitive'); + } else { + args.push('--smart-case'); + } + if (options.fixedStrings) { + args.push('--fixed-strings'); + } + if (options.glob) { + args.push('--glob', options.glob); + } + args.push( + '--max-count', + String(options.maxResults), + '--', + options.query, + options.resolvedPath, + ); + + try { + const { stdout, stderr } = await execFileAsync('rg', args, { + maxBuffer: MAX_BUFFER_BYTES, + }); + const matches = stdout ? stdout.trimEnd().split('\n') : []; + return buildSearchResult(options, matches, stderr); + } catch (error) { + if (isNoMatchError(error)) { + return buildSearchResult(options, [], error.stderr ?? ''); + } + throw error; + } +}; + +const runNodeSearch = async (options: NormalizedSearch) => { + const matches: string[] = []; + const rootStat = await stat(options.resolvedPath); + + if (rootStat.isDirectory()) { + await collectDirectoryMatches( + options.resolvedPath, + options.resolvedPath, + options, + matches, ); - const fixedStrings = validateOptionalBoolean( - value.fixedStrings, - 'fixedStrings', + } else if (rootStat.isFile()) { + await collectFileMatches( + options.resolvedPath, + options.resolvedPath, + options, + matches, ); - const maxResults = validateMaxResults(value.maxResults); - const resolvedPath = resolveUserPath(searchPath); - const args = ['--line-number', '--column', '--no-heading', '--color', 'never']; - - if (caseSensitive === false) { - args.push('--ignore-case'); - } else if (caseSensitive === true) { - args.push('--case-sensitive'); - } else { - args.push('--smart-case'); + } + + return buildSearchResult(options, matches); +}; + +const collectDirectoryMatches = async ( + root: string, + directory: string, + options: NormalizedSearch, + matches: string[], +): Promise => { + let entries: Dirent[]; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (entry.name.startsWith('.')) { + continue; + } + + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (!IGNORED_DIRECTORIES.has(entry.name)) { + await collectDirectoryMatches(root, fullPath, options, matches); + } + continue; } - if (fixedStrings) { - args.push('--fixed-strings'); + + if ( + entry.isFile() && + matchesGlob(path.relative(root, fullPath), options.glob) + ) { + await collectFileMatches(fullPath, root, options, matches); } - if (glob) { - args.push('--glob', glob); + } +}; + +const collectFileMatches = async ( + fullPath: string, + root: string, + options: NormalizedSearch, + matches: string[], +): Promise => { + let content: string; + try { + content = await readFile(fullPath, 'utf8'); + } catch { + return; + } + + const regex = createQueryRegex(options); + const displayPath = + fullPath === root ? fullPath : toPosixPath(path.relative(root, fullPath)); + const lines = content.split('\n'); + let matchedLines = 0; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const match = regex.exec(line); + if (!match) { + continue; } - args.push('--max-count', String(maxResults), '--', query, resolvedPath); - try { - const { stdout, stderr } = await execFileAsync('rg', args, { - maxBuffer: 10 * 1024 * 1024, - }); - const matches = stdout ? stdout.trimEnd().split('\n') : []; - - return { - query, - path: resolvedPath, - glob, - caseSensitive: caseSensitive ?? 'smart', - fixedStrings: fixedStrings ?? false, - maxResults, - matchCount: matches.length, - matches, - stderr, - }; - } catch (error) { - if (isNoMatchError(error)) { - return { - query, - path: resolvedPath, - glob, - caseSensitive: caseSensitive ?? 'smart', - fixedStrings: fixedStrings ?? false, - maxResults, - matchCount: 0, - matches: [], - stderr: error.stderr ?? '', - }; - } - throw error; + const column = (match.index ?? 0) + 1; + matches.push(`${displayPath}:${index + 1}:${column}:${line}`); + matchedLines += 1; + if (matchedLines >= options.maxResults) { + return; } - }, + } +}; + +const createQueryRegex = (options: NormalizedSearch): RegExp => { + const source = options.fixedStrings + ? escapeRegExp(options.query) + : options.query; + const useIgnoreCase = + options.caseSensitive === false || + (options.caseSensitive === undefined && !/[A-Z]/.test(options.query)); + return new RegExp(source, useIgnoreCase ? 'i' : ''); }; +const matchesGlob = ( + relativePath: string, + glob: string | undefined, +): boolean => { + if (!glob) { + return true; + } + + const normalizedPath = toPosixPath(relativePath); + const pattern = toPosixPath(glob); + const basename = normalizedPath.split('/').pop() ?? normalizedPath; + + if (pattern.startsWith('!')) { + const excluded = globToRegExp(pattern.slice(1)); + return !excluded.test(normalizedPath) && !excluded.test(basename); + } + if (!pattern.includes('/')) { + return globToRegExp(pattern).test(basename); + } + if (pattern.startsWith('**/')) { + return ( + globToRegExp(pattern).test(normalizedPath) || + globToRegExp(pattern.slice(3)).test(normalizedPath) + ); + } + return globToRegExp(pattern).test(normalizedPath); +}; + +const globToRegExp = (pattern: string): RegExp => { + const source = escapeRegExp(pattern) + .replace(/\\\*\\\*/g, '\0') + .replace(/\\\*/g, '[^/]*') + .replace(/\\\?/g, '[^/]') + .replace(/\0/g, '.*'); + return new RegExp(`^${source}$`); +}; + +const escapeRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const toPosixPath = (value: string): string => value.split(path.sep).join('/'); + +const buildSearchResult = ( + options: NormalizedSearch, + matches: string[], + stderr = '', +) => ({ + query: options.query, + path: options.resolvedPath, + glob: options.glob, + caseSensitive: options.caseSensitive ?? 'smart', + fixedStrings: options.fixedStrings ?? false, + maxResults: options.maxResults, + matchCount: matches.length, + matches, + stderr, +}); + const validateRequiredString = (value: unknown, name: string): string => { if (typeof value !== 'string' || !value.trim()) { throw new Error(`Invalid ${name}`); diff --git a/tests/search-fallback.spec.ts b/tests/search-fallback.spec.ts new file mode 100644 index 0000000..d53117e --- /dev/null +++ b/tests/search-fallback.spec.ts @@ -0,0 +1,91 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockExecFile } = vi.hoisted(() => ({ mockExecFile: vi.fn() })); + +vi.mock('node:child_process', () => ({ execFile: mockExecFile })); + +import { searchTool } from '@tools/search'; + +type ExecCallback = ( + error?: Error | null, + stdout?: string, + stderr?: string, +) => void; + +const simulateMissingRipgrep = () => { + mockExecFile.mockImplementation( + (_command: string, _args: string[], _options: object, callback: ExecCallback) => { + callback( + Object.assign(new Error('spawn rg ENOENT'), { + code: 'ENOENT', + syscall: 'spawn rg', + path: 'rg', + }), + ); + }, + ); +}; + +let tempDir: string | undefined; + +afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } + mockExecFile.mockReset(); +}); + +describe('search tool ripgrep fallback', () => { + it('uses a built-in search when rg is missing', async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'agent-search-fallback-')); + await writeFile( + path.join(tempDir, 'one.ts'), + 'Alpha needle\nneedle again\n', + ); + await writeFile(path.join(tempDir, 'two.txt'), 'needle ignored\n'); + simulateMissingRipgrep(); + + const result = await searchTool.run({ + query: 'needle', + path: tempDir, + glob: '*.ts', + caseSensitive: false, + fixedStrings: true, + maxResults: 1, + }); + + expect(mockExecFile).toHaveBeenCalledWith( + 'rg', + expect.any(Array), + expect.any(Object), + expect.any(Function), + ); + expect(result).toMatchObject({ + path: tempDir, + glob: '*.ts', + caseSensitive: false, + fixedStrings: true, + maxResults: 1, + matchCount: 1, + }); + expect(result.matches[0]).toContain('one.ts:1:7:Alpha needle'); + }); + + it('returns an empty result when the fallback finds nothing', async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'agent-search-fallback-')); + await writeFile(path.join(tempDir, 'sample.txt'), 'content\n'); + simulateMissingRipgrep(); + + const result = await searchTool.run({ + query: 'missing', + path: tempDir, + }); + + expect(result.matchCount).toBe(0); + expect(result.matches).toEqual([]); + }); +}); From d43b3cd75d0ddf3d8538cae4e45d997fa5e5d2e6 Mon Sep 17 00:00:00 2001 From: AlkaidSTART <2595006848@qq.com> Date: Sun, 23 Aug 2026 16:17:14 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E7=BC=BA=E5=A4=B1=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=87=BD=E6=95=B0=EF=BC=8C=E4=BB=A5=E5=A2=9E=E5=BC=BA=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent-core/src/harness/tools/search.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/agent-core/src/harness/tools/search.ts b/packages/agent-core/src/harness/tools/search.ts index 9a5bcc3..535d030 100644 --- a/packages/agent-core/src/harness/tools/search.ts +++ b/packages/agent-core/src/harness/tools/search.ts @@ -352,3 +352,8 @@ const isNoMatchError = ( error instanceof Error && 'code' in error && (error as { code?: unknown }).code === 1; + +const isCommandMissing = ( + error: unknown, +): error is Error & { code?: string } => + error instanceof Error && (error as { code?: unknown }).code === 'ENOENT';