import { readFile, readdir, stat } from 'node:fs/promises'; import { resolve, basename, relative } from 'node:path'; import { z } from 'zod'; import { pathGuard, PathScopeError } from './path_guard.js'; import { isSecretPath, SecretBlockedError, filterSecretEntries } from './secret_guard.js'; import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js'; import { getGitMeta } from './git_meta.js'; import { findSkills, getSkillBody, getSkillResource } from './skills.js'; import { webSearch } from './web_search.js'; import { webFetch } from './web_fetch.js'; import { readTruncation, truncateIfNeeded } from './truncate.js'; // v1.12 Track B.2: codecontext tools. 8 wrappers re-exported from // tools/codecontext/index.ts. Each calls into services/codecontext_client.ts // which talks to the codecontext sidecar at http://codecontext:8080. import { getCodebaseOverview, getFileAnalysis, getSymbolInfo, searchSymbols, getDependencies, watchChanges, getSemanticNeighborhoods, getFrameworkAnalysis, } from './tools/codecontext/index.js'; const MAX_FILE_BYTES = 5 * 1024 * 1024; const DEFAULT_VIEW_LINES = 200; const MAX_GREP_RESULTS = 200; const DEFAULT_GREP_RESULTS = 100; const MAX_FIND_RESULTS = 200; const DEFAULT_FIND_RESULTS = 100; const MAX_DIR_ENTRIES = 500; export interface ToolJsonSchema { type: 'function'; function: { name: string; description: string; parameters: Record; }; } export interface ToolDef { name: string; description: string; inputSchema: z.ZodType; jsonSchema: ToolJsonSchema; execute(input: TInput, projectRoot: string): Promise; } const ViewFileInput = z.object({ path: z.string().min(1), start_line: z.number().int().positive().optional(), end_line: z.number().int().positive().optional(), }); type ViewFileInputT = z.infer; export const viewFile: ToolDef = { name: 'view_file', description: "Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused. Output is truncated if longer than the slice; the response indicates truncation.", inputSchema: ViewFileInput, jsonSchema: { type: 'function', function: { name: 'view_file', description: "Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused.", parameters: { type: 'object', properties: { path: { type: 'string', description: 'absolute or project-relative path' }, start_line: { type: 'integer', description: 'first line (1-indexed)' }, end_line: { type: 'integer', description: 'last line (1-indexed, inclusive)' }, }, required: ['path'], additionalProperties: false, }, }, }, async execute(input, projectRoot) { const real = await pathGuard(projectRoot, input.path); // v1.11.7: secret-file deny check. Test the project-relative path // (matches the form continue.dev's patterns expect: basenames + dir // segments). Throw a typed error so executeToolCall in inference.ts // surfaces a clear "blocked" message to the LLM instead of silently // returning content the user wanted hidden. const relPath = relative(projectRoot, real) || basename(real); if (isSecretPath(relPath)) { throw new SecretBlockedError(relPath); } const s = await stat(real); if (!s.isFile()) { throw new PathScopeError(`not a file: ${input.path}`); } if (s.size > MAX_FILE_BYTES) { throw new Error(`file too large (${s.size} bytes, max ${MAX_FILE_BYTES})`); } const raw = await readFile(real, 'utf8'); const lines = raw.split('\n'); const total = lines.length; let start = input.start_line ?? 1; let end = input.end_line ?? Math.min(total, start + DEFAULT_VIEW_LINES - 1); if (input.start_line == null && input.end_line == null) { end = Math.min(total, DEFAULT_VIEW_LINES); } if (start < 1) start = 1; if (end > total) end = total; if (end < start) end = start; const slice = lines.slice(start - 1, end); const content = slice.join('\n'); const truncated = total > end || start > 1; // v1.13.5: stash the full file on tmpfs so the model can retrieve more // via view_truncated_output(id) without re-reading the file (which it // may not have project-relative-path access to in future agent setups). // raw is bounded by MAX_FILE_BYTES (5MB), within truncateIfNeeded's cap. const wrapped = await truncateIfNeeded({ fullContent: raw, slicedContent: content, wasTruncated: truncated, }); return { path: relative(projectRoot, real) || basename(real), content: wrapped.content, total_lines: total, returned_lines: [start, end], truncated: wrapped.truncated, ...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}), }; }, }; const ListDirInput = z.object({ path: z.string().min(1), show_hidden: z.boolean().optional(), }); type ListDirInputT = z.infer; export const listDir: ToolDef = { name: 'list_dir', description: 'List entries in a directory (up to 500). Hidden files excluded unless show_hidden=true.', inputSchema: ListDirInput, jsonSchema: { type: 'function', function: { name: 'list_dir', description: 'List entries in a directory (up to 500). Hidden files (dot-prefixed) excluded unless show_hidden=true.', parameters: { type: 'object', properties: { path: { type: 'string' }, show_hidden: { type: 'boolean' }, }, required: ['path'], additionalProperties: false, }, }, }, async execute(input, projectRoot) { const real = await pathGuard(projectRoot, input.path); const s = await stat(real); if (!s.isDirectory()) { throw new PathScopeError(`not a directory: ${input.path}`); } const entries = await readdir(real, { withFileTypes: true }); const filtered = input.show_hidden ? entries : entries.filter((e) => !e.name.startsWith('.')); const total = filtered.length; const wasTruncated = total > MAX_DIR_ENTRIES; const relDir = relative(projectRoot, real) || '.'; // v1.13.5: when we'd truncate, render the FULL list to tmpfs so // view_truncated_output can serve it. Stat sizes for all entries when // truncating so the stored view matches the visible shape; this is the // one extra cost for big directories, bounded by total entries (which // is itself bounded by filesystem behavior). const processOne = async (e: typeof filtered[number]) => { const child = resolve(real, e.name); let size: number | undefined; if (e.isFile()) { try { const cs = await stat(child); size = cs.size; } catch { /* ignore */ } } return { name: e.name, type: e.isDirectory() ? ('dir' as const) : ('file' as const), ...(size != null ? { size } : {}), }; }; const slice = filtered.slice(0, MAX_DIR_ENTRIES); const out = await Promise.all(slice.map(processOne)); // v1.11.7: filter entries whose project-relative path matches a secret // pattern. The same filter applies to the full-list snapshot below so // the stashed file never holds entries the slice would have hidden. const secretFilter = filterSecretEntries(out, (e) => relDir === '.' ? e.name : `${relDir}/${e.name}`, ); let outputPath: string | undefined; if (wasTruncated) { const fullProcessed = await Promise.all(filtered.map(processOne)); const fullFiltered = filterSecretEntries(fullProcessed, (e) => relDir === '.' ? e.name : `${relDir}/${e.name}`, ); // One line per entry, view_truncated_output's line slicing semantics // map cleanly. Format: "\t[\tsize=N]". Header documents // the shape so the model can grep / regex without prior schema lookup. const header = `# list_dir ${relDir} — ${fullFiltered.kept.length} entries`; const lines = [header, ...fullFiltered.kept.map((e) => { const sz = 'size' in e && e.size != null ? `\tsize=${e.size}` : ''; return `${e.type}\t${e.name}${sz}`; })]; const wrapped = await truncateIfNeeded({ fullContent: lines.join('\n'), slicedContent: '', wasTruncated: true, }); outputPath = wrapped.outputPath; } return { path: relDir, entries: secretFilter.kept, total: secretFilter.kept.length, truncated: wasTruncated, ...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}), ...(outputPath ? { outputPath } : {}), }; }, }; const GrepInput = z.object({ pattern: z.string().min(1), path: z.string().optional(), case_sensitive: z.boolean().optional(), max_results: z.number().int().positive().optional(), hidden: z.boolean().optional(), }); type GrepInputT = z.infer; export const grep: ToolDef = { name: 'grep', description: 'Search file contents with ripgrep. Default path is project root. Max 100 results (200 cap).', inputSchema: GrepInput, jsonSchema: { type: 'function', function: { name: 'grep', description: 'Search file contents with ripgrep. Returns up to 100 matches (cap 200). Set hidden=true to include dot-prefixed files.', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' }, case_sensitive: { type: 'boolean' }, max_results: { type: 'integer' }, hidden: { type: 'boolean' }, }, required: ['pattern'], additionalProperties: false, }, }, }, async execute(input, projectRoot) { const limit = Math.min( Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1), MAX_GREP_RESULTS ); // Delegate to file_ops.grep; reshape match objects to preserve LLM output format // (file_ops uses {path, line, text}; tool output uses {path, line, content}) const result = await fileOpsGrep(projectRoot, input.pattern, { path: input.path, max_matches: limit, case_sensitive: input.case_sensitive, hidden: input.hidden, }); const reshaped = result.matches.map((m) => ({ path: m.path, line: m.line, content: m.text, })); // v1.11.7: drop matches whose source file is a known-secret pattern. // file_ops.grep returns project-relative paths, so we feed them straight // into isSecretPath. Multiple matches in the same secret file each get // dropped individually — they all count in the hidden tally. const secretFilter = filterSecretEntries(reshaped, (m) => m.path); return { matches: secretFilter.kept, total: secretFilter.kept.length, truncated: result.truncated, ...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}), }; }, }; const FindFilesInput = z.object({ pattern: z.string().min(1), path: z.string().optional(), max_results: z.number().int().positive().optional(), }); type FindFilesInputT = z.infer; export const findFiles: ToolDef = { name: 'find_files', description: 'Glob for filenames. Default path is project root. Max 100 results (200 cap).', inputSchema: FindFilesInput, jsonSchema: { type: 'function', function: { name: 'find_files', description: 'Glob for filenames under a directory. Default path is project root. Max 100 results (cap 200). Pattern uses standard glob (e.g. "**/*.ts").', parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' }, max_results: { type: 'integer' }, }, required: ['pattern'], additionalProperties: false, }, }, }, async execute(input, projectRoot) { const limit = Math.min( Math.max(input.max_results ?? DEFAULT_FIND_RESULTS, 1), MAX_FIND_RESULTS ); // Delegate to file_ops.findFiles; reshape { files, total, truncated } to // preserve the LLM-visible output format { paths, total, truncated } const result = await fileOpsFindFiles(projectRoot, input.pattern, { path: input.path, max_results: limit, }); // v1.11.7: drop paths matching secret patterns. The original `total` // from file_ops includes pre-truncation count; we report the visible // count post-filter so the LLM can't infer hidden-count by subtraction. const secretFilter = filterSecretEntries(result.files, (p) => p); return { paths: secretFilter.kept, total: secretFilter.kept.length, truncated: result.truncated, ...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}), }; }, }; // v1.13.5: retrieves the full content of a previously-truncated tool output // via the opaque id stamped on the original tool_result. Line-based slicing // matches view_file's mental model so the model uses the same affordances. // Tmpfs-backed, 7-day TTL (see services/truncate.ts). const VIEW_TRUNCATED_DEFAULT_LINES = 200; const ViewTruncatedOutputInput = z.object({ id: z.string().regex(/^tr_[0-9a-v]{12}$/), start_line: z.number().int().positive().optional(), end_line: z.number().int().positive().optional(), }); type ViewTruncatedOutputInputT = z.infer; export const viewTruncatedOutput: ToolDef = { name: 'view_truncated_output', description: `Retrieve the full content of a previously-truncated tool output by its outputPath id. When a tool returns { truncated: true, outputPath: "tr_..." }, call this to view the full content. Defaults to the first ${VIEW_TRUNCATED_DEFAULT_LINES} lines. Use start_line and end_line (1-indexed, inclusive) to slice. Stored for 7 days.`, inputSchema: ViewTruncatedOutputInput, jsonSchema: { type: 'function', function: { name: 'view_truncated_output', description: `Retrieve the full content of a previously-truncated tool output by its outputPath id. Returns the first ${VIEW_TRUNCATED_DEFAULT_LINES} lines by default; use start_line/end_line to slice. Stored for 7 days.`, parameters: { type: 'object', properties: { id: { type: 'string', description: 'The outputPath value from an earlier truncated tool result (e.g. "tr_abc123def456").' }, start_line: { type: 'integer', description: 'First line (1-indexed). Default 1.' }, end_line: { type: 'integer', description: `Last line (1-indexed, inclusive). Default ${VIEW_TRUNCATED_DEFAULT_LINES} lines past start.` }, }, required: ['id'], additionalProperties: false, }, }, }, async execute(input, _projectRoot) { const content = await readTruncation(input.id); if (content === null) { return { id: input.id, content: '', truncated: false, error: `No truncation found for id "${input.id}". It may have been pruned (7-day TTL) or never existed.`, }; } const lines = content.split('\n'); const total = lines.length; let start = input.start_line ?? 1; let end = input.end_line ?? Math.min(total, start + VIEW_TRUNCATED_DEFAULT_LINES - 1); if (start < 1) start = 1; if (end > total) end = total; if (end < start) end = start; const slice = lines.slice(start - 1, end).join('\n'); // Re-slicing this view isn't truncation in the dual-write sense — the // model already has the id; no point stashing the slice again. const truncated = total > end || start > 1; return { id: input.id, content: slice, total_lines: total, returned_lines: [start, end], truncated, }; }, }; // v1.8 Level 1 branch awareness: gives the model a read-only view of the // project's git state. No path input — operates on the inference-resolved // project root via getGitMeta. Subprocess runs with a 2s timeout (see git_meta). const GitStatusInput = z.object({}).strict(); type GitStatusInputT = z.infer; export const gitStatus: ToolDef = { name: 'git_status', description: "Returns the current git branch, whether the working tree is dirty, and ahead/behind counts vs upstream. Read-only. Use when you need to know which branch the user is currently working on.", inputSchema: GitStatusInput, jsonSchema: { type: 'function', function: { name: 'git_status', description: 'Returns the current git branch, dirty flag, and ahead/behind counts vs upstream. Read-only.', parameters: { type: 'object', properties: {}, additionalProperties: false, }, }, }, async execute(_input, projectRoot) { const meta = await getGitMeta(projectRoot); if (meta === null) { return { repo: false, branch: null, is_dirty: false, ahead: 0, behind: 0 }; } return { repo: true, ...meta }; }, }; // Batch 9.6: skill_find, skill_use, skill_resource. Lazy-loaded markdown // playbooks at /data/skills/. Three tools rather than one to keep each call // cheap — the model lists, then loads, then optionally pulls support files. const SkillFindInput = z.object({ query: z.string().optional(), }); type SkillFindInputT = z.infer; export const skillFind: ToolDef = { name: 'skill_find', description: 'Find skills (markdown playbooks under /data/skills) by name or description. Returns up to 5 matches. Empty query or "*" returns all available skills. Call this first to discover what skills are available.', inputSchema: SkillFindInput, jsonSchema: { type: 'function', function: { name: 'skill_find', description: 'Find skills by name or description. Returns up to 5 matches. Empty or "*" returns all.', parameters: { type: 'object', properties: { query: { type: 'string', description: 'substring matched against skill name and description' }, }, additionalProperties: false, }, }, }, async execute(input) { return await findSkills(input.query ?? ''); }, }; const SkillUseInput = z.object({ name: z.string().min(1), }); type SkillUseInputT = z.infer; export const skillUse: ToolDef = { name: 'skill_use', description: "Load the full body of a skill's SKILL.md by name. Returns the markdown playbook to follow. Discover names via skill_find. Errors: unknown_skill.", inputSchema: SkillUseInput, jsonSchema: { type: 'function', function: { name: 'skill_use', description: "Load the full body of a skill's SKILL.md by name.", parameters: { type: 'object', properties: { name: { type: 'string', description: 'skill name from skill_find' }, }, required: ['name'], additionalProperties: false, }, }, }, async execute(input) { const body = await getSkillBody(input.name); if (body === null) { return { error: 'unknown_skill', message: `unknown skill: ${input.name}` }; } return { body }; }, }; const SkillResourceInput = z.object({ name: z.string().min(1), path: z.string().min(1), }); type SkillResourceInputT = z.infer; export const skillResource: ToolDef = { name: 'skill_resource', description: "Read a support file inside a skill's folder (e.g. references/root-cause-tracing.md). Path is relative to the skill folder. Use skill_use to read SKILL.md itself. Errors: unknown_skill, unknown_resource, path_escape.", inputSchema: SkillResourceInput, jsonSchema: { type: 'function', function: { name: 'skill_resource', description: "Read a support file inside a skill's folder. Path is relative to the skill folder.", parameters: { type: 'object', properties: { name: { type: 'string', description: 'skill name' }, path: { type: 'string', description: 'relative path under the skill folder' }, }, required: ['name', 'path'], additionalProperties: false, }, }, }, async execute(input) { const result = await getSkillResource(input.name, input.path); if (!result.ok) { return { error: result.code, message: result.message }; } return { content: result.content }; }, }; // Batch 9.7: ask_user_input. Interactive elicitation. The model emits a tool // call with 1-3 structured questions; the inference loop PAUSES (does not // execute the tool server-side, does not recurse) and waits for the frontend // to POST /api/chats/:id/answer_user_input with the user's selections. See // routes/messages.ts for the resume path and services/inference.ts for the // pause branch in executeToolPhase. const AskUserInputInput = z.object({ questions: z .array( z.object({ question: z.string().min(1).max(200), type: z.enum(['single_select', 'multi_select']), options: z.array(z.string().min(1).max(80)).min(2).max(6), }), ) .min(1) .max(3), }); type AskUserInputInputT = z.infer; export const askUserInput: ToolDef = { name: 'ask_user_input', description: "Ask the user 1-3 structured questions through an inline picker UI. Use when you genuinely need a choice the user must make (e.g. scope, options, preferences) before continuing. Each question has 2-6 options and accepts free-text answers in addition. The tool call pauses the conversation until the user submits — the next assistant turn sees their answers as the tool result. Do not use for trivial yes/no clarifications you could infer; prefer it over multi-paragraph speculation about what the user might want.", inputSchema: AskUserInputInput, jsonSchema: { type: 'function', function: { name: 'ask_user_input', description: 'Ask the user 1-3 structured questions through an inline picker. Pauses the conversation until the user answers; the next turn sees their selections.', parameters: { type: 'object', properties: { questions: { type: 'array', minItems: 1, maxItems: 3, items: { type: 'object', properties: { question: { type: 'string', description: '<=200 chars, shown to the user' }, type: { type: 'string', enum: ['single_select', 'multi_select'], description: 'single_select = at most one option; multi_select = any subset', }, options: { type: 'array', minItems: 2, maxItems: 6, items: { type: 'string' }, description: '2-6 strings, each <=80 chars; free-text input is always available alongside', }, }, required: ['question', 'type', 'options'], additionalProperties: false, }, }, }, required: ['questions'], additionalProperties: false, }, }, }, // Server-side no-op. The "execution" of ask_user_input is the user's // response, captured client-side and posted to /api/chats/:id/answer_user_input. // The inference loop detects this tool by name and pauses before reaching // executeToolCall — this fallback only runs if something bypasses that // branch, in which case the pending sentinel matches the pause-path shape. async execute(input) { return { _pending: true, questions: input.questions }; }, }; // v1.13.3: alpha-sorted by tool.name at module load. llama.cpp's prompt // cache hits on byte-identical prefixes; the tool list lives near the top // of the system prompt, so any order drift would invalidate every cached // turn. Single source of truth for ordering lives here — toolJsonSchemas() // and TOOLS_BY_NAME inherit it. export const ALL_TOOLS: ReadonlyArray> = [ viewFile as ToolDef, viewTruncatedOutput as ToolDef, listDir as ToolDef, grep as ToolDef, findFiles as ToolDef, gitStatus as ToolDef, skillFind as ToolDef, skillUse as ToolDef, skillResource as ToolDef, askUserInput as ToolDef, // v1.11.8: web tools. Gated per-chat via session.web_search_enabled // (with project default fallback) — see effectiveTools filter in // services/inference.ts. webSearch as ToolDef, webFetch as ToolDef, // v1.12 Track B.2: codecontext tools. Backed by the codecontext sidecar // container. All read-only. target_dir is resolved server-side from the // project root in codecontext_client.ts (the LLM never supplies it). getCodebaseOverview as ToolDef, getFileAnalysis as ToolDef, getSymbolInfo as ToolDef, searchSymbols as ToolDef, getDependencies as ToolDef, watchChanges as ToolDef, getSemanticNeighborhoods as ToolDef, getFrameworkAnalysis as ToolDef, ].sort((a, b) => a.name.localeCompare(b.name)); // v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is // fully contained in this set gets a generous default tool budget (30); // anything outside means the agent can mutate state and gets a tighter // default (10). Every tool in v1.8.2 happens to be read-only, so the // non-RO branch only takes effect once BooCoder lands write tools. // Batch 9.6: skill_* added; all still read-only. // Batch 9.7: ask_user_input added — it pauses execution but doesn't mutate // project state, so it belongs in the read-only set for budget purposes. export const READ_ONLY_TOOL_NAMES = [ 'view_file', 'view_truncated_output', 'list_dir', 'grep', 'find_files', 'git_status', 'skill_find', 'skill_use', 'skill_resource', 'ask_user_input', // v1.11.8: web tools don't mutate project state; counted as read-only // for the budget-tier calculation (BUDGET_READ_ONLY=30) when an agent's // toolset is fully contained in this list. 'web_search', 'web_fetch', // v1.12 Track B.2: codecontext tools. Read-only — they call the // codecontext sidecar which only analyzes files (never writes). 'get_codebase_overview', 'get_file_analysis', 'get_symbol_info', 'search_symbols', 'get_dependencies', 'watch_changes', 'get_semantic_neighborhoods', 'get_framework_analysis', ] as const; export const TOOLS_BY_NAME: Record> = Object.fromEntries( ALL_TOOLS.map((t) => [t.name, t]) ); // v1.13.15-tools: tiered tool loading. BOOCODE_TOOLS env var (`core` | // `standard` | `all`) filters the agent's tool whitelist before LLM dispatch. // Daily-driver token win on qwen3.6-35b-a3b — the 35B-A3B MoE benefits from // any prompt-cache stability win (fewer tools = shorter, more stable tool // schemas in the system prompt). Pattern lift from eyaltoledano/claude-task- // master (MIT + Commons Clause — pattern only, no code lift). // // The env var is a CEILING. It only narrows; never expands an agent's // declared whitelist. Default behavior (var unset) is unchanged: all tools. export const CORE_TOOL_NAMES = [ 'view_file', 'list_dir', 'grep', 'find_files', ] as const; export const STANDARD_TOOL_NAMES = [ ...CORE_TOOL_NAMES, 'web_search', 'web_fetch', 'git_status', 'get_codebase_overview', 'get_file_analysis', 'get_symbol_info', 'search_symbols', 'get_dependencies', 'watch_changes', 'get_semantic_neighborhoods', 'get_framework_analysis', ] as const; // Module-load validation: every name in CORE / STANDARD must exist in // TOOLS_BY_NAME. Catches typos and stale tier definitions before they reach // production; server boot fails loudly rather than silently filtering valid // tools out of agent whitelists. for (const name of CORE_TOOL_NAMES) { if (!TOOLS_BY_NAME[name]) { throw new Error(`CORE_TOOL_NAMES references unknown tool: '${name}'`); } } for (const name of STANDARD_TOOL_NAMES) { if (!TOOLS_BY_NAME[name]) { throw new Error(`STANDARD_TOOL_NAMES references unknown tool: '${name}'`); } } export function resolveToolTier(tier: string | undefined): readonly string[] { switch ((tier ?? 'all').toLowerCase()) { case 'core': return CORE_TOOL_NAMES; case 'standard': return STANDARD_TOOL_NAMES; case 'all': default: return ALL_TOOLS.map((t) => t.name); } } export function toolJsonSchemas(): ToolJsonSchema[] { return ALL_TOOLS.map((t) => t.jsonSchema); }