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

157 lines
5.7 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.

// 命令行驱动:对真实模型跑一个级别,打印时间线,把整次运行存进 recordings/。
//
// node scripts/drive.ts --level 1 [--steer 4000:"顺便说下 status 在哪些文件里用到"]
// node scripts/drive.ts --level 4 --answer idle:保留一版 # 等 Agent 停在依赖处再回答
// node scripts/drive.ts --level 4 --answer 3000:直接移除 # 提问后 3 秒就回答
//
// 结束条件:Agent 空闲,并且没有在跑的后台命令、没有待回答的问题。
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { parseArgs } from 'node:util';
import { loadProvider, PROJECT_ROOT } from '../agent/config.ts';
import { createLLM } from '../agent/llm.ts';
import { Run } from '../agent/run.ts';
import type { TraceEvent } from '../shared/types.ts';
const { values } = parseArgs({
options: {
level: { type: 'string', default: '1' },
answer: { type: 'string' }, // idle:选项 或 <毫秒>:选项
steer: { type: 'string' }, // <毫秒>:消息,或 test+<毫秒>:消息(第一次 npm test 启动后多少毫秒插话)
out: { type: 'string' },
'idle-ms': { type: 'string', default: '1500' },
},
});
const run = new Run(createLLM(loadProvider()), Number(values.level));
const t0 = Date.now();
const stamp = () => `${((Date.now() - t0) / 1000).toFixed(1).padStart(5)}s`;
const log = (text: string) => console.log(`${stamp()} ${text}`);
const short = (text: string, n = 110) => text.replace(/\s+/g, ' ').slice(0, n);
let busy = false;
let turnIdleSince = 0;
let streamed = '';
const [answerWhen, ...answerRest] = (values.answer ?? '').split(':');
const answerText = answerRest.join(':');
run.subscribe((event: TraceEvent) => {
switch (event.type) {
case 'model.start':
busy = true;
streamed = '';
log(`▶ 模型请求 #${event.n}(历史 ${event.messageCount} 条)`);
break;
case 'model.delta':
if (event.content) streamed += event.content;
break;
case 'model.end': {
const calls = event.message.tool_calls?.map((c) => `${c.function.name}(${short(c.function.arguments, 70)})`) ?? [];
log(`◀ 模型返回 #${event.n} ttft=${event.ttftMs}ms ${short(streamed)}${calls.length ? `\n 工具调用:${calls.join(' | ')}` : ''}`);
if (!calls.length) {
busy = false;
turnIdleSince = Date.now();
}
break;
}
case 'model.error':
log(`✖ 模型错误 #${event.n} ${event.error}`);
break;
case 'tool.start':
log(` ├ 工具开始 ${event.name} ${event.callId.slice(-6)}`);
break;
case 'tool.end':
log(` └ 工具返回 ${event.callId.slice(-6)} ${short(event.result, 90)}`);
break;
case 'job.start':
log(` ◆ 后台任务 ${event.jobId} 启动:${event.command}`);
break;
case 'job.end':
log(` ◆ 后台任务 ${event.jobId} 退出 exit=${event.exitCode} ${(event.durationMs / 1000).toFixed(1)}s`);
break;
case 'question.ask':
log(` ? 提问 ${event.questionId}(${event.mode})${event.question} [${event.options.join(' / ')}]`);
scheduleAnswer(event.questionId);
break;
case 'question.answer':
log(` ! 用户回答 ${event.questionId}:${event.answer}`);
break;
case 'inbox.push':
log(` ✉ 收件箱 +${event.item.kind}${event.item.ref ? `(${event.item.ref})` : ''}`);
break;
case 'inbox.deliver':
log(` ✉ 收件箱 → 历史:${event.ids.join(', ')}`);
break;
case 'run.stop':
if (event.reason === 'error') log(`✖ 运行出错:${event.error}`);
break;
}
});
function scheduleAnswer(questionId: string) {
if (!answerWhen) return;
const fire = () => run.agent.answerQuestion(questionId, answerText);
if (answerWhen === 'idle') {
const timer = setInterval(() => {
if (!busy && turnIdleSince && Date.now() - turnIdleSince > 1500) {
clearInterval(timer);
fire();
}
}, 200);
} else {
setTimeout(fire, Number(answerWhen));
}
}
if (values.steer) {
const [when, ...rest] = values.steer.split(':');
const text = rest.join(':');
const fire = () => {
log(` ✉ 用户插话:${text}`);
run.agent.sendUserMessage(text);
};
if (when.startsWith('test+')) {
const delay = Number(when.slice(5));
let armed = true;
run.subscribe((event) => {
const isTest = (event.type === 'tool.start' && /npm (run )?test/.test(String((event.args as any)?.command ?? ''))) ||
(event.type === 'job.start' && /npm (run )?test/.test(event.command));
if (armed && isTest) {
armed = false;
setTimeout(fire, delay);
}
});
} else {
setTimeout(fire, Number(when));
}
}
log(`运行 ${run.runId},工作区 ${run.workspace}`);
run.start();
const idleMs = Number(values['idle-ms']);
await new Promise<void>((resolve) => {
const timer = setInterval(() => {
const tools = run.agent.tools;
const settled =
!busy && turnIdleSince && Date.now() - turnIdleSince > idleMs &&
tools.runningJobs.size === 0 && tools.pendingQuestions.size === 0;
const failed = run.events.some((e) => e.type === 'run.stop');
if (settled || failed) {
clearInterval(timer);
resolve();
}
}, 300);
});
run.stop();
const lastDiff = [...run.events].reverse().find((e) => e.type === 'workspace.diff');
console.log('\n===== 最终 diff =====\n' + (lastDiff && 'diff' in lastDiff ? lastDiff.diff : '(无)'));
const dir = join(PROJECT_ROOT, 'recordings');
mkdirSync(dir, { recursive: true });
const file = join(dir, `${values.out ?? run.runId}.json`);
writeFileSync(file, JSON.stringify(run.recording(), null, 1));
console.log(`录制已保存:${file}`);
process.exit(0);