first commit
This commit is contained in:
163
async-agent-lab/agent/agent.ts
Normal file
163
async-agent-lab/agent/agent.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
42
async-agent-lab/agent/config.ts
Normal file
42
async-agent-lab/agent/config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
39
async-agent-lab/agent/inbox.ts
Normal file
39
async-agent-lab/agent/inbox.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
102
async-agent-lab/agent/levels.ts
Normal file
102
async-agent-lab/agent/levels.ts
Normal 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;
|
||||
}
|
||||
169
async-agent-lab/agent/llm.ts
Normal file
169
async-agent-lab/agent/llm.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
63
async-agent-lab/agent/run.ts
Normal file
63
async-agent-lab/agent/run.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
286
async-agent-lab/agent/tools.ts
Normal file
286
async-agent-lab/agent/tools.ts
Normal 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');
|
||||
}
|
||||
29
async-agent-lab/agent/workspace.ts
Normal file
29
async-agent-lab/agent/workspace.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user