04 · 把一切串起来
在 agent 循环中插入事件发射,在 main.ts 中连接渲染器,完成从黑箱到实时交互的升级。
三步改造
前三节分别实现了事件类型、事件发射器和终端渲染器。现在要把它们串起来,完成三步改造:
- agent.ts:在关键节点发出事件
- main.ts:创建 emitter 和 renderer,把两者连接
- 测试:验证事件在正确的时机发出
第一步:agent 中发出事件
回顾 src/agent.ts 中 runAgent 函数的改造。emitter 从 options 中获取,所有 emit 调用都用可选链:
export async function runAgent(
state: AgentState,
model: Model,
tools: Tool[],
options?: RunAgentOptions,
): Promise<AgentResult> {
const emit = options?.emitter;
// ...
/** 任务开始 */
emit?.emit({ type: "agent_start", task: state.task });
/** 第一次模型调用 */
emit?.emit({ type: "model_call", phase: "thinking" });
let response = await model.chat(allMessages, allTools);
for (let i = 0; i < maxIterations; i++) {
if (!response.toolCalls || response.toolCalls.length === 0) {
/** 正常结束 */
emit?.emit({
type: "agent_done",
answer: response.content,
stats: { modelCalls: stats.modelCalls, toolCallCount: stats.toolCallCount, hitLimit: false },
});
return { answer: response.content, todos: [...todoManager.getAll()], stats };
}
for (const toolCall of response.toolCalls) {
// ... 权限检查 ...
/** 工具执行前 */
emit?.emit({ type: "tool_call_start", tool: toolCall.name, args: toolCall.arguments });
/** 权限拒绝 */
if (!approved) {
emit?.emit({ type: "tool_call_result", tool: toolCall.name, result: `被拒绝: ${reason}`, approved: false });
continue;
}
const result = await tool.execute(toolCall.arguments, state);
stats.toolCallCount++;
/** 工具执行后 */
emit?.emit({ type: "tool_call_result", tool: toolCall.name, result, approved: true });
/** 计划工具更新 */
if (toolCall.name === "create_todos" || toolCall.name === "update_todo") {
emit?.emit({ type: "todo_update", todos: [...todoManager.getAll()] });
}
}
/** 循环内模型调用 */
emit?.emit({ type: "model_call", phase: "acting" });
response = await model.chat(allMessages, allTools);
stats.modelCalls++;
}
/** 达到上限 */
stats.hitLimit = true;
emit?.emit({
type: "agent_done",
answer: response.content || "未能完成任务:达到最大迭代次数。",
stats: { modelCalls: stats.modelCalls, toolCallCount: stats.toolCallCount, hitLimit: true },
});
return { answer: response.content || "未能完成任务:达到最大迭代次数。", todos: [...todoManager.getAll()], stats };
}关键设计点:
emit 用可选链 emit?.emit(...)。 不传 emitter 时,所有 emit 都是空操作。这保证了向后兼容——第 1-4 章的代码不需要任何修改。
每个退出路径都有 agent_done。 正常结束和达到上限都会发出 agent_done 事件,让渲染器知道 agent 停下来了。
计划工具的特殊处理。 create_todos 和 update_todo 执行后会额外发出 todo_update 事件。这样渲染器可以在计划变化时立即更新显示。
第二步:main.ts 中连接渲染器
在 src/main.ts 中,创建 emitter 和 renderer,把两者连起来:
import { AgentEmitter } from "./ui/events";
import { TerminalRenderer } from "./ui/renderer";
async function main() {
// ...
/** 创建事件发射器和渲染器 */
const emitter = new AgentEmitter();
const renderer = new TerminalRenderer();
emitter.on((event) => renderer.handleEvent(event));
// ...
/** 传入 emitter */
const result = await runAgent(state, model, tools, { permissionGuard, emitter });
}连接方式非常简单:emitter.on(renderer.handleEvent.bind(renderer))——渲染器注册为 emitter 的监听器。从这一刻起,agent 发出的所有事件都会被渲染器处理并输出到终端。
因为渲染器已经通过事件系统展示了工具调用、计划状态和完成统计,main.ts 中原来手动输出这些信息的代码可以删除。只保留最终答案的输出。
第三步:运行效果
改造完成后,运行 agent 时的终端输出从:
> 帮我找到入口文件
(漫长的等待……)
入口文件是 src/main.ts变成了:
> 帮我找到入口文件
>> 开始处理: 帮我找到入口文件
... 思考中
> create_todos: 搜索入口文件,读取文件内容
计划更新:
[ ] #1 搜索入口文件
[ ] #2 读取文件内容
> 执行操作
> update_todo: ...
> search: 入口文件 main
+ search: 找到 src/main.ts
> update_todo: ...
计划更新:
[x] #1 搜索入口文件
[>] #2 读取文件内容
> 执行操作
> update_todo: ...
> read_file: src/main.ts
+ read_file: import { Model } from "./model"
> update_todo: ...
计划更新:
[x] #1 搜索入口文件
[x] #2 读取文件内容
= 完成 [3次模型调用, 7次工具调用]
入口文件是 src/main.ts每一步都在实时展示——用户能看到 agent 在思考什么、调用了什么工具、每个步骤的状态。
架构图
完成后的架构:
用户输入
↓
main.ts ── 创建 emitter + renderer
↓ ↓
↓ emitter.on(renderer.handleEvent)
↓ ↓
runAgent(state, model, tools, { emitter })
↓
├─ emit("agent_start") → renderer → ">> 开始处理"
├─ emit("model_call") → renderer → "... 思考中"
├─ emit("tool_call_start") → renderer → "> search: 关键词"
├─ emit("tool_call_result")→ renderer → "+ search: 找到 3 个文件"
├─ emit("todo_update") → renderer → "[x] #1 搜索文件"
└─ emit("agent_done") → renderer → "= 完成 [3次, 5次]"agent 只负责发出事件,不关心谁在监听。renderer 只负责处理事件,不关心 agent 的内部逻辑。main.ts 作为"胶水"把两者连在一起。
版本更新
main.ts 的版本号从 v0.4.0 升级到 v0.5.0:
console.log(`mca v0.5.0`);测试覆盖
这一章新增了两个测试文件:
test/ui/events.test.ts:测试AgentEmitter的注册、移除、多监听器、clear、所有事件类型的传递test/ui/renderer.test.ts:测试TerminalRenderer对每种事件类型的渲染输出、长文本截断、拒绝标记
加上之前章节的测试,整个项目现在有 120 个测试用例。
回顾:从脚本到工具
回到第 1 章时的 agent——一个只调用模型、返回文本的脚本。到现在,它经历了这样的演进:
| 章节 | 新增能力 | 用户视角变化 |
|---|---|---|
| 第 1 章 | 最小 agent | 能回答问题 |
| 第 2 章 | ReAct 循环 + 计划工具 | 能拆分任务 |
| 第 3 章 | 完整工具箱 | 能搜索、读写、执行命令 |
| 第 4 章 | 权限系统 | 安全地执行操作 |
| 第 5 章 | 事件流 + 终端渲染 | 实时看到执行过程 |
从"黑箱脚本"到"可观察的交互工具"——这就是事件流带来的变化。而且整个改造是通过事件和监听器模式实现的,agent 的核心逻辑没有被打乱,只是多了几行 emit 调用。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。