import { Inbox } from '../agent/inbox.ts'; import type { LLM } from '../agent/llm.ts'; import type { AssistantMessage, Message, ToolCall } from '../shared/types.ts'; import { Tools, requireString } from './tools.ts'; import type { Args } from './tools.ts'; import type { Answer, Delivery, LiveBody, Question, ScenarioInfo } from './types.ts'; type ToolResult = { result: string; ref?: string }; export class Runtime { readonly history: Message[]; readonly inbox = new Inbox(); readonly tools: Tools; private controller = new AbortController(); private deliveries: Delivery[] = []; private requestCount = 0; private questions = new Map void }>(); private questionCount = 0; private jobCount = 0; private answers = new Map(); private jobs = new Set(); readonly emit: (event: LiveBody) => void; readonly config: ScenarioInfo; readonly llm: LLM; constructor(config: ScenarioInfo, workspace: string, llm: LLM, emit: (event: LiveBody) => void) { this.config = config; this.llm = llm; this.emit = emit; this.history = [{ role: 'system', content: config.system }, { role: 'user', content: config.task }]; this.tools = new Tools(workspace, this.controller.signal); this.inbox.onPush = (item) => emit({ type: 'inbox.push', item }); } async callModel(history = this.history, choice?: string): Promise { this.controller.signal.throwIfAborted(); this.deliverInbox(); const n = ++this.requestCount; const deliveries = this.deliveries.slice(); const response = await this.llm.chat({ messages: history, tools: this.config.tools, toolChoice: choice, signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(90_000)]), onTransport: (event) => this.emit(event.type === 'http.request' ? { ...event, n, deliveries } : { ...event, n }), onDelta: ({ content }) => { if (content) this.emit({ type: 'model.text', n, text: content }); }, }); this.emit({ type: 'model.result', n, response }); return response.reasoning && response.message.tool_calls?.length ? { ...response.message, reasoning_content: response.reasoning } as AssistantMessage : response.message; } async runTool(call: ToolCall, handle: (args: Args) => ToolResult | Promise = (args: Args) => this.baseTool(call.function.name, args)) { const n = this.requestCount; const { id: callId, function: { name, arguments: raw } } = call; let args: Args | undefined; try { const parsed: unknown = JSON.parse(raw || '{}'); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) args = parsed as Args; } catch { // Preserve the raw input in the trace so malformed calls can be inspected. } this.emit({ type: 'tool.call', n, callId, name, args: args ?? raw }); let output: ToolResult; try { if (!args) throw new Error('工具参数必须是 JSON 对象。'); output = await handle(args); this.controller.signal.throwIfAborted(); } catch (error) { this.controller.signal.throwIfAborted(); output = { result: JSON.stringify({ error: (error as Error).message }) }; } this.emit({ type: 'tool.result', n, callId, name, ...output }); return output; } async baseTool(name: string, args: Args): Promise { switch (name) { case 'bash': return { result: JSON.stringify(await this.tools.bash(args)) }; case 'read': return { result: await this.tools.read(args) }; case 'grep': return { result: await this.tools.grep(args) }; case 'write': throw new Error('写周报前需要完成两项周报决策。'); default: throw new Error(`此处无法执行 ${name}`); } } startBackground(callId: string, args: Args): ToolResult { const command = requireString(args, 'command'); if (command !== 'weekly-evidence' || args.run_in_background !== true) { throw new Error('请用 run_in_background: true 运行 weekly-evidence。'); } const jobId = `job_${++this.jobCount}`; const started = performance.now(); const process = this.tools.bash(args); this.emit({ type: 'job.start', jobId, callId, command }); void process.catch((error) => ({ exit_code: null, stdout: '', stderr: (error as Error).message, })).then(({ exit_code, stdout, stderr }) => { if (this.controller.signal.aborted) return; const output = JSON.stringify({ job_id: jobId, exit_code, stdout, stderr }); this.emit({ type: 'job.exit', jobId, callId, code: exit_code, duration: Math.round(performance.now() - started), output }); this.inbox.push({ kind: 'job_done', ref: jobId, content: `\n${output}\n` }); this.jobs.add(jobId); }); return { ref: jobId, result: JSON.stringify({ accepted: true, job_id: jobId }) }; } openQuestion(callId: string, mode: Question['mode'], kind: Question['kind'], args: Args) { const text = requireString(args, 'question').trim(); if (!text || text.length > 100) throw new Error('请用一句话提问。'); const input = kind === 'selection' ? args.options : []; const options = Array.isArray(input) ? input.map((item) => typeof item === 'string' ? item.replace(/\s+/g, ' ').trim() : '') : []; if (options.some((item) => !item || item.length > 110) || (kind === 'selection' && (options.length < 1 || options.length > 5 || new Set(options).size !== options.length))) { throw new Error('请给出 1 至 5 个互不重复的候选工作项,每项不超过 110 字。'); } const question: Question = { id: `q_${++this.questionCount}`, callId, mode, kind, text, options }; const promise = new Promise((resolve) => { this.questions.set(question.id, { question, resolve }); }); this.emit({ type: 'question.open', question }); return { question, answer: promise }; } answer(id: string, input: unknown) { const entry = this.questions.get(id); if (!entry || this.controller.signal.aborted) throw new Error('这道问题已结束。'); if (!input || typeof input !== 'object') throw new Error('请输入答复。'); const { question } = entry; let answer: Answer; if (question.kind === 'priority') { const text = 'text' in input && typeof input.text === 'string' ? input.text.trim() : ''; if (!text || text.length > 160 || /[\r\n]/.test(text) || text.includes(this.llm.provider.apiKey)) { throw new Error('请输入一行下周优先事项,最多 160 字。'); } answer = { text }; } else { const selected = 'selected' in input ? input.selected : undefined; if (!Array.isArray(selected) || selected.some((index) => !Number.isInteger(index) || index < 0 || index >= question.options.length)) { throw new Error('请从问题卡上的候选工作项中选择。'); } answer = { selected: [...new Set(selected)].sort((a, b) => a - b) }; } this.questions.delete(id); this.emit({ type: 'question.answer', questionId: id, callId: question.callId, answer }); if (question.mode === 'async') { const payload = 'text' in answer ? answer : { ...answer, items: answer.selected.map((index) => question.options[index]) }; this.inbox.push({ kind: 'question_reply', ref: id, content: `\n${JSON.stringify({ question_id: id, ...payload })}\n`, }); } this.answers.set(id, answer); entry.resolve(answer); } async waitForAnswer(id: string) { await this.waitFor(() => this.answers.has(id), `等待 ${id} 的网页答复`); return this.answers.get(id)!; } async waitForJob(id: string) { await this.waitFor(() => this.jobs.has(id), `等待 ${id} 的进程结果`); } private async waitFor(ready: () => boolean, note: string) { if (ready()) return; this.emit({ type: 'run.state', state: 'waiting', note }); while (!ready()) { await this.inbox.waitForItem(); this.controller.signal.throwIfAborted(); this.deliverInbox(); } this.emit({ type: 'run.state', state: 'running' }); } private deliverInbox() { const items = this.inbox.drain().map((item) => { const messageIndex = this.history.push({ role: 'user', content: item.content }) - 1; return { inboxId: item.id, kind: item.kind, ref: item.ref ?? item.id, messageIndex }; }); if (items.length) { this.deliveries.push(...items); this.emit({ type: 'inbox.deliver', items }); } } stop() { this.controller.abort(); this.inbox.interrupt(); for (const entry of this.questions.values()) entry.resolve({ text: '' }); this.questions.clear(); } } export function appendResult(history: Message[], call: ToolCall, content: string) { history.push({ role: 'tool', tool_call_id: call.id, content }); }