5 min read
Day 06:Agent Loop:Observe, Decide, Act / Coding Agent 执行循环

上一篇:Day 05 - Local File Tools
下一篇:Transcript Logging:Agent 执行日志与可观测性
English version: Agent Loop: Observe, Decide, Act in a Coding Agent

文章介绍

Day 05 我们给 mini harness 加了三个只读本地工具:list_filesread_filesearch_text

但它们还只是“手动工具”。用户必须显式写:

npm run dev -- --tool list_files --tool-input '{"path":".","maxDepth":1}'

这还不是 agent。真正的 coding agent 不只是回答一次,也不只是执行一次工具。它需要反复做这件事:

observe -> decide -> act -> observe -> final

今天实现 Day 06:一个有边界的 agent 执行循环。

Day 06 agent loop flow

今天要解决什么

今天要完成四个交付:

  1. 让模型可以通过文本协议请求工具调用。
  2. 让 harness 解析工具调用并分发到 ToolRegistry
  3. 将工具结果回写进 messages,作为下一轮观察。
  4. 增加 maxSteps、工具 timeout 和工具错误回灌,避免无限循环。

Demo 命令:

npm run dev -- "inspect the repo and summarize the structure"

预期不再是单轮回答,而是多步执行:

Step 1 [model]: Model requested tool: list_files
Step 1 [tool]: Tool completed: list_files
Step 2 [model]: Model requested tool: read_file
Step 2 [tool]: Tool completed: read_file
Step 3 [final]: Model returned a final answer.

它在 Cursor/Codex 里对应哪一层

今天实现的是 agent runtime state machine

Cursor、Codex、Claude Code 这类工具的核心差异,不是“能不能调用模型”,而是 harness 如何管理模型和外部世界之间的往返:

User task
  -> system prompt + tool definitions
  -> model decision
  -> tool call
  -> tool result
  -> model decision
  -> final answer

在真实产品里,模型可能使用原生 function calling、MCP tool call、IDE API 或 shell sandbox。这个 mini harness 先使用一个最小文本协议:

TOOL_CALL {"name":"read_file","input":{"path":"package.json","maxBytes":2000}}

这样做的好处是实现足够透明:读者可以直接看到模型输出什么、harness 如何解析、工具结果如何进入下一轮上下文。

设计思路

1. 模型只负责决定,不直接执行

ModelProvider 仍然只有一个方法:

chat(messages): Promise<ChatMessage>

模型返回普通文本时,harness 把它视为 final answer。

模型返回 TOOL_CALL ... 时,harness 才会解析 JSON,并调用 registry:

const toolResult = await registry.dispatch(toolCall.name, toolCall.input);

这个边界很重要:模型可以提出行动意图,但实际权限、路径限制、输入校验和错误处理都在 harness 里。

2. 工具结果作为新的观察回灌

每次工具执行后,结果会被追加到 messages:

Tool result for list_files:
{ ... }
Continue the task.

下一轮模型看到的就不只是原始 user task,还包括刚刚观察到的 workspace 状态。

这就是 agent loop 和普通 chat loop 的根本区别:模型的下一次决策依赖真实工具结果,而不是只依赖自己上一轮生成的文本。

3. 循环必须有硬边界

如果没有边界,agent 很容易无限调用工具。Day 06 加了两个保护:

  • maxSteps:默认最多 6 轮模型决策。
  • toolTimeoutMs:默认单个工具最多等待 5 秒。

超出上限时,run summary 会返回:

Stopped after 6 steps without a final answer.

工具失败也不会直接让整个进程崩掉。harness 会把错误包装成 ToolResult.error,再回灌给模型,让模型决定是否换一种方式继续,或者带着限制结束。

4. 每一步都记录成 AgentStep

Day 06 新增 AgentStep

export type AgentStep = {
  index: number;
  kind: "model" | "tool" | "final" | "error";
  summary: string;
  durationMs: number;
  toolCall?: ToolCall;
  toolResult?: ToolResult;
};

这不是完整 transcript。完整 JSONL 日志会放到 Day 07。

今天只做 run 内部的结构化 step,让 CLI 可以打印执行过程,也让 --context-report 能看到 agent loop 的中间状态。

实现步骤

1. 扩展共享类型

文件:packages/shared/src/types.ts

ToolResult 新增可选 error 字段,AgentStep 记录每轮模型和工具状态。

2. 实现文本工具协议解析

文件:apps/mini-harness/src/agent/run.ts

协议刻意简单:

TOOL_CALL {"name":"tool_name","input":{}}

解析后得到:

type ToolCall = {
  name: string;
  input: unknown;
};

如果 assistant message 不以 TOOL_CALL 开头,就视为 final answer。

3. 实现 bounded loop

createRunSummary 从单轮 chat 变成循环:

for (let index = 1; index <= maxSteps; index += 1) {
  const assistantMessage = await provider.chat(messages);
  const toolCall = parseToolCall(assistantMessage.content);

  if (!toolCall) {
    finalMessage = assistantMessage.content;
    break;
  }

  const toolResult = await dispatchToolWithTimeout(...);
  messages.push(toolResultObservationMessage);
}

这就是今天的最小 agent runtime。

4. 更新 mock provider

文件:apps/mini-harness/src/model/provider.ts

为了没有 API key 时也能演示 loop,mock provider 增加了一个固定流程:

  1. 如果任务像是在检查仓库,先调用 list_files
  2. 看到 list_files 结果后,调用 read_file 读取 package.json
  3. 看到 read_file 结果后,输出 final answer。

这里还修了一个容易踩的坑:工具结果也是 user message。如果 mock 总是读取最后一条 user message,就会把工具结果误当成原始任务。现在它固定读取第一条 user message 作为 task。

5. 更新 CLI 输出

文件:apps/mini-harness/src/cli.ts

运行 demo 时,CLI 会打印每个 step:

[mini-harness] Step 1 [model]: Model requested tool: list_files
[mini-harness] Step 1 [tool]: Tool completed: list_files
[mini-harness] Step 2 [model]: Model requested tool: read_file
[mini-harness] Step 2 [tool]: Tool completed: read_file
[mini-harness] Step 3 [final]: Model returned a final answer.

Demo

See Day 06 demo.

核心命令:

npm run dev -- "inspect the repo and summarize the structure"

输出摘要:

[mini-harness] Model provider: mock
[mini-harness] Step 1 [model]: Model requested tool: list_files
[mini-harness] Step 1 [tool]: Tool completed: list_files
[mini-harness] Step 2 [model]: Model requested tool: read_file
[mini-harness] Step 2 [tool]: Tool completed: read_file
[mini-harness] Step 3 [final]: Model returned a final answer.

最终回答:

This repo is a TypeScript npm workspace for a mini coding agent harness.
It contains articles, demos, docs, daily logs, shared package types,
and the apps/mini-harness CLI implementation.

当前系统能力变化

到 Day 06,mini harness 已经从“能调用模型”和“能手动 dispatch 工具”,变成了一个真正的最小 agent:

  • 模型可以决定是否使用工具。
  • harness 可以执行工具并把结果反馈给模型。
  • 多轮观察和行动可以在一个 run 内完成。
  • 工具失败和超时可以被结构化记录。
  • CLI 可以显示 agent 的中间步骤。

这个版本仍然很小,但关键抽象已经出现了:模型不是 runtime,工具也不是 runtime,真正的 runtime 是管理它们往返的 agent loop。

遇到的问题

1. 文本协议不够稳

TOOL_CALL {...} 很适合教学,但真实系统更应该使用模型原生 tool call 或严格的结构化输出。文本 JSON 可能被模型多输出解释文字,也可能生成非法 JSON。

这个问题暂时接受,因为 Day 06 的目标是解释 loop,而不是绑定某个模型 API。

2. 工具结果会污染 user message

现在工具结果用 user role 回灌,这是为了复用当前最小 ChatMessage 类型。真实系统里最好有 tool role,或者在 provider adapter 层映射成对应模型 API 的 tool result message。

这个会在后续扩展模型协议时处理。

3. timeout 不能真正取消底层任务

当前 Promise.race 可以让 harness 不再等待,但不能中止已经开始的工具 handler。未来如果加入 shell command、网络请求或长任务,需要引入 AbortSignal

明天做什么

Day 07 会实现 transcript logging:把 run started、model response、tool call、tool result、final/error 逐行写成 JSONL。

有了 transcript,agent loop 就不只是“能跑”,还可以被审计、回放和 debug。

Comments

  • Loading comments…

Comments are posted immediately and emailed to the site owner. No account needed.