first commit
This commit is contained in:
286
async-agent-lab/agent/tools.ts
Normal file
286
async-agent-lab/agent/tools.ts
Normal 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');
|
||||
}
|
||||
Reference in New Issue
Block a user