205 lines
8.8 KiB
TypeScript
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 });
|
|
}
|