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