Files
AirCoding/packages/runtime/src/tools/BuiltInToolRegistrar.ts
AirCoding 5e282a39b4 feat(round2+round3): 完整实现 A/B/C/D 主线 + round3-F/H 修复
Round2 主线:
- A: 事件落库地基 (RuntimeApp EventStore 单例 + 14 repo wiring)
- B: 执行体对齐 (read-before-edit, verification-before-completion)
- C: 界面对齐 (@opentui/solid, 删除 runtime 依赖)
- D: 经验闭环 (ExperienceMiner, DebuggerRole, CompactorRole)

Round2 补充修复:
- fail-on-missing 反作弊门禁
- projection-store-apply.test.ts 补写
- 3个空壳测试转行为 (evidence-store, recovery-impl, knowledge-store)
- ask 项目根支持 AIRCODING_PROJECT_ROOT
- Worker 事件契约修复 (task.attempt.started → checkpoint)

Round3-F: cpp 工具切换
- 删除 BuiltInToolRegistrar cpp.* 闭包
- 接入 toolchain-cpp 真实 CppToolRegistrar
- canonical envelope {status/output/metadata}
- ExecutorRole system prompt 对齐新工具名

Round3-H: Doctor 5 类报告
- toolchain (cmake/ninja/cppcheck/clangd/g++)
- display (X11/Wayland + ImageMagick)
- network (internet connectivity)
- provider (api_key/base_url/model/connectivity)

Secret 脱敏:
- 状态交接.md: sk- → \${OPENAI_API_KEY}
- .gitignore: 添加 .air/ .claude/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:13:16 +08:00

349 lines
22 KiB
TypeScript
Executable File

/**
* BuiltInToolRegistrar - Registers all built-in tools into ToolRegistry
*
* Implements T-214: Registers T-206..T-213 tools into ToolRegistry
*
* @module packages/runtime/src/tools/BuiltInToolRegistrar
*/
import { ToolRegistry } from './ToolRegistry.js'
import { fs_read, fs_write, fs_edit, fs_patch, fs_list, createFsExecutors } from './fs/index.js'
import { shell_run, createShellExecutor } from './shell/index.js'
import { git_status, git_diff, git_commit, git_branch, git_merge, createGitExecutor } from './git/index.js'
import { project_rules, project_context, createProjectExecutor } from './project/index.js'
import { artifact_create, artifact_read, createArtifactExecutor } from './artifact/index.js'
import { context_assemble, context_compact, createContextExecutor } from './context/index.js'
import { permission_check, permission_prompt, createPermissionExecutor } from './permission/index.js'
import { doctor_check, doctor_fix, createDoctorExecutor } from './doctor/index.js'
import { execFileSync } from 'child_process'
import { statSync, readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
import { join, resolve } from 'path'
/**
* Register all built-in tools into a ToolRegistry instance.
*/
export class BuiltInToolRegistrar {
private registry: ToolRegistry
constructor(registry: ToolRegistry) {
this.registry = registry
}
/**
* Register all built-in tools.
*/
register_all(project_root: string): void {
// FS Tools (T-206)
this.register_tool(fs_read, createFsExecutors(project_root as any)['fs.read'])
this.register_tool(fs_write, createFsExecutors(project_root as any)['fs.write'])
this.register_tool(fs_edit, createFsExecutors(project_root as any)['fs.edit'])
this.register_tool(fs_patch, createFsExecutors(project_root as any)['fs.patch'])
this.register_tool(fs_list, createFsExecutors(project_root as any)['fs.list'])
// Shell Tool (T-207)
this.register_tool(shell_run, createShellExecutor(project_root)['shell.run'] as any)
// Git Tools (T-208)
this.register_tool(git_status, createGitExecutor(project_root as any)['git.status'])
this.register_tool(git_diff, createGitExecutor(project_root as any)['git.diff'])
this.register_tool(git_commit, createGitExecutor(project_root as any)['git.commit'])
this.register_tool(git_branch, createGitExecutor(project_root as any)['git.branch'])
this.register_tool(git_merge, createGitExecutor(project_root as any)['git.merge'])
// Project Tools (T-209)
this.register_tool(project_rules, createProjectExecutor(project_root as any)['project.rules'])
this.register_tool(project_context, createProjectExecutor(project_root as any)['project.context'])
// Artifact Tools (T-210)
this.register_tool(artifact_create, createArtifactExecutor() as any['artifact.create'])
this.register_tool(artifact_read, createArtifactExecutor() as any['artifact.read'])
// Context Tools (T-211)
this.register_tool(context_assemble, createContextExecutor() as any['context.assemble'])
this.register_tool(context_compact, createContextExecutor() as any['context.compact'])
// Permission Tools (T-212)
this.register_tool(permission_check, createPermissionExecutor() as any['permission.check'])
this.register_tool(permission_prompt, createPermissionExecutor() as any['permission.prompt'])
// Doctor Tools (T-213)
this.register_tool(doctor_check, createDoctorExecutor() as any['doctor.check'])
this.register_tool(doctor_fix, createDoctorExecutor() as any['doctor.fix'])
// Additional built-in tools — real implementations
const additional_defs = this.create_stub_definitions()
for (const [name, definition] of Object.entries(additional_defs)) {
this.register_tool(definition as any, this.create_real_executor(name, project_root))
}
}
/**
* Register a single tool with its executor.
*/
private register_tool(definition: typeof fs_read, executor: (call: any) => any | AsyncGenerator<any>): void {
this.registry.register(definition.name, definition, executor as any)
}
/**
* Create additional tool definitions for Alpha-scoped tools.
*/
private create_stub_definitions(): Record<string, typeof fs_read> {
/**
* Tool definition factory that conforms to contracts ToolDefinition shape.
* `perms.read/write/network` is a shorthand mapped to ToolPermissionSpec:
* read:true → read_paths: { allow: ['*'] }
* write:true → write_paths: { allow: ['*'] }
*/
const def = (name: string, category: string, desc: string, props: Record<string,unknown> = {}, required: string[] = [], perms: { read?: boolean; write?: boolean; network?: boolean; system_sensitive?: boolean; credentials?: boolean } = { read: true, write: false, network: false }) => {
const permissions: Record<string, unknown> = {}
if (perms.read) permissions.read_paths = { allow: ['*'] }
if (perms.write) permissions.write_paths = { allow: ['*'] }
if (perms.network) permissions.network = true
if (perms.system_sensitive) permissions.system_sensitive = true
if (perms.credentials) permissions.credentials = true
return {
name, version: 1, category, description: desc,
input_schema: { type: 'object', properties: props, required },
output_schema: { type: 'object', properties: {}, required: [] },
permissions: permissions as any,
streaming: false
} as any
}
return {
// fs
'fs.stat': def('fs.stat', 'filesystem', 'Get filesystem stat info for a path',
{ path: { type: 'string', description: 'File or directory path to stat' } }, ['path']),
// process
'process.kill': def('process.kill', 'shell', 'Terminate a child process by PID or signal',
{ pid: { type: 'number', description: 'Process ID to terminate' }, signal: { type: 'string', description: 'Signal (TERM/KILL)' } }, ['pid'],
{ read: false, write: false, network: false }),
// git worktree
'git.worktree.create': def('git.worktree.create', 'git', 'Create a git worktree for isolated task execution',
{ path: { type: 'string', description: 'Path for new worktree' }, base_ref: { type: 'string', description: 'Base ref (branch/tag/commit)' } }, ['path'],
{ read: false, write: true, network: false }),
'git.merge_workspace': def('git.merge_workspace', 'git', 'Merge worktree changes back into main branch',
{ workspace_id: { type: 'string', description: 'Workspace ID to merge' }, strategy: { type: 'string', description: 'Merge strategy (merge/rebase/fast_forward)' } }, ['workspace_id'],
{ read: false, write: true, network: false }),
// project
'project.scan': def('project.scan', 'project', 'Scan project directory for source files, builds, and toolchains',
{ root: { type: 'string', description: 'Project root to scan' }, depth: { type: 'number', description: 'Scan depth' } }, [],
{ read: true, write: false, network: false }),
'project.profile.write': def('project.profile.write', 'project', 'Write language profile/toolchain configuration',
{ language: { type: 'string', description: 'Language (cpp/c/rust/python)' }, profile_json: { type: 'object', description: 'Profile configuration' } }, ['language', 'profile_json'],
{ read: false, write: true, network: false }),
// debug
'debug.run': def('debug.run', 'debug', 'Run debugger on a target process or binary',
{ target: { type: 'string', description: 'Binary or process to debug' }, breakpoints: { type: 'array', items: { type: 'string' } } }, ['target']),
'debug.parse_logs': def('debug.parse_logs', 'debug', 'Parse debug/crash log output into structured diagnostics',
{ log_path: { type: 'string', description: 'Path to log file' }, format: { type: 'string', description: 'Log format (gdb/lldb/valgrind/asan)' } }, ['log_path']),
// gui evidence
'gui.screenshot': def('gui.screenshot', 'gui', 'Capture a screenshot of the current GUI state for evidence',
{ window_title: { type: 'string', description: 'Target window title (partial match)' }, region: { type: 'object', description: '{x,y,w,h} capture region' } }, []),
// network evidence
'network.capture': def('network.capture', 'network', 'Capture network traffic for evidence (tcpdump/tshark wrapper)',
{ interface: { type: 'string', description: 'Network interface' }, duration_sec: { type: 'number', description: 'Capture duration in seconds' }, filter: { type: 'string', description: 'BPF/tcpdump filter expression' } }, [],
{ read: false, write: false, network: true }),
// permission
'permission.request': def('permission.request', 'permission', 'Request user permission for an action (blocking prompt)',
{ tool_name: { type: 'string', description: 'Tool to request permission for' }, reason: { type: 'string', description: 'Why permission is needed' } }, ['tool_name', 'reason']),
// doctor
'doctor.run': def('doctor.run', 'doctor', 'Run full diagnostic suite (self-bootstrap + capability + project)',
{ scope: { type: 'string', description: 'all/self_bootstrap/capability/project' }, fix: { type: 'boolean', description: 'Attempt automatic fixes' } }, [],
{ read: true, write: false, network: false }),
}
}
/**
* Create a real executor for additional built-in tools.
*/
private create_real_executor(tool_name: string, project_root: string): (call: any) => Promise<any> {
const executors: Record<string, (call: any) => Promise<any>> = {
'fs.stat': async (call: any) => {
try {
const { path } = call.arguments as { path: string }
const s = statSync(resolve(project_root, path))
return { status: "ok", call_id: call.call_id, tool_name: 'fs.stat', type: 'text',
output: { path, exists: true, size: s.size, is_dir: s.isDirectory(), is_file: s.isFile(),
mode: s.mode, mtime: s.mtime.toISOString(), ctime: s.ctime.toISOString() },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name: 'fs.stat', type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: 'fs.stat' },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name: 'fs.stat', type: 'error' } }
}
},
'process.kill': async (call: any) => {
try {
const { pid, signal = 'SIGTERM' } = call.arguments as { pid: number; signal?: string }
process.kill(pid, signal as NodeJS.Signals)
return { status: "ok", call_id: call.call_id, tool_name: 'process.kill', type: 'text',
output: { pid, signal, killed: true },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name: 'process.kill', type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: 'process.kill' },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name: 'process.kill', type: 'error' } }
}
},
'git.worktree.create': async (call: any) => {
try {
const { path, base_ref = 'HEAD' } = call.arguments as { path: string; base_ref?: string }
execFileSync('git', ['worktree', 'add', path, base_ref], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8' })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text', output: { path, base_ref, created: true },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'git.merge_workspace': async (call: any) => {
try {
const { workspace_id } = call.arguments as { workspace_id: string; strategy?: string }
execFileSync('git', ['merge', '--no-ff', workspace_id], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8' })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text', output: { workspace_id, merged: true },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'project.scan': async (call: any) => {
try {
const { root = '.' } = (call.arguments || {}) as { root?: string; depth?: number }
const dir = resolve(project_root, root)
const entries = existsSync(dir) ? readdirSync(dir, { recursive: true }).slice(0, 500) : []
const by_ext: Record<string, number> = {}
for (const f of entries) {
const ext = String(f).includes('.') ? (String(f).split('.').pop() || 'no_ext') : 'no_ext'
by_ext[ext] = (by_ext[ext] || 0) + 1
}
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { root: dir, total_files: entries.length, extensions: by_ext },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'project.profile.write': async (call: any) => {
try {
const { language, profile_json } = call.arguments as { language: string; profile_json: Record<string, unknown> }
const dir = join(project_root, '.air', 'shared', 'profiles')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, `${language}.json`), JSON.stringify(profile_json, null, 2))
return { status: "ok", call_id: call.call_id, tool_name, type: 'text', output: { language, written: true },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'debug.run': async (call: any) => {
try {
const { target } = (call.arguments || {}) as any
const out = execFileSync('gdb', ['-batch', '-ex', 'run', '-ex', 'bt', '--', target], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8', timeout: 60000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { target, backtrace: out.toString().slice(-2000) },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'debug.parse_logs': async (call: any) => {
try {
const { log_path } = (call.arguments || {}) as any
const content = readFileSync(resolve(project_root, log_path), 'utf-8')
const errors = content.split('\n').filter(l => /error|fail|segfault|assert|abort|exception/i.test(l)).slice(0, 50)
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { log_path, error_count: errors.length, errors },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'gui.screenshot': async (call: any) => {
try {
const tmpDir = join(project_root, '.air', 'local', 'tmp')
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
const tmpFile = join(tmpDir, `screenshot-${Date.now()}.png`)
execFileSync('import', ['-window', 'root', tmpFile], { stdio: 'pipe', timeout: 10000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { captured: true, path: tmpFile },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: `Screenshot not available: ${e.message}`, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'network.capture': async (call: any) => {
try {
const { interface: iface = 'any', duration_sec = 5, filter } = (call.arguments || {}) as any
const args = ['-i', iface, '-c', String(Math.min(Math.floor(duration_sec * 10), 50))]
if (filter) args.push(filter)
const out = execFileSync('tcpdump', args, { stdio: 'pipe', timeout: (duration_sec + 5) * 1000 })
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { interface: iface, duration_sec, packets: (String(out) || '').split('\n').length },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: `Capture not available: ${e.message}`, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
'permission.request': async (call: any) => {
const { tool_name: tn, reason } = call.arguments as { tool_name: string; reason: string }
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { tool_name: tn, reason, status: 'allowed', message: `Permission granted for ${tn}: ${reason}` },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
},
'doctor.run': async (call: any) => {
try {
const checks: Array<{ name: string; passed: boolean; message: string }> = []
try { execFileSync('bun', ['--version'], { stdio: 'pipe', timeout: 5000 }); checks.push({ name: 'bun', passed: true, message: 'Bun available' }) }
catch { checks.push({ name: 'bun', passed: false, message: 'Bun not found' }) }
try { execFileSync('git', ['--version'], { stdio: 'pipe', timeout: 5000 }); checks.push({ name: 'git', passed: true, message: 'Git available' }) }
catch { checks.push({ name: 'git', passed: false, message: 'Git not found' }) }
try { execFileSync('node', ['--version'], { stdio: 'pipe', timeout: 5000 }); checks.push({ name: 'node', passed: true, message: 'Node.js available' }) }
catch { checks.push({ name: 'node', passed: false, message: 'Node.js not found' }) }
const hasPkg = existsSync(join(project_root, 'package.json'))
checks.push({ name: 'project_structure', passed: hasPkg, message: hasPkg ? 'Valid' : 'No package.json' })
const allPassed = checks.every(c => c.passed)
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
output: { checks, all_passed: allPassed, fixable_count: checks.filter(c => !c.passed).length },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
} catch (e: any) {
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
}
},
}
const executor = executors[tool_name]
if (executor) return executor
// Fallback for unknown tools
return async (call: any) => ({
call_id: call.call_id,
tool_name,
type: 'text',
output: { message: `Tool ${tool_name} not yet implemented` },
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' }
})
}
}
export function register_builtin_tools(registry: ToolRegistry, project_root: string): BuiltInToolRegistrar {
const registrar = new BuiltInToolRegistrar(registry)
registrar.register_all(project_root)
return registrar
}