// 工具:bash(前台 / 后台)、edit_file、question(阻塞 / 异步)。全部真实执行。 import { spawn, type ChildProcess } from 'node:child_process'; import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { dirname, resolve, relative, isAbsolute } from 'node:path'; import type { InboxItem, QuestionMode, ToolSpec, TraceBody } from '../shared/types.ts'; import type { LevelConfig } from './levels.ts'; export type ToolContext = { workspace: string; level: LevelConfig; emit: (body: TraceBody) => void; pushInbox: (item: Omit) => void; onFileChanged: () => void; }; type PendingQuestion = { mode: QuestionMode; resolve?: (answer: string) => void }; const FOREGROUND_TIMEOUT_MS = 180_000; const RESULT_LIMIT = 6000; const SUMMARY_LINES = 25; export class ToolBox { private ctx: ToolContext; private children = new Set(); private jobSeq = 0; private questionSeq = 0; readonly runningJobs = new Map(); readonly pendingQuestions = new Map(); constructor(ctx: ToolContext) { this.ctx = ctx; } specs(): ToolSpec[] { const { level } = this.ctx; const bashParams: Record = { command: { type: 'string', description: '要执行的 shell 命令,在仓库根目录运行' }, }; if (level.backgroundBash) { bashParams.run_in_background = { type: 'boolean', description: '为 true 时在后台运行,立即返回 job_id;命令退出后结果会以消息形式送达', }; } const specs: ToolSpec[] = [ { type: 'function', function: { name: 'bash', description: level.backgroundBash ? '执行 shell 命令。默认等命令退出后返回退出码和输出;run_in_background 为 true 时立即返回 job_id。' : '执行 shell 命令,等命令退出后返回退出码和输出。', parameters: { type: 'object', properties: bashParams, required: ['command'] }, }, }, { type: 'function', function: { name: 'edit_file', description: '修改文件:把 old_string 替换成 new_string,old_string 必须在文件里恰好出现一次。old_string 为空时用 new_string 新建或覆盖整个文件。', parameters: { type: 'object', properties: { path: { type: 'string', description: '相对仓库根目录的路径' }, old_string: { type: 'string' }, new_string: { type: 'string' }, }, required: ['path', 'old_string', 'new_string'], }, }, }, ]; if (level.question) { specs.push({ type: 'function', function: { name: 'question', description: level.question === 'blocking' ? '向用户提一个选择题,等用户回答后返回答案。' : '向用户提一个选择题。立即返回 {"accepted":true,"question_id":...},答案稍后以 消息送达。', parameters: { type: 'object', properties: { question: { type: 'string' }, options: { type: 'array', items: { type: 'string' }, description: '2 到 4 个选项' }, }, required: ['question', 'options'], }, }, }); } return specs; } async run(callId: string, name: string, args: any): Promise { switch (name) { case 'bash': if (typeof args.command !== 'string') return '错误:缺少 command'; if (args.run_in_background && this.ctx.level.backgroundBash) { return this.startBackground(callId, args.command); } return this.runForeground(callId, args.command); case 'edit_file': return this.editFile(args); case 'question': if (!this.ctx.level.question) break; return this.ctx.level.question === 'blocking' ? this.askBlocking(callId, args) : this.askAsync(callId, args); } return `错误:没有工具 ${name}`; } // 前台:工具调用一直挂着,直到子进程退出。 // 这里用的是 await,Node 事件循环没有被卡住,网页照样能收消息;但 Loop 停在这一步,模型看不到任何新东西。 private runForeground(callId: string, command: string): Promise { return new Promise((done) => { const startedAt = Date.now(); const child = this.spawnShell(command); let output = ''; const onData = (chunk: Buffer) => { const text = chunk.toString(); output += text; this.ctx.emit({ type: 'tool.output', callId, chunk: text }); }; child.stdout!.on('data', onData); child.stderr!.on('data', onData); const timer = setTimeout(() => child.kill('SIGKILL'), FOREGROUND_TIMEOUT_MS); child.on('close', (code, signal) => { clearTimeout(timer); this.children.delete(child); const ms = Date.now() - startedAt; const status = signal ? `被信号 ${signal} 终止` : `exit_code: ${code}`; done(`${status}(耗时 ${(ms / 1000).toFixed(1)} 秒)\n${tail(output, RESULT_LIMIT)}`); }); }); } // 后台:启动进程就返回 job_id。进程退出时,把退出码和日志摘要放进收件箱。 private startBackground(callId: string, command: string): string { const jobId = `job_${++this.jobSeq}`; const startedAt = Date.now(); const child = this.spawnShell(command); this.runningJobs.set(jobId, { command, startedAt }); this.ctx.emit({ type: 'job.start', jobId, callId, command }); let output = ''; const onData = (chunk: Buffer) => { const text = chunk.toString(); output += text; this.ctx.emit({ type: 'job.output', jobId, chunk: text }); }; child.stdout!.on('data', onData); child.stderr!.on('data', onData); child.on('close', (code) => { this.children.delete(child); if (!this.runningJobs.delete(jobId)) return; // 已被 stop() 收掉 const durationMs = Date.now() - startedAt; this.ctx.emit({ type: 'job.end', jobId, exitCode: code, durationMs }); this.ctx.pushInbox({ kind: 'job_done', ref: jobId, content: `\n` + `命令:${command}\n输出最后 ${SUMMARY_LINES} 行:\n${lastLines(output, SUMMARY_LINES)}\n` + ``, }); }); return JSON.stringify({ job_id: jobId, status: 'running', note: '命令已在后台启动。退出后你会收到 消息。', }); } // AiWork 式:工具调用挂起,直到用户回答。答案就是工具结果。 private askBlocking(callId: string, args: any): Promise { const questionId = `q_${++this.questionSeq}`; this.ctx.emit({ type: 'question.ask', questionId, callId, mode: 'blocking', question: String(args.question ?? ''), options: Array.isArray(args.options) ? args.options.map(String) : [], }); return new Promise((resolveAnswer) => { this.pendingQuestions.set(questionId, { mode: 'blocking', resolve: (answer) => resolveAnswer(`用户回答:${answer}`), }); }); } // Codex 式:先回执 accepted,答案稍后带着 question_id 作为用户消息进收件箱。 private askAsync(callId: string, args: any): string { const questionId = `q_${++this.questionSeq}`; this.pendingQuestions.set(questionId, { mode: 'async' }); this.ctx.emit({ type: 'question.ask', questionId, callId, mode: 'async', question: String(args.question ?? ''), options: Array.isArray(args.options) ? args.options.map(String) : [], }); return JSON.stringify({ accepted: true, question_id: questionId }); } answer(questionId: string, answer: string): void { const pending = this.pendingQuestions.get(questionId); if (!pending) throw new Error(`问题 ${questionId} 不存在或已回答`); this.pendingQuestions.delete(questionId); this.ctx.emit({ type: 'question.answer', questionId, answer }); if (pending.mode === 'blocking') { pending.resolve!(answer); } else { this.ctx.pushInbox({ kind: 'question_reply', ref: questionId, content: `${answer}`, }); } } private editFile(args: any): string { const path = String(args.path ?? ''); const full = resolve(this.ctx.workspace, path); const rel = relative(this.ctx.workspace, full); if (!path || rel.startsWith('..') || isAbsolute(rel)) return `错误:路径 ${path} 不在仓库内`; const oldString = String(args.old_string ?? ''); const newString = String(args.new_string ?? ''); if (oldString === '') { mkdirSync(dirname(full), { recursive: true }); writeFileSync(full, newString); this.ctx.onFileChanged(); return `已写入 ${rel}`; } let text: string; try { text = readFileSync(full, 'utf8'); } catch { return `错误:文件 ${rel} 不存在`; } const count = text.split(oldString).length - 1; if (count !== 1) return `错误:old_string 在 ${rel} 里出现了 ${count} 次,需要恰好 1 次`; writeFileSync(full, text.replace(oldString, () => newString)); this.ctx.onFileChanged(); return `已修改 ${rel}`; } private spawnShell(command: string): ChildProcess { const env: NodeJS.ProcessEnv = { ...process.env, CI: '1', NO_COLOR: '1', FORCE_COLOR: '0' }; delete env.EASTMONEY_API_KEY; const child = spawn('bash', ['-c', command], { cwd: this.ctx.workspace, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true, // 独立进程组,stop() 时连同 npm 拉起的子进程一起结束 }); this.children.add(child); return child; } stop(): void { this.runningJobs.clear(); for (const child of this.children) { try { process.kill(-child.pid!, 'SIGKILL'); } catch {} } this.children.clear(); for (const pending of this.pendingQuestions.values()) pending.resolve?.('(会话已停止)'); this.pendingQuestions.clear(); } } function tail(text: string, limit: number): string { return text.length <= limit ? text : `…(前面省略 ${text.length - limit} 字)\n${text.slice(-limit)}`; } function lastLines(text: string, count: number): string { return text.trimEnd().split('\n').slice(-count).join('\n'); }