first commit

This commit is contained in:
魏天炜
2026-11-12 15:29:12 +08:00
commit 5dabea54ab
93 changed files with 12170 additions and 0 deletions

View File

@@ -0,0 +1,286 @@
// 工具: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<InboxItem, 'id'>) => 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<ChildProcess>();
private jobSeq = 0;
private questionSeq = 0;
readonly runningJobs = new Map<string, { command: string; startedAt: number }>();
readonly pendingQuestions = new Map<string, PendingQuestion>();
constructor(ctx: ToolContext) {
this.ctx = ctx;
}
specs(): ToolSpec[] {
const { level } = this.ctx;
const bashParams: Record<string, unknown> = {
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":...},答案稍后以 <question_reply> 消息送达。',
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<string> {
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<string> {
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:
`<background_job_done job_id="${jobId}" exit_code="${code}" duration_ms="${durationMs}">\n` +
`命令:${command}\n输出最后 ${SUMMARY_LINES} 行:\n${lastLines(output, SUMMARY_LINES)}\n` +
`</background_job_done>`,
});
});
return JSON.stringify({
job_id: jobId,
status: 'running',
note: '命令已在后台启动。退出后你会收到 <background_job_done> 消息。',
});
}
// AiWork 式:工具调用挂起,直到用户回答。答案就是工具结果。
private askBlocking(callId: string, args: any): Promise<string> {
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: `<question_reply question_id="${questionId}">${answer}</question_reply>`,
});
}
}
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');
}