Files
agent-loop/async-agent-lab/agent/tools.ts
2026-11-12 15:29:12 +08:00

287 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 工具: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');
}