first commit

This commit is contained in:
魏天炜
2026-11-12 15:29:12 +08:00
commit 5dabea54ab
93 changed files with 12170 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
// Agent Loop。四个级别共用这一个文件,差别只在工具。
//
// 空闲 ──收件箱有东西──▶ 一轮(turn)
// ├─ 一步(step):请求模型 → 依次执行它要的工具 → 结果加进历史
// ├─ 每步结束:把收件箱里的东西加进历史(用户插话、后台完成、问题答复)
// └─ 模型不再调用工具 → 一轮结束,回到空闲
//
// 收件箱只在步与步之间被取走。工具没返回,模型就看不到新东西。
import type { Message, TraceBody } from '../shared/types.ts';
import { Inbox } from './inbox.ts';
import type { LLM } from './llm.ts';
import type { LevelConfig } from './levels.ts';
import { ToolBox } from './tools.ts';
import { workspaceDiff } from './workspace.ts';
export type AgentOptions = {
level: LevelConfig;
workspace: string;
llm: LLM;
emit: (body: TraceBody) => void;
};
const MAX_STEPS_PER_TURN = 40;
export class Agent {
readonly history: Message[] = [];
readonly inbox = new Inbox();
readonly tools: ToolBox;
private opts: AgentOptions;
private abort = new AbortController();
private stopped = false;
private requestCount = 0;
constructor(opts: AgentOptions) {
this.opts = opts;
this.inbox.onPush = (item) => opts.emit({ type: 'inbox.push', item });
this.tools = new ToolBox({
workspace: opts.workspace,
level: opts.level,
emit: opts.emit,
pushInbox: (item) => this.inbox.push(item),
onFileChanged: () => this.emitDiff(),
});
this.append({ role: 'system', content: opts.level.systemPrompt });
}
// 外部入口:三件事都只是往收件箱里放东西。
sendUserMessage(text: string): void {
this.inbox.push({ kind: 'user', content: text });
}
answerQuestion(questionId: string, answer: string): void {
this.tools.answer(questionId, answer);
}
async run(): Promise<void> {
try {
while (!this.stopped) {
await this.inbox.waitForItem();
if (this.stopped) break;
await this.runTurn();
}
} catch (error) {
if (this.stopped) return;
const message = error instanceof Error ? error.message : String(error);
this.opts.emit({ type: 'run.stop', reason: 'error', error: message });
this.stop();
}
}
private async runTurn(): Promise<void> {
this.deliverInbox();
for (let step = 0; step < MAX_STEPS_PER_TURN && !this.stopped; step += 1) {
const assistant = await this.callModel();
if (!assistant.tool_calls?.length) break; // 模型没再要工具,这一轮结束
for (const call of assistant.tool_calls) {
const result = await this.runTool(call.id, call.function.name, call.function.arguments);
if (this.stopped) return;
this.append({ role: 'tool', tool_call_id: call.id, content: result });
}
this.deliverInbox();
}
this.emitDiff();
}
private async callModel() {
const n = ++this.requestCount;
const { emit, llm } = this.opts;
emit({ type: 'model.start', n, messageCount: this.history.length });
try {
const result = await llm.chat({
messages: this.history,
tools: this.tools.specs(),
signal: this.abort.signal,
onDelta: (delta) => emit({ type: 'model.delta', n, ...delta }),
});
emit({ type: 'model.end', n, ...result });
this.append(result.message);
// 防线:完成通知和问题答复只能由运行时放进收件箱。模型自己写出这些标签,就是在冒充结果。
const forged = result.message.content.match(/<(background_job_done|question_reply)\b/);
if (forged) {
emit({ type: 'guard.forged', n, tag: forged[1] });
this.inbox.push({
kind: 'user',
content: `<runtime_notice>你在上一条回复里自己写出了 <${forged[1]}>。这类消息只由运行时发出,你写的内容不是真实结果,请作废基于它的结论,等待真正的消息。</runtime_notice>`,
});
}
return result.message;
} catch (error) {
if (!this.stopped) {
emit({ type: 'model.error', n, error: error instanceof Error ? error.message : String(error) });
}
throw error;
}
}
private async runTool(callId: string, name: string, rawArgs: string): Promise<string> {
let args: unknown;
try {
args = rawArgs ? JSON.parse(rawArgs) : {};
} catch {
const result = `错误:工具参数不是合法 JSON:${rawArgs}`;
this.opts.emit({ type: 'tool.start', callId, name, args: rawArgs, n: this.requestCount });
this.opts.emit({ type: 'tool.end', callId, result });
return result;
}
this.opts.emit({ type: 'tool.start', callId, name, args, n: this.requestCount });
const result = await this.tools.run(callId, name, args);
this.opts.emit({ type: 'tool.end', callId, result });
return result;
}
// 把收件箱里攒下的东西按到达顺序加进历史,下一次模型请求就能看到。
private deliverInbox(): void {
const items = this.inbox.drain();
if (!items.length) return;
this.opts.emit({ type: 'inbox.deliver', ids: items.map((item) => item.id) });
for (const item of items) {
this.append(
{ role: 'user', content: item.content },
{ inboxId: item.id, kind: item.kind, ref: item.ref },
);
}
}
private append(message: Message, origin?: Extract<TraceBody, { type: 'history.append' }>['origin']) {
this.history.push(message);
this.opts.emit({ type: 'history.append', index: this.history.length - 1, message, origin });
}
private emitDiff(): void {
this.opts.emit({ type: 'workspace.diff', diff: workspaceDiff(this.opts.workspace) });
}
stop(): void {
if (this.stopped) return;
this.stopped = true;
this.abort.abort();
this.tools.stop();
this.inbox.interrupt();
}
}

View File

@@ -0,0 +1,42 @@
// 模型网关配置。字段和团队的 provider 配置保持一致:
//
// eastmoney:
// displayName: 东财AI网关
// apiKeyEnv: EASTMONEY_API_KEY
// api: openai-completions
// baseURL: https://dd-ai-api.eastmoney.com/coding/v1
// models: [DeepSeek-V4.1-Flash-1017]
//
// 密钥只从环境变量读取。项目根目录的 .env.local(不进 git)会在启动时加载。
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import type { Provider } from './llm.ts';
export const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url));
const eastmoney = {
displayName: '东财AI网关',
apiKeyEnv: 'EASTMONEY_API_KEY',
api: 'openai-completions' as const,
baseURL: 'https://dd-ai-api.eastmoney.com/coding/v1',
model: 'DeepSeek-V4.1-Flash-1017',
};
export function loadProvider(): Provider {
const envFile = `${PROJECT_ROOT}.env.local`;
if (existsSync(envFile)) process.loadEnvFile(envFile);
const apiKey = process.env[eastmoney.apiKeyEnv];
if (!apiKey) {
throw new Error(
`缺少模型网关密钥。请设置环境变量 ${eastmoney.apiKeyEnv},或写进 ${envFile}(参考 .env.example)。`,
);
}
return {
displayName: eastmoney.displayName,
api: eastmoney.api,
baseURL: process.env.LLM_BASE_URL ?? eastmoney.baseURL,
model: process.env.LLM_MODEL ?? eastmoney.model,
apiKey,
};
}

View File

@@ -0,0 +1,39 @@
// 收件箱:会话外面发生的事要让模型知道,都先放到这里。
// 用户消息、后台命令的完成通知、问题的答复,三种来源走同一个入口。
// Loop 只在两个时刻取走:一轮开始时,以及每一步的工具都返回之后。
import type { InboxItem } from '../shared/types.ts';
export class Inbox {
private items: InboxItem[] = [];
private wake: (() => void) | null = null;
private seq = 0;
onPush: (item: InboxItem) => void = () => {};
push(item: Omit<InboxItem, 'id'>): InboxItem {
const stored = { id: `in_${++this.seq}`, ...item };
this.items.push(stored);
this.onPush(stored);
this.wake?.();
this.wake = null;
return stored;
}
// 取走当前所有内容。
drain(): InboxItem[] {
return this.items.splice(0);
}
// 停止时叫醒空闲中的 Loop,让它退出。
interrupt(): void {
this.wake?.();
this.wake = null;
}
// 空闲时等在这里,有东西进来才继续。
waitForItem(): Promise<void> {
if (this.items.length) return Promise.resolve();
return new Promise((resolve) => {
this.wake = resolve;
});
}
}

View File

@@ -0,0 +1,102 @@
// 四个级别用同一个 Loop、同一份订单服务代码,每级只多一个能力。
import type { LevelInfo, QuestionMode } from '../shared/types.ts';
export type LevelConfig = LevelInfo & {
backgroundBash: boolean;
question: QuestionMode | null;
systemPrompt: string;
};
const BASE = `你是一个在本地代码仓库里工作的编码 Agent,工作目录是订单服务 order-service 的根目录。始终用中文回复。
- 用 bash 查看和搜索代码,用 edit_file 修改文件。先看 README.md 和 docs/api-contract.md 了解约定。
- 每次调用工具前,用一两句中文说明你接下来做什么、为什么。
- 只根据工具真实返回的内容下结论,没看到的结果不要编造。
- 任务完成后,用几句话汇报改了什么、测试结果如何。`;
const BACKGROUND = `
- 完整的 npm test 大约要 15 秒。跑它时把 run_in_background 设为 true,命令会在后台运行,工具立刻返回 job_id,你可以接着做不依赖测试结果的事。
- 命令退出后,系统会发给你一条 <background_job_done> 消息,里面有退出码和输出摘要。在收到它之前,不要声称测试通过或失败。
- <background_job_done> 和 <question_reply> 只由系统发出。你绝对不要自己写出这些标签,也不要预测或模拟它们的内容。
- 不要用 sleep、循环查询之类的办法等后台结果。如果手上的事都做完了,只剩后台结果没回来,就说明你在等什么,然后结束这一轮回复(不再调用工具)。结果到达时你会被自动唤醒。`;
const QUESTION_BLOCKING = `
- 需要用户做决定时调用 question。它会一直等到用户回答才返回,返回值就是用户的选择。`;
const QUESTION_ASYNC = `
- 需要用户做决定时调用 question。它不会等回答,立刻返回 {"accepted":true,"question_id":...}。
- 用户回答后,答案会以 <question_reply question_id="..."> 消息送达。
- 等待期间继续做不依赖答案的工作,例如查清所有调用方、跑基线测试、确认要改的文件。
- 走到依赖答案的那一步时,不要替用户假设答案,也不要用 sleep 等待。说明你停在哪一步、在等哪个问题,然后结束这一轮回复(不再调用工具)。答案到达时你会被自动唤醒。`;
const TASK_DECIDED =
'把订单接口 GET /orders/:id 返回体里的状态字段 status 换成 orderStatus(取值不变)。旧字段 status 保留一个版本,在代码里注明已废弃。仓库内的调用方改读 orderStatus,接口文档同步更新。动手前先跑一遍 npm test 确认基线,改完再跑一遍验证。';
const TASK_ASK =
'把订单接口 GET /orders/:id 返回体里的状态字段 status 换成 orderStatus(取值不变)。仓库内的调用方改读 orderStatus,接口文档同步更新。旧字段 status 要不要保留一个版本会影响仓库外的调用方,这个请你一开始就先用 question 问我,选项给「保留一版」和「直接移除」。动手前先跑一遍 npm test 确认基线,改完再跑一遍验证。';
export const LEVELS: LevelConfig[] = [
{
id: 1,
name: '同步 Bash',
title: '最小 Agent:消息历史 + 模型调用 + 同步 bash',
adds: 'Loop 发出工具调用后等命令退出,把结果加进历史,再请求模型。',
watch: '测试在输出日志,模型请求计数停在当前这一次。测试退出后,下一次请求才发出。这期间发的消息会进收件箱,等工具返回后才交给模型。',
tools: ['bash', 'edit_file'],
defaultTask: TASK_DECIDED,
backgroundBash: false,
question: null,
systemPrompt: BASE,
source: [
{ file: 'agent/agent.ts', anchor: 'runTurn' },
{ file: 'agent/tools.ts', anchor: 'runForeground' },
],
},
{
id: 2,
name: '后台 Bash',
title: '同一个 bash 加 run_in_background',
adds: '启动进程后先返回 job_id,命令退出时运行时把退出码和日志摘要放进收件箱,交回原会话。',
watch: '测试还在跑,下一次模型请求已经发出,Agent 去查调用方。完成通知到达后,它才汇报测试结果。',
tools: ['bash', 'edit_file'],
defaultTask: TASK_DECIDED,
backgroundBash: true,
question: null,
systemPrompt: BASE + BACKGROUND,
source: [
{ file: 'agent/tools.ts', anchor: 'startBackground' },
{ file: 'agent/agent.ts', anchor: 'deliverInbox' },
],
},
{
id: 3,
name: '阻塞提问',
title: '把等待对象换成人:AiWork 式阻塞 question',
adds: 'question 工具一直等到用户回答才返回。答案就是工具结果。',
watch: '问题卡挂着的时候,模型请求停在提问的那一次。这期间发的消息进了收件箱,但 Agent 不会继续工作。',
tools: ['bash', 'edit_file', 'question'],
defaultTask: TASK_ASK,
backgroundBash: true,
question: 'blocking',
systemPrompt: BASE + BACKGROUND + QUESTION_BLOCKING,
source: [{ file: 'agent/tools.ts', anchor: 'askBlocking' }],
},
{
id: 4,
name: '异步提问',
title: 'Codex 式异步 question:先回执,答案带问题 ID 回来',
adds: 'question 立刻返回 accepted,答案作为带 question_id 的用户消息进收件箱。',
watch: '回答之前 Agent 已经查完调用方,停在需要答案的那一步。选「保留一版」或「直接移除」后,它做对应的修改。提前回答的话,答案在下一次请求里被消费。',
tools: ['bash', 'edit_file', 'question'],
defaultTask: TASK_ASK,
backgroundBash: true,
question: 'async',
systemPrompt: BASE + BACKGROUND + QUESTION_ASYNC,
source: [{ file: 'agent/tools.ts', anchor: 'askAsync' }],
},
];
export function getLevel(id: number): LevelConfig {
const level = LEVELS.find((item) => item.id === id);
if (!level) throw new Error(`没有第 ${id} 级`);
return level;
}

View File

@@ -0,0 +1,169 @@
// 大模型请求封装:OpenAI Chat Completions 协议,流式读取,把分片拼成一条完整的 assistant 消息。
// 只依赖 Node 自带的 fetch。
import type { AssistantMessage, Message, ToolCall, ToolSpec, Usage } from '../shared/types.ts';
import { setTimeout as delay } from 'node:timers/promises';
export type Provider = {
displayName: string;
api: 'openai-completions';
baseURL: string;
apiKey: string;
model: string;
contextWindow?: number;
maxOutputTokens?: number;
inputModalities?: readonly string[];
};
export type TransportEvent =
| { type: 'http.request'; attempt: number; body: string }
| { type: 'http.response'; attempt: number; status: number }
| { type: 'http.error'; attempt: number; error: string };
export type ChatRequest = {
messages: Message[];
tools: ToolSpec[];
signal?: AbortSignal;
onDelta?: (delta: { content?: string; reasoning?: string }) => void;
onTransport?: (event: TransportEvent) => void;
toolChoice?: string;
};
export type ChatResult = {
message: AssistantMessage;
reasoning: string;
usage: Usage | null;
finishReason: string | null;
ttftMs: number | null; // 首个分片到达的耗时
attempt: number;
};
export type LLM = { provider: Provider; chat(request: ChatRequest): Promise<ChatResult> };
// 网关对单个令牌有 RPM 限制(实测 50 次/分钟),429 时退避重试。
const RETRY_DELAYS_MS = [1000, 3000, 8000, 15000, 30000];
export function createLLM(provider: Provider): LLM {
async function open(request: ChatRequest) {
for (let attempt = 1; ; attempt += 1) {
const body = JSON.stringify({
model: provider.model,
messages: request.messages,
tools: request.tools,
temperature: 0,
stream: true,
stream_options: { include_usage: true },
max_tokens: provider.maxOutputTokens,
tool_choice: request.toolChoice === undefined || request.toolChoice === 'none'
? request.toolChoice
: { type: 'function', function: { name: request.toolChoice } },
});
if (provider.apiKey && body.includes(provider.apiKey)) throw new Error('请求内容含有传输凭据。');
let failure: string;
try {
request.onTransport?.({ type: 'http.request', attempt, body });
const response = await fetch(`${provider.baseURL}/chat/completions`, {
method: 'POST',
redirect: 'error',
signal: request.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${provider.apiKey}`,
},
body,
});
request.onTransport?.({ type: 'http.response', attempt, status: response.status });
if (response.ok) return { response, attempt };
await response.body?.cancel();
failure = `模型网关返回 HTTP ${response.status}`;
const retryable = response.status === 429 || response.status >= 500;
if (!retryable) throw new Error(failure);
} catch (error) {
if (request.signal?.aborted) throw error;
if (error instanceof Error && error.message.startsWith('模型网关返回')) throw error;
failure = `连接模型网关失败:${error instanceof Error ? error.message : String(error)}`;
request.onTransport?.({ type: 'http.error', attempt, error: failure });
}
const waitMs = RETRY_DELAYS_MS[attempt - 1];
if (waitMs === undefined) throw new Error(failure);
await delay(waitMs, undefined, { signal: request.signal });
}
}
return {
provider,
async chat(request) {
const startedAt = performance.now();
const { response, attempt } = await open(request);
let content = '';
let reasoning = '';
let usage: Usage | null = null;
let finishReason: string | null = null;
let ttftMs: number | null = null;
const toolCalls: ToolCall[] = [];
for await (const chunk of readServerSentEvents(response)) {
if (chunk.error) throw new Error('模型网关在流中返回错误');
if (ttftMs === null) ttftMs = Math.round(performance.now() - startedAt);
if (chunk.usage) usage = normalizeUsage(chunk.usage);
const choice = chunk.choices?.[0];
if (!choice) continue;
if (choice.finish_reason) finishReason = choice.finish_reason;
const delta = choice.delta ?? {};
if (delta.reasoning_content) {
reasoning += delta.reasoning_content;
request.onDelta?.({ reasoning: delta.reasoning_content });
}
if (delta.content) {
content += delta.content;
request.onDelta?.({ content: delta.content });
}
// 工具调用按 index 分片到达:第一片带 id 和函数名,后续分片只追加参数字符串。
for (const part of delta.tool_calls ?? []) {
const call = (toolCalls[part.index] ??= {
id: '',
type: 'function',
function: { name: '', arguments: '' },
});
if (part.id) call.id = part.id;
if (part.function?.name) call.function.name += part.function.name;
if (part.function?.arguments) call.function.arguments += part.function.arguments;
}
}
const message: AssistantMessage = { role: 'assistant', content };
const calls = toolCalls.filter(Boolean);
if (calls.length) message.tool_calls = calls;
if (finishReason === null) throw new Error('模型响应流提前结束');
return { message, reasoning, usage, finishReason, ttftMs, attempt };
},
};
}
async function* readServerSentEvents(response: Response): AsyncGenerator<any> {
if (!response.body) throw new Error('模型网关没有返回响应体');
const decoder = new TextDecoder();
let buffer = '';
for await (const bytes of response.body as unknown as AsyncIterable<Uint8Array>) {
buffer += decoder.decode(bytes, { stream: true });
let newline: number;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (data === '[DONE]') return;
yield JSON.parse(data);
}
}
}
function normalizeUsage(raw: any): Usage {
return {
prompt_tokens: raw.prompt_tokens ?? 0,
completion_tokens: raw.completion_tokens ?? 0,
total_tokens: raw.total_tokens ?? 0,
cached_tokens: raw.prompt_tokens_details?.cached_tokens,
reasoning_tokens: raw.completion_tokens_details?.reasoning_tokens,
};
}

View File

@@ -0,0 +1,63 @@
// 一次运行:准备工作区、创建 Agent、给每个事件打上序号和相对时间。
// 本地服务和命令行脚本都用它。
import { randomBytes } from 'node:crypto';
import type { RunRecording, TraceBody, TraceEvent } from '../shared/types.ts';
import { Agent } from './agent.ts';
import type { LLM } from './llm.ts';
import { getLevel } from './levels.ts';
import { prepareWorkspace } from './workspace.ts';
export class Run {
readonly runId: string;
readonly level: number;
readonly task: string;
readonly startedAt = Date.now();
readonly events: TraceEvent[] = [];
readonly agent: Agent;
readonly workspace: string;
private listeners = new Set<(event: TraceEvent) => void>();
private seq = 0;
constructor(llm: LLM, level: number, task?: string) {
const config = getLevel(level);
this.level = level;
this.task = task?.trim() || config.defaultTask;
this.runId = `L${level}-${new Date().toISOString().slice(11, 19).replaceAll(':', '')}-${randomBytes(2).toString('hex')}`;
this.workspace = prepareWorkspace(this.runId);
this.agent = new Agent({ level: config, workspace: this.workspace, llm, emit: (body) => this.record(body) });
this.record({
type: 'run.start',
runId: this.runId,
level,
model: llm.provider.model,
baseURL: llm.provider.baseURL,
tools: this.agent.tools.specs(),
startedAt: this.startedAt,
});
}
start(): void {
this.agent.sendUserMessage(this.task);
void this.agent.run();
}
stop(): void {
this.agent.stop();
this.record({ type: 'run.stop', reason: 'stopped' });
}
subscribe(listener: (event: TraceEvent) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
recording(): RunRecording {
return { runId: this.runId, level: this.level, task: this.task, startedAt: this.startedAt, events: this.events };
}
private record(body: TraceBody): void {
const event = { seq: ++this.seq, t: Date.now() - this.startedAt, ...body } as TraceEvent;
this.events.push(event);
for (const listener of this.listeners) listener(event);
}
}

View File

@@ -0,0 +1,286 @@
// 工具:bash(前台 / 后台)、edit_file、question(阻塞 / 异步)。全部真实执行。
import { spawn, type ChildProcess } from 'node:child_process';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, resolve, relative, isAbsolute } from 'node:path';
import type { InboxItem, QuestionMode, ToolSpec, TraceBody } from '../shared/types.ts';
import type { LevelConfig } from './levels.ts';
export type ToolContext = {
workspace: string;
level: LevelConfig;
emit: (body: TraceBody) => void;
pushInbox: (item: Omit<InboxItem, 'id'>) => void;
onFileChanged: () => void;
};
type PendingQuestion = { mode: QuestionMode; resolve?: (answer: string) => void };
const FOREGROUND_TIMEOUT_MS = 180_000;
const RESULT_LIMIT = 6000;
const SUMMARY_LINES = 25;
export class ToolBox {
private ctx: ToolContext;
private children = new Set<ChildProcess>();
private jobSeq = 0;
private questionSeq = 0;
readonly runningJobs = new Map<string, { command: string; startedAt: number }>();
readonly pendingQuestions = new Map<string, PendingQuestion>();
constructor(ctx: ToolContext) {
this.ctx = ctx;
}
specs(): ToolSpec[] {
const { level } = this.ctx;
const bashParams: Record<string, unknown> = {
command: { type: 'string', description: '要执行的 shell 命令,在仓库根目录运行' },
};
if (level.backgroundBash) {
bashParams.run_in_background = {
type: 'boolean',
description: '为 true 时在后台运行,立即返回 job_id;命令退出后结果会以消息形式送达',
};
}
const specs: ToolSpec[] = [
{
type: 'function',
function: {
name: 'bash',
description: level.backgroundBash
? '执行 shell 命令。默认等命令退出后返回退出码和输出;run_in_background 为 true 时立即返回 job_id。'
: '执行 shell 命令,等命令退出后返回退出码和输出。',
parameters: { type: 'object', properties: bashParams, required: ['command'] },
},
},
{
type: 'function',
function: {
name: 'edit_file',
description:
'修改文件:把 old_string 替换成 new_string,old_string 必须在文件里恰好出现一次。old_string 为空时用 new_string 新建或覆盖整个文件。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: '相对仓库根目录的路径' },
old_string: { type: 'string' },
new_string: { type: 'string' },
},
required: ['path', 'old_string', 'new_string'],
},
},
},
];
if (level.question) {
specs.push({
type: 'function',
function: {
name: 'question',
description:
level.question === 'blocking'
? '向用户提一个选择题,等用户回答后返回答案。'
: '向用户提一个选择题。立即返回 {"accepted":true,"question_id":...},答案稍后以 <question_reply> 消息送达。',
parameters: {
type: 'object',
properties: {
question: { type: 'string' },
options: { type: 'array', items: { type: 'string' }, description: '2 到 4 个选项' },
},
required: ['question', 'options'],
},
},
});
}
return specs;
}
async run(callId: string, name: string, args: any): Promise<string> {
switch (name) {
case 'bash':
if (typeof args.command !== 'string') return '错误:缺少 command';
if (args.run_in_background && this.ctx.level.backgroundBash) {
return this.startBackground(callId, args.command);
}
return this.runForeground(callId, args.command);
case 'edit_file':
return this.editFile(args);
case 'question':
if (!this.ctx.level.question) break;
return this.ctx.level.question === 'blocking'
? this.askBlocking(callId, args)
: this.askAsync(callId, args);
}
return `错误:没有工具 ${name}`;
}
// 前台:工具调用一直挂着,直到子进程退出。
// 这里用的是 await,Node 事件循环没有被卡住,网页照样能收消息;但 Loop 停在这一步,模型看不到任何新东西。
private runForeground(callId: string, command: string): Promise<string> {
return new Promise((done) => {
const startedAt = Date.now();
const child = this.spawnShell(command);
let output = '';
const onData = (chunk: Buffer) => {
const text = chunk.toString();
output += text;
this.ctx.emit({ type: 'tool.output', callId, chunk: text });
};
child.stdout!.on('data', onData);
child.stderr!.on('data', onData);
const timer = setTimeout(() => child.kill('SIGKILL'), FOREGROUND_TIMEOUT_MS);
child.on('close', (code, signal) => {
clearTimeout(timer);
this.children.delete(child);
const ms = Date.now() - startedAt;
const status = signal ? `被信号 ${signal} 终止` : `exit_code: ${code}`;
done(`${status}(耗时 ${(ms / 1000).toFixed(1)} 秒)\n${tail(output, RESULT_LIMIT)}`);
});
});
}
// 后台:启动进程就返回 job_id。进程退出时,把退出码和日志摘要放进收件箱。
private startBackground(callId: string, command: string): string {
const jobId = `job_${++this.jobSeq}`;
const startedAt = Date.now();
const child = this.spawnShell(command);
this.runningJobs.set(jobId, { command, startedAt });
this.ctx.emit({ type: 'job.start', jobId, callId, command });
let output = '';
const onData = (chunk: Buffer) => {
const text = chunk.toString();
output += text;
this.ctx.emit({ type: 'job.output', jobId, chunk: text });
};
child.stdout!.on('data', onData);
child.stderr!.on('data', onData);
child.on('close', (code) => {
this.children.delete(child);
if (!this.runningJobs.delete(jobId)) return; // 已被 stop() 收掉
const durationMs = Date.now() - startedAt;
this.ctx.emit({ type: 'job.end', jobId, exitCode: code, durationMs });
this.ctx.pushInbox({
kind: 'job_done',
ref: jobId,
content:
`<background_job_done job_id="${jobId}" exit_code="${code}" duration_ms="${durationMs}">\n` +
`命令:${command}\n输出最后 ${SUMMARY_LINES} 行:\n${lastLines(output, SUMMARY_LINES)}\n` +
`</background_job_done>`,
});
});
return JSON.stringify({
job_id: jobId,
status: 'running',
note: '命令已在后台启动。退出后你会收到 <background_job_done> 消息。',
});
}
// AiWork 式:工具调用挂起,直到用户回答。答案就是工具结果。
private askBlocking(callId: string, args: any): Promise<string> {
const questionId = `q_${++this.questionSeq}`;
this.ctx.emit({
type: 'question.ask',
questionId,
callId,
mode: 'blocking',
question: String(args.question ?? ''),
options: Array.isArray(args.options) ? args.options.map(String) : [],
});
return new Promise((resolveAnswer) => {
this.pendingQuestions.set(questionId, {
mode: 'blocking',
resolve: (answer) => resolveAnswer(`用户回答:${answer}`),
});
});
}
// Codex 式:先回执 accepted,答案稍后带着 question_id 作为用户消息进收件箱。
private askAsync(callId: string, args: any): string {
const questionId = `q_${++this.questionSeq}`;
this.pendingQuestions.set(questionId, { mode: 'async' });
this.ctx.emit({
type: 'question.ask',
questionId,
callId,
mode: 'async',
question: String(args.question ?? ''),
options: Array.isArray(args.options) ? args.options.map(String) : [],
});
return JSON.stringify({ accepted: true, question_id: questionId });
}
answer(questionId: string, answer: string): void {
const pending = this.pendingQuestions.get(questionId);
if (!pending) throw new Error(`问题 ${questionId} 不存在或已回答`);
this.pendingQuestions.delete(questionId);
this.ctx.emit({ type: 'question.answer', questionId, answer });
if (pending.mode === 'blocking') {
pending.resolve!(answer);
} else {
this.ctx.pushInbox({
kind: 'question_reply',
ref: questionId,
content: `<question_reply question_id="${questionId}">${answer}</question_reply>`,
});
}
}
private editFile(args: any): string {
const path = String(args.path ?? '');
const full = resolve(this.ctx.workspace, path);
const rel = relative(this.ctx.workspace, full);
if (!path || rel.startsWith('..') || isAbsolute(rel)) return `错误:路径 ${path} 不在仓库内`;
const oldString = String(args.old_string ?? '');
const newString = String(args.new_string ?? '');
if (oldString === '') {
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, newString);
this.ctx.onFileChanged();
return `已写入 ${rel}`;
}
let text: string;
try {
text = readFileSync(full, 'utf8');
} catch {
return `错误:文件 ${rel} 不存在`;
}
const count = text.split(oldString).length - 1;
if (count !== 1) return `错误:old_string 在 ${rel} 里出现了 ${count} 次,需要恰好 1 次`;
writeFileSync(full, text.replace(oldString, () => newString));
this.ctx.onFileChanged();
return `已修改 ${rel}`;
}
private spawnShell(command: string): ChildProcess {
const env: NodeJS.ProcessEnv = { ...process.env, CI: '1', NO_COLOR: '1', FORCE_COLOR: '0' };
delete env.EASTMONEY_API_KEY;
const child = spawn('bash', ['-c', command], {
cwd: this.ctx.workspace,
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true, // 独立进程组,stop() 时连同 npm 拉起的子进程一起结束
});
this.children.add(child);
return child;
}
stop(): void {
this.runningJobs.clear();
for (const child of this.children) {
try {
process.kill(-child.pid!, 'SIGKILL');
} catch {}
}
this.children.clear();
for (const pending of this.pendingQuestions.values()) pending.resolve?.('(会话已停止)');
this.pendingQuestions.clear();
}
}
function tail(text: string, limit: number): string {
return text.length <= limit ? text : `…(前面省略 ${text.length - limit} 字)\n${text.slice(-limit)}`;
}
function lastLines(text: string, count: number): string {
return text.trimEnd().split('\n').slice(-count).join('\n');
}

View File

@@ -0,0 +1,29 @@
// 每次运行把样例仓库复制一份,初始化成 git 仓库,方便看 diff。
import { cpSync, mkdirSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { PROJECT_ROOT } from './config.ts';
export const FIXTURE = join(PROJECT_ROOT, 'fixture', 'order-service');
export const RUNS_DIR = join(PROJECT_ROOT, '.runs');
export function prepareWorkspace(runId: string): string {
const dir = join(RUNS_DIR, runId, 'order-service');
rmSync(dir, { recursive: true, force: true });
mkdirSync(dir, { recursive: true });
cpSync(FIXTURE, dir, { recursive: true });
const git = (...args: string[]) => execFileSync('git', args, { cwd: dir, stdio: 'ignore' });
git('init', '-q', '-b', 'main');
git('add', '-A');
git('-c', 'user.name=demo', '-c', 'user.email=demo@example.com', 'commit', '-qm', 'baseline');
return dir;
}
export function workspaceDiff(dir: string): string {
try {
execFileSync('git', ['add', '-A', '-N'], { cwd: dir, stdio: 'ignore' });
return execFileSync('git', ['diff', '--no-color'], { cwd: dir, encoding: 'utf8' });
} catch {
return '';
}
}