02 · 事件系统设计与实现
定义 Agent 事件类型,实现一个轻量的 AgentEmitter 事件总线。
事件从哪里来
上一节确定了方向:agent 在关键节点发出事件,外部监听并渲染。现在要回答的问题是——具体要发哪些事件?每个事件带什么数据?
回到 agent 循环,梳理一下关键节点:
| 时刻 | 事件 | 携带信息 |
|---|---|---|
| 开始处理任务 | agent_start | 任务描述 |
| 调用模型 | model_call | 当前阶段(思考/执行/验证) |
| 模型返回内容 | model_response | 模型的文本输出 |
| 开始执行工具 | tool_call_start | 工具名、参数 |
| 工具返回结果 | tool_call_result | 工具名、结果、是否被批准 |
| 权限请求确认 | permission_ask | 工具名、参数、原因 |
| 权限确认结果 | permission_result | 工具名、是否批准 |
| 计划更新 | todo_update | 当前列表 |
| 任务完成 | agent_done | 最终答案、统计数据 |
这些事件覆盖了 agent 执行的完整生命周期。不需要更多——保持事件种类精简,每个事件都有明确的语义。
注意:permission_ask、permission_result 和 model_response 已在类型中定义,渲染器也处理了这些事件,但在当前实现中 agent 还不会主动发出它们。权限确认目前通过 main.ts 中的确认回调直接处理。这三个事件是为将来更丰富的 UI 集成预留的——当权限确认需要脱离终端回调、改由事件驱动时,只需在 agent 循环中添加对应的 emit 调用即可。
事件类型定义
在 src/ui/events.ts 中定义事件类型:
/** Agent 执行阶段 */
export type AgentPhase = 'thinking' | 'acting' | 'verifying' | 'done';
/** 所有事件类型的联合类型 */
export type AgentEvent =
| { type: 'agent_start'; task: string }
| { type: 'model_call'; phase: AgentPhase }
| { type: 'model_response'; content: string }
| { type: 'tool_call_start'; tool: string; args: Record<string, unknown> }
| {
type: 'tool_call_result';
tool: string;
result: string;
approved: boolean;
}
| {
type: 'permission_ask';
tool: string;
args: Record<string, unknown>;
reason: string;
}
| { type: 'permission_result'; tool: string; approved: boolean }
| {
type: 'todo_update';
todos: Array<{ id: string; description: string; status: string }>;
}
| {
type: 'agent_done';
answer: string;
stats: { modelCalls: number; toolCallCount: number; hitLimit: boolean };
};设计要点:
用联合类型(Discriminated Union)。 每个事件都有 type 字段作为判别标识,消费者可以用 switch (event.type) 安全地处理不同事件,TypeScript 会自动收窄类型。
阶段枚举(AgentPhase)。 模型调用事件带一个 phase 字段,表示当前 agent 处于哪个阶段。"思考"是第一次模型调用,"执行"是收到工具结果后的再次调用。渲染器可以根据阶段显示不同的图标。
approved 字段。 tool_call_result 事件带一个 approved: boolean,表示这个工具调用是被放行还是被拒绝。这样渲染器可以对拒绝的操作显示不同样式。
AgentEmitter 实现
有了事件类型,接下来实现事件发射器。这是一个经典的观察者模式——不依赖 Node.js 的 EventEmitter,用纯 TypeScript 实现:
export type EventListener = (event: AgentEvent) => void;
export class AgentEmitter {
private listeners: EventListener[] = [];
/** 注册监听器 */
on(listener: EventListener): void {
this.listeners.push(listener);
}
/** 移除监听器 */
off(listener: EventListener): void {
this.listeners = this.listeners.filter((l) => l !== listener);
}
/** 发出事件 */
emit(event: AgentEvent): void {
for (const listener of this.listeners) {
listener(event);
}
}
/** 清空所有监听器 */
clear(): void {
this.listeners = [];
}
}这个实现非常简单,但恰好满足需求:
on:注册监听器。返回 void,不支持链式调用,保持简单。off:按引用移除监听器。用filter创建新数组,不影响正在遍历的数组。emit:同步地通知所有监听器。同步意味着事件发出时,所有监听器会在当前调用栈中执行完毕。这对我们的场景足够——不需要异步事件。clear:批量移除。用于测试或重置。
为什么不用 Node.js EventEmitter
Node.js 自带了 EventEmitter,功能更丰富(once、prepend、maxListeners 等)。但选择自己实现有几个原因:
- 教学清晰。 20 行代码,学员一眼就能看懂事件系统的核心原理。
- 类型安全。 自定义实现可以用 TypeScript 联合类型精确约束事件载荷,Node.js EventEmitter 的
on("event", callback)类型推断不如联合类型精确。 - 零依赖。 如果将来 agent 要在浏览器或 Deno 中运行,不依赖 Node.js API 会更方便。
测试事件系统
测试事件系统很简单——注册一个 mock 监听器,发出事件,检查监听器是否收到了正确的事件:
it('on 注册的监听器能收到事件', () => {
const emitter = new AgentEmitter();
const listener = vi.fn();
emitter.on(listener);
const event: AgentEvent = { type: 'agent_start', task: '测试任务' };
emitter.emit(event);
expect(listener).toHaveBeenCalledWith(event);
});还可以测试多个监听器、off 移除、clear 清空等边界情况。完整的测试文件在 test/ui/events.test.ts 中,覆盖了注册、移除、多监听器、无监听器时不报错等场景。
事件的插入点
定义了事件类型和发射器后,下一步是找到 agent 代码中每个事件的插入点——在哪个位置发出哪个事件。
回到 src/agent.ts,每个插入点都用一行 emit?.emit(...) 搞定:
- 任务开始前:
emit?.emit({ type: "agent_start", task: state.task }) - 第一次模型调用前:
emit?.emit({ type: "model_call", phase: "thinking" }) - 工具执行前:
emit?.emit({ type: "tool_call_start", ... }) - 工具执行后:
emit?.emit({ type: "tool_call_result", ... }) - 计划更新后:
emit?.emit({ type: "todo_update", ... }) - 循环内模型调用前:
emit?.emit({ type: "model_call", phase: "acting" }) - 任务完成时:
emit?.emit({ type: "agent_done", ... })
使用可选链 emit?.emit(...) 确保:如果没传 emitter,所有 emit 调用都是空操作,agent 行为不受影响。这保证了向后兼容——第 1-4 章的调用方式不需要任何修改。
RunAgentOptions 的变化
和第 4 章加入 permissionGuard 一样,emitter 也通过 options 传入:
export interface RunAgentOptions {
maxIterations?: number;
permissionGuard?: PermissionGuard;
/** 事件发射器,如果不传则不发出任何事件(向后兼容) */
emitter?: AgentEmitter;
}调用方可以选择传入 emitter 来获取实时事件,也可以不传——这时 agent 的行为和之前完全一样。
下一节会实现终端渲染器,把这些事件变成用户能看懂的终端输出。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。