Files
2026-11-12 15:29:12 +08:00

205 lines
8.8 KiB
TypeScript

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<string, { question: Question; resolve: (answer: Answer) => void }>();
private questionCount = 0;
private jobCount = 0;
private answers = new Map<string, Answer>();
private jobs = new Set<string>();
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<AssistantMessage> {
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<ToolResult> = (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<ToolResult> {
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: `<background_job_done>\n${output}\n</background_job_done>` });
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<Answer>((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<number>(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: `<question_reply>\n${JSON.stringify({ question_id: id, ...payload })}\n</question_reply>`,
});
}
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 });
}