157 lines
5.7 KiB
TypeScript
157 lines
5.7 KiB
TypeScript
// 命令行驱动:对真实模型跑一个级别,打印时间线,把整次运行存进 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);
|