Practice Report · Coding Agent v1.83

从 0 实现 Coding Agent
一份按踩坑时间线写的实践报告

这份文档从 docs/practice 自动生成。它不是教程目录,而是把我目前的实现过程写成技术博客:问题是什么、官方/成熟工具怎么做、我现在怎么落地、关键代码在哪里、哪些还没补齐。

75 章 · 45 已完成 · 7 开发中 · 23 未开始 · 2026/5/3 20:20:36
01

开场

为什么要自己拆一个 Coding Agent。这一组不是功能清单,而是按“为什么做、怎么做、踩了什么坑、和 Claude Code / Codex 怎么对照”来写。

00 · Start · 已完成

我为什么要从 0 实现 Coding Agent

起因

我一开始不是想“复刻一个 Claude Code”。真正的起因更朴素:我每天都在用 Coding Agent,但越用越发现自己对它的边界不放心。

它什么时候该读文件,什么时候该改文件?为什么有时会重复调用同一个工具?为什么一个看似正常的 Bash 命令需要确认?为什么长对话后突然忘了前面的约束?这些问题如果只停留在使用层面,很容易变成玄学:这次表现好,是模型聪明;下次翻车,是模型不稳。

早期工程调研里有个公式,我觉得非常适合拿来当这份实践的总纲:

Agent 能力 = 上下文质量 × 工具可用性 × 循环效率

这不是加法,是乘法。上下文再好,没有工具就只能聊天;工具再多,循环效率低就会把 round 浪费在重复探索;循环再快,如果上下文乱了,模型会越跑越偏。

我拿 Claude Code 当参照,不当答案

Claude Code 给我的启发不是“它有哪些按钮”,而是它的产品定位很清楚:一个 terminal-native agentic coding system。它不是把聊天框搬到终端,而是让模型真的进入项目目录,读代码、改代码、跑命令,再根据结果继续行动。

Codex CLI 的启发则是另一面:approval mode、sandbox、代码审查和本地工作区的组合,说明 Coding Agent 的重点不是让模型更大胆,而是让它的每一步都可见、可控、可回滚。

nanobot 那份分析给我的提醒更克制:Agent 的本质其实就是循环、记忆和工具,其余都是边界上的扩展。它把复杂性放到渠道、skills、cron、memory 这些边缘模块里,核心 loop 保持很薄。这个判断让我没有一开始就把系统做成平台,而是先把最小闭环、基础工具和安全边界打牢。

第一版先做最小闭环

我没有先做插件市场、MCP、Agent Team 这些大东西,而是先问一个更小的问题:如果只有一个本地 CLI,它最少需要什么?

答案最后收敛成四个模块:

用户输入
  -> Agentic Loop
  -> Provider 调模型
  -> Tool 执行本地动作
  -> Tool Result 回到模型
  -> 最终回答

这条链路一旦跑通,很多问题就不再抽象了。比如工具描述写得差,模型真的会误用;Bash 不做确认,真的会让人不敢继续;read_file 没有截断,真的会把上下文撑爆;tool result 顺序不合法,OpenAI 兼容接口真的会 400。

我是怎么做的

我的实现没有一上来追求“平台化”。先把 CLI 的几件事做扎实:

  • mc "问题" 走单次模式,适合脚本和快速问答。
  • 不传 query 时进入 REPL,适合长任务。
  • Agent 内部维护消息历史,但 system prompt 每轮重新构建,不塞进历史。
  • 工具执行结果作为 tool 消息回传给模型,让模型继续判断下一步。
  • 危险命令走确认函数,单次模式默认拒绝,REPL 模式交给 UI 弹窗。

最核心的伪代码其实很短:

while (true) {
  response = await model(messages, tools)

  if (response.text) return response.text

  result = await runTool(response.toolCall)
  messages.push(toolResult(result))
}

但真正花时间的是“循环之外”的边界:工具结果要不要清理、消息历史是否合法、用户中断怎么传给工具、Bash 是否要确认、读写文件是否允许穿越工作目录。

这份文档为什么也要一起做

做到后面我发现,代码本身只能说明“现在怎么写”,很难说明“当时为什么这么写”。而 Coding Agent 的很多设计,恰恰是从踩坑里长出来的。

所以这份页面不是宣传页,也不是教程目录。它更像一份实践日志:每一章都应该回答四个问题。

当时的问题是什么?
我现在怎么实现?
Claude Code / Codex / nanobot 这些实现给了什么参照?
下一步还差什么?

现在真正写到完成稿的章节还会继续扩,但标准已经定下来了:不能只说功能,要把问题、实现、对照和代码都写出来。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
02

基础闭环

Agent loop、provider、prompt 与配置。这一组不是功能清单,而是按“为什么做、怎么做、踩了什么坑、和 Claude Code / Codex 怎么对照”来写。

01 · Foundation · 已完成

跑通最小 Agentic Loop

Agentic Loop 工具结果回灌流程

当时的问题

最小 Agentic Loop 容易被说得很玄,但我真正动手时发现,它就是一个朴素的问题:模型说“我要调用工具”之后,谁来真的执行?执行完以后,谁把结果再交回模型?

如果没有这层循环,模型只是生成文本。它可以建议你运行 npm test,但不会真的运行;它可以猜某个文件有问题,但不会打开文件验证。Coding Agent 的起点,就是把“建议”变成“观察、行动、再观察”。

Claude Code 的 Agent SDK 文档也把这个闭环拆成几步:收到 prompt,模型评估,执行工具,把结果回传,重复直到没有工具调用。这个描述看着简单,但实现时有两个细节很关键:工具调用轮次必须有限制,工具结果必须按协议回到模型。

我真正意识到 loop 不是玩具,是在 v1.5 的一次日志里。那次任务只是“分析工具模块并给出优化方案”,结果 20 轮工具调用耗尽时,模型还在读代码,一行修改都没开始写:

round=0   todo_write
round=1   bash: ls -la
round=2   bash: ls src
round=3   read_file: src/tools.ts
...
round=20  WARN: approaching max tool rounds

这个日志很刺眼:Agent Loop 能跑,不代表跑得有效率。没有搜索策略、并行工具、round 预算和循环检测,它会把“自主探索”变成“逐文件散步”。

我是怎么做的

我把循环放在 Agent.run() 里。每次用户输入进来,先构建 system prompt,再把用户消息追加到历史,然后进入循环。

核心结构大概是这样:

async function run(userInput) {
  systemPrompt = await buildSystemPrompt(toolNames, userInput)
  messages.push({ role: 'user', content: userInput })

  while (true) {
    response = await provider.chat([systemPrompt, ...messages], tools)

    if (response.type === 'text') {
      messages.push(assistantText(response.text))
      return response.text
    }

    messages.push(assistantToolCall(response))
    result = await executeTool(response.name, response.params)
    messages.push(toolResult(response.id, result))
  }
}

第一版跑通以后,我很快加了几个护栏。

一是 maxToolRounds。开放式任务很容易让模型一直读、一直搜、一直试,所以循环不能无上限。

toolRounds++
if (toolRounds > maxToolRounds) {
  throw new Error('超过最大工具调用轮次')
}

二是循环检测。连续几次用同一个工具、同一组参数,还都失败,基本就是卡住了。这时继续烧 token 没意义,应该把错误暴露出来。

if (sameToolSameParamsAndError(last3Calls)) {
  throw new Error('检测到工具调用死循环')
}

三是每轮发给模型前做消息合法性修剪。这个坑后面单独写了一章:OpenAI 兼容接口不接受“孤儿 tool 消息”,所以历史截断不能只按数量切。

一个实际回合长什么样

比如用户说“帮我看测试为什么失败”,理想的循环不是一次回答完,而是这样的:

Turn 1: 模型调用 bash -> npm test
Turn 2: 根据报错调用 read_file -> 打开失败文件
Turn 3: 调用 edit_file -> 修改代码
Turn 4: 再调用 bash -> 验证测试
Turn 5: 没有 tool_call -> 总结结果

这也是我后来理解 Agent 的关键:不是模型一次想得多完美,而是每次行动后都能拿到真实反馈。

现在还差什么

我现在的 loop 已经能跑完整任务,但离成熟系统还有距离。

Claude Code SDK 有更完整的事件类型、cost、session id、compact boundary、hook event;我的实现目前更像“能工作的内核”。下一步要补的是更清晰的运行观测:每一轮为什么调用某个工具、用了多少 token、是否触发压缩、是否被权限系统拦截,都应该能在 UI 和日志里看见。

旧文档里我写过一句现在看仍然成立的话:MAX_TOOL_ROUNDS 不是越大越好。轮次上限只是安全阀,真正的改进是减少无效轮次。比如先 glob/grep 建地图,再读关键文件;独立只读工具并行;大任务交给 explore 子代理。否则把上限从 20 调到 50,只是让模型更久地低效。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
02 · Foundation · 已完成

OpenAI-compatible Provider 与 SSE

SSE 文本与 tool_calls 聚合流程

为什么先做 OpenAI-compatible

我没有一开始就写多个 provider。原因很现实:本地 Agent 的难点不在“多接几个模型 API”,而在工具循环、上下文和权限。先用 OpenAI-compatible 协议跑通,可以把模型通信层压到一个稳定接口上。

这条路的好处是明显的:OpenAI、Azure OpenAI、本地 Ollama、很多兼容网关都能用类似的 /chat/completions 形态。代价是也要处理各种“看似兼容但细节不一样”的地方,比如 SSE 里 usage 出现的位置、tool_calls 参数分片、reasoning 字段命名。

我是怎么做的

Provider 对 Agent 暴露的接口很小:

interface Provider {
  chat(messages, tools, onChunk, signal): Promise<LLMResponse>
}

Agent 不关心底层是 OpenAI、Azure 还是本地模型,它只关心返回的是三类结果之一:

type LLMResponse =
  | { type: 'text'; text: string }
  | { type: 'tool_call'; id: string; name: string; params: unknown }
  | { type: 'tool_calls'; calls: ToolCallItem[] }

这样做的好处是 Agentic Loop 可以保持稳定。以后换 provider,最好只改协议适配,不改循环。

SSE 里最容易踩的坑

流式响应不是一整块 JSON,而是一段一段 delta。文本可以直接累加,工具调用却不能。

模型可能这样分片返回参数:

delta.tool_calls[0].function.arguments = "{\"path\":"
delta.tool_calls[0].function.arguments = "\"src/agent.ts\"}"

所以我用 index -> ToolCallState 做聚合:

const toolCallMap = new Map<number, ToolCallState>()

for (const tc of delta.tool_calls) {
  state.id ||= tc.id
  state.name ||= tc.function?.name
  state.args += tc.function?.arguments ?? ''
}

等 stream 结束后,再统一 JSON.parse(state.args)。这个设计后来直接支撑了多工具并行章节。

还有一个小坑是 usage。某些 OpenAI-compatible 服务会在最后一条 SSE 里返回 usage,但 choices 是空数组。如果代码先判断 choices.length === 0 就 return,会把 usage 丢掉。

if (parsed.usage) {
  usage = normalizeUsage(parsed.usage)
}

if (!choices || choices.length === 0) return

这个顺序看起来小,但影响 StatusBar 的 token 统计。

官方做法给我的参照

Claude Code SDK 的事件模型更完整,它会把 SystemMessage、AssistantMessage、UserMessage、ResultMessage、compact boundary 等事件显式暴露出来。我的实现现在还是压缩成 text / tool_call / tool_calls 三类,适合第一版 CLI,但表达力不够。

Codex CLI 的思路也类似:模型通信只是底层能力,真正重要的是把 approval、sandbox、工具执行和最终结果统一进一个可控 loop。

现在还差什么

Provider 现在够用,但还不够“工程化”:

  • 没有完整 provider 抽象测试矩阵。
  • Anthropic 原生消息协议还没实现。
  • tool call 参数解析失败时,只返回文本错误,没有让模型自动修正参数。
  • SSE 事件没有完整透传给 UI,用户还看不到每个阶段的实时结构化事件。

这章后面的方向不是再加几个 if,而是把 Provider 从“能调模型”升级成“能稳定承载 Agent 运行时事件”。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
03 · Foundation · 已完成

tool_calls 聚合与多工具并行

多工具并行与串行执行分区

问题是怎么冒出来的

一开始我只处理单个 tool call。模型要读文件,就执行一个 read_file;要跑命令,就执行一个 bash。这能跑,但很快会遇到一个问题:模型会在同一轮里要求多个工具。

比如它想同时读两个文件:

read_file({ path: "src/agent.ts" })
read_file({ path: "src/provider.ts" })

如果 provider 只取第一个 tool call,第二个会被静默丢掉。模型以为自己请求了两个观察,实际只收到一个结果,下一轮判断就会偏。

这个问题在 v1.51 前尤其明显。模型本来可以在一轮里做“更新 todo + 读多个文件”,但 provider 只执行第一个 tool call。结果就是 todo_writeread_file 抢 round,上限还没到真正修改阶段就被耗光。

我是怎么做的

我把 provider 的返回值从单个 tool_call 扩成两种:

type LLMResponse =
  | { type: 'tool_call'; id; name; params }
  | { type: 'tool_calls'; calls: ToolCallItem[] }

SSE 聚合时按 index 保存每个工具调用的状态。结束后如果 toolCallMap.size > 1,就返回 tool_calls

const calls = [...toolCallMap.entries()]
  .sort(([a], [b]) => a - b)
  .map(([, state]) => ({
    id: state.id,
    name: state.name,
    params: JSON.parse(state.args || '{}'),
  }))

Agent 收到多个工具后,不是简单 Promise.all。我做了一个很保守的分组:

const READ_ONLY_TOOLS = new Set(['glob', 'grep', 'read_file'])

readCalls = calls.filter(c => READ_ONLY_TOOLS.has(c.name))
writeCalls = calls.filter(c => !READ_ONLY_TOOLS.has(c.name))

readResults = await Promise.all(readCalls.map(run))
for (const call of writeCalls) {
  writeResults.push(await run(call))
}

读操作并发,写操作串行。这个取舍很朴素:读文件、搜索代码天然可以并发;写文件、跑 Bash、改状态的工具如果并发,冲突成本比省下的时间高。

为什么结果顺序也重要

并发以后还有一个隐蔽问题:结果完成顺序不等于模型请求顺序。

模型发出的 assistant 消息里有多个 tool_calls,后面必须用对应的 tool_call_id 回传结果。为了让历史更稳定,我执行后按原始 call 顺序重新排序:

const callIndexMap = new Map(calls.map((c, i) => [c.id, i]))

allResults.sort((a, b) =>
  callIndexMap.get(a.c.id) - callIndexMap.get(b.c.id)
)

这个细节后来也影响了“孤儿 tool 消息”那章。工具消息不是普通日志,它是协议的一部分。

和 Claude Code 的差距

Claude Code SDK 文档里也提到类似策略:只读工具可以并行,修改状态的工具要避免冲突。它还能通过工具 annotation 判断 read-only,而我现在是手写一个工具名集合。

旧的效率分析文档里,我对照过 Claude Code 的工具编排思路:它不是简单把所有工具丢进 Promise.all,而是先按并发安全性分区。读取类工具可以一起跑,写文件、Bash、状态更新这类工具要串行。这个差别很重要,因为 Coding Agent 的并行不是为了炫技,而是为了减少等待,同时不制造文件冲突。

这意味着我的实现还不够可扩展。以后 MCP 工具接进来以后,光靠工具名判断就不够了,应该让工具自己声明:

{
  name: 'grep',
  annotations: { readOnly: true }
}

现在还差什么

这章的下一步是把“并发策略”从 Agent 里抽出去。Agent 不应该知道哪个工具只读,应该由工具注册表或权限系统告诉它:哪些可以并行,哪些必须串行,哪些需要锁文件,哪些需要用户确认。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
04 · Foundation · 已完成

System Prompt 与五分区上下文

System Prompt 静态区与动态区分界

当时的问题

System Prompt 很容易被写成一大坨“你是一个有帮助的助手”。但 Coding Agent 不一样,它要在真实项目里行动,prompt 里必须告诉它:你是谁、能用什么工具、当前在哪里、有哪些长期记忆、输出应该怎么收。

我踩到的第一个问题是:如果把所有内容无脑拼在一起,模型能读,但边界不清楚。工具策略、环境信息、记忆内容混在一起,后面想调试某个行为时,很难判断是哪一段 prompt 影响了它。

早期工程调研里把上下文分成五个 Zone,我后来发现这不是“文档分层”,而是成本、缓存、准确性三件事的共同边界。固定内容越稳定,越适合缓存;动态内容越靠后,越不容易污染前缀;记忆和对话如果混在一起,长会话就会越来越难清理。

我是怎么做的

现在的构建逻辑在 buildSystemPrompt() 里,大概是这样:

const parts = [
  introAndSafety,
  taskGuidance,
  outputStyle,
  toolsSection,
  envSection,
  memorySection,
]

return parts.filter(Boolean).join('

')

我把它理解成五个上下文分区:

Zone 1: 身份、安全行为、任务执行指南、输出风格
Zone 2: 工具列表和工具使用策略
Zone 3: 当前运行环境 cwd / git / platform / shell
Zone 4: 和本次 query 相关的长期记忆
Zone 5: 对话历史和工具结果,由 Agent 在每轮请求时追加

其中前四区是 system prompt 构建出来的,第五区是 loop 里的消息历史。

为什么环境信息要动态生成

Claude Code / Codex 这类本地 Agent 都很重视“我在哪里运行”。模型如果不知道 cwd、git 状态、shell,很容易给出脱离项目的建议。

我的第一版只放了几个最实用的信息:

Working directory: /path/to/project
Is directory a git repo: Yes
Platform: darwin
Shell: /bin/zsh
OS Version: ...

这些信息不花太多 token,但能显著减少模型问“你的项目目录在哪里”这类废话。

这里我刻意没有放目录树、完整 git status、完整文件树。一个原则是:只注入模型无法自己获取、且足够稳定的信息。目录树和 git status 都可以用工具实时查,塞进 system prompt 反而可能过期。

工具策略不能只靠工具 description

工具本身有 description,但我还是单独加了 TOOL_USAGE_GUIDANCE。原因是工具 description 主要说明“这个工具是什么”,而工具策略要说明“什么时候该用,什么时候别用”。

比如:

修改前先 read_file
搜索优先 grep/glob
危险命令需要确认
输出过长要截断续读

这类策略如果散在每个工具描述里,模型会读到,但不容易形成全局行为习惯。

工程调研里提到“工具区可以渐进式发现”:初始只给摘要,需要时再加载详情。我现在还没做到渐进式工具描述,但已经把签名和策略拆开了。下一步如果工具继续增多,就不能把所有 MCP tool、skills、hook 规则一次性塞进 prompt。

记忆为什么放在 system prompt

长期记忆不是每次都全量塞进去,而是按当前 query 搜索相关条目:

const matched = searchMemory(query, 5)
const entries = matched.map(readMemoryEntry)

这个做法比较粗糙,但方向是对的:记忆应该是“按需注入”,不是把过去所有东西都带进上下文。

现在还差什么

现在的五分区已经比一坨 prompt 好调试,但还缺几个东西:

  • 每个区的 token 成本统计。
  • prompt 版本号,方便复盘某次行为是哪个版本造成的。
  • 明确缓存边界,把稳定前缀和动态区从结构上分开。
  • 渐进式工具发现,避免工具越多 system prompt 越胖。
  • 更强的记忆召回质量,不只是关键词匹配。

后面如果要支持 skills、hooks、项目级规则,system prompt 还要继续模块化,否则它会很快重新变成一堵文字墙。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
05 · Foundation · 已完成

项目指令 / Agent Manifest / AGENTS.md

为什么需要项目指令

我刚开始做 Agent 时,所有规则都写在 system prompt 里。很快就发现不够用:不同项目的技术栈、测试命令、目录结构、禁忌操作都不一样。如果这些全靠用户每次重复说,Agent 会很累,用户也会很烦。

项目指令的价值就在这里:把“这个项目怎么工作”变成默认上下文。

Claude Code 里的 CLAUDE.md、Codex 里的 AGENTS.md,本质上都是这个思路。它们不是普通 README,而是写给 Agent 的运行说明。

我是怎么做的

目前项目级指令还没有做成完整的多层扫描系统,但我已经把这个方向写进了 system prompt 的结构里:稳定规则属于上下文的一部分,不应该混在临时用户消息里。

一份好用的项目指令,通常要覆盖这些内容:

项目是什么
怎么安装、怎么测试、怎么启动
关键目录在哪里
哪些命令危险
哪些文件不要动
UI / 测试 / Git 的项目约定
文档更新规则

这次页面本身也已经吃到了这个机制的好处。AGENTS.md 里明确写了:

docs/ 目录下所有文件使用中文命名
修改版本行为或架构时检查 README / release notes / package.json
涉及 UI 行为要覆盖 tests/ui/*

这些规则如果不进入 Agent 上下文,它就很容易写出“看起来能用但不符合项目习惯”的改动。

我现在怎么看 AGENTS.md

我不把它当“约束清单”,而是当项目的第二份架构文档。README 是给人快速了解项目的,AGENTS.md 是给 Agent 安全动手的。

比如对这个项目来说,最关键的不是“TypeScript + Ink”这几个标签,而是这些行为规则:

Tool.execute 第二参数是 ToolContext
BashTool 用 createBashTool 工厂闭包
Glob/Grep 用 callback exec 兼容 vi.mock
compact 必须 trimToLegalStart
MessageList 用 Ink Static,已打印消息不能修改

这些都是踩坑后留下来的项目记忆。它们写在代码注释里不一定会被模型读到,写进项目指令才更稳定。

和 Claude Code / Codex 的对照

Claude Code 的 CLAUDE.md 更强调项目规则和上下文继承;Codex 的 AGENTS.md 更强调执行约束、测试命令和安全边界。我的实现目前还在早期阶段,但文档结构已经按这个方向组织。

以后真正完善时,我希望项目指令进入这样的加载顺序:

全局规则
  -> 用户级偏好
  -> 项目 AGENTS.md
  -> 子目录 AGENTS.md
  -> 本轮用户请求

越靠后的规则越具体,但不能越过安全底线。

现在还差什么

现在还缺自动扫描和冲突处理。比如子目录里有自己的 AGENTS.md 时,应该如何覆盖根目录规则?规则冲突时是提醒用户,还是按更近目录优先?这些都还没有完整实现。

但方向已经明确:项目指令不是锦上添花,它是 Coding Agent 能不能长期稳定工作的基础设施。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
06 · Foundation · 已完成

配置文件、多模型、单次命令模式

为什么配置一开始就要做

Agent CLI 如果只能写死一个模型,很快就会卡住。不同任务需要不同模型:日常小改动要快,复杂重构要强,本地实验可能走 Ollama,公司环境可能走兼容网关。

所以我比较早就把配置和模型列表拆开了。不是为了做一个复杂设置页,而是为了让运行时不要被某个 provider 锁死。

我是怎么做的

配置读取走三层优先级:

环境变量
  > 当前项目 .mc/config.json
  > 全局 ~/.mc/config.json
  > 内置默认值

代码里的形态很直接:

const globalConfig = readConfigFile('~/.mc/config.json')
const localConfig = readConfigFile('.mc/config.json')
const merged = { ...globalConfig, ...localConfig }

model = process.env.MC_MODEL ?? merged.model ?? DEFAULTS.model

模型列表则单独放在 models.json 思路里。默认模型可以覆盖 config 里的 model / apiKey / baseUrl,这样 UI 里切模型时,不需要用户手动改环境变量。

const marked = models.find(m => m.default === true)
const fallback = models.find(m => m.type !== 'text-to-image')

单次模式为什么默认拒绝危险命令

CLI 有两种运行方式:

mc
mc "帮我解释这个报错"

REPL 模式可以弹确认框,用户就在现场;单次模式常常用于脚本或管道,没人能及时按确认。所以我给单次模式的危险命令确认函数做成默认拒绝:

const confirm = async (message: string) => {
  process.stderr.write(`单次模式跳过危险命令: ${message}\n`)
  return 'no'
}

这就是一个很典型的 Agent 取舍:自动化越强,默认权限越要保守。

多模型不是简单下拉框

模型切换看起来是 UI 功能,实际会影响整条运行链路:

  • 是否支持 reasoning。
  • 是否返回 reasoning_content
  • 是否支持 tool_calls。
  • 是否会把 usage 放在最后一条 SSE。
  • 是否需要 Azure 的 api-version
  • 是否使用自定义 headers。

所以我把配置对象尽量保留这些差异:

{
  model,
  baseUrl,
  apiVersion,
  reasoningEffort,
  thinkingEnabled,
  headers,
}

这比只存一个模型名麻烦,但后面接企业网关、本地模型、生图模型时会少很多硬编码。

现在还差什么

配置系统现在能用,但还不够像成熟 CLI:

  • 缺少 mc config set/get 这样的命令。
  • 缺少配置诊断,用户配错 baseUrl 时只能看到 API 错误。
  • 多模型能力没有做 capability detection。
  • 敏感字段还没有专门的加密或 keychain 集成。

Claude Code / Codex 这类工具的配置体验更完整:认证、权限、模型、沙箱、项目规则是联动的。我的下一步也应该从“读取配置”走向“配置可诊断、可迁移、可解释”。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
03

工具系统

读写文件、bash、搜索与编辑。这一组不是功能清单,而是按“为什么做、怎么做、踩了什么坑、和 Claude Code / Codex 怎么对照”来写。

07 · Tools · 已完成

基础工具 read / write / bash

基础工具 read write bash 风险分层

为什么需要它

工具层的问题很具体:读文件要不要截断,写文件要不要先读,bash 输出太长怎么办,路径越界怎么办,失败时要不要给模型下一步建议。

官方做法

参考的那篇文件工具分析把 Read/Edit/Write 拆成风险分级,而不是简单的功能分类。Read 只读,Edit/Write 涉及写权限;Read 还要处理去重、多格式、安全门和路径建议。这种写法很适合我现在的工具章节。

我是怎么做的

我读了当前实现后,这一章可以概括成三条边界。

  • read_file 是可控读取:支持 offset/limit,超出最大行数会做 head/tail 截断,并在结果里提示下一次该从哪里续读。它的价值是替代 bash cat 这种不可控读取。
  • write_file 是全量写入:负责创建或覆盖文件,父目录不存在时自动创建。它没有假装自己适合局部修改,所以描述里明确提醒局部修改优先走 edit_file
  • bash 是执行兜底:先做危险命令识别,再走确认函数;执行时有超时,输出会截断,失败时把 stdout/stderr 一起整理给模型。

这三个工具已经能支撑最小开发闭环,但如果对照 Claude Code 那类成熟文件工具,差距其实不只是“功能少”,而是少了一整层工程保护。

Read 去重解决的是重复读取污染上下文的问题。模型经常会在不确定时反复读同一个文件,如果工具每次都把完整内容塞回历史,长对话很快被重复文本淹没。更成熟的做法会记录已读文件、读取范围和文件版本:同样内容重复读取时,可以返回“已读取过”的提示,或者只返回变化片段。我的 read_file 目前只做截断,不知道这份内容是不是已经给过模型。

多格式读取解决的是“文件不是纯文本”的问题。真实项目里会遇到图片、PDF、Notebook、二进制、超大日志、压缩包。Claude Code 这类工具通常会按类型分流:文本走普通 Read,图片走多模态输入,二进制直接拒绝或提示用专门工具。我的实现现在默认按 UTF-8 文本读取,读到不适合的文件时只能靠异常返回,体验和安全性都比较粗。

设备文件屏蔽和二进制拒绝是安全边界,不是体验优化。像 /dev/zero、管道、socket、超大二进制这类路径,一旦被普通 read 当成文本处理,轻则卡住,重则把进程拖死。成熟实现会先判断文件类型、大小、是否 regular file,再决定能不能读。我的 safePath 主要处理路径穿越,还没有做文件类型级别的保护。

路径建议解决的是 Agent 失败后的下一步。现在我读不到文件时会提示用 bash ls 确认路径,这已经比单纯抛错好一点;但更好的工具会基于当前目录、相近文件名、大小写差异、扩展名误差给出候选路径。这样模型不用盲目 grep 或 ls 一大片目录,下一轮能更快收敛。

写入前快照解决的是可回滚问题。write_file 现在可以覆盖文件,但覆盖前没有保存旧内容。如果模型写错了,只能靠 git diff 或用户记忆找回。成熟实现会在写入前记录原文件内容、mtime、hash,至少能在本轮会话里恢复,或者给 UI 展示完整 diff。

mtime 校验解决的是并发修改问题。Agent 读取文件后,用户或另一个工具可能已经改过它。如果这时继续按旧上下文写入,就会覆盖别人的新改动。更稳的流程是:Read 时记录 mtime/hash,Edit/Write 前检查文件是否变化;变了就拒绝写入,要求重新读取。我的 write_file 现在还没有这个保护。

LSP 通知解决的是“写完之后怎么知道写坏了”。成熟 coding agent 往往会把文件变更通知语言服务或诊断系统,马上拿到 TypeScript、ESLint、编译错误,再把错误反馈给模型。我的版本目前只能依赖后续手动跑 tsc / test / lint,还没有把写入动作和诊断闭环接起来。

所以这里的差距可以总结成一句话:我现在实现的是“工具能执行”,Claude Code 那类实现追求的是“工具执行后仍然可控、可回滚、可诊断”。下一步如果继续补文件工具,我会优先补 mtime/hash、写入前快照和二进制/设备文件拒绝,因为这三项最直接影响安全性。

下一步补齐

当前版本能覆盖基本场景,但还没有达到官方工具那种完整度。后续要补的是更细的权限策略、更好的失败恢复、以及和 UI / session / 验证链路的联动。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
08 · Tools · 已完成

edit_file 与精确代码修改

为什么不能只靠 write_file

最早有 write_file 以后,我以为改文件就够了。后来发现不行。

让模型每次修改都重写整个文件,风险太高:一个没读全、一个格式化差异、一个上下文截断,都可能把用户原来的内容覆盖掉。对 Coding Agent 来说,“精确修改”比“能写文件”更重要。

旧的踩坑总结里还有一个和写文件相关的细节:write_file 写入不存在的父目录时直接 ENOENT。后来对照 Claude Code 的 FileWriteTool,确认成熟工具会在写入前创建父目录。这个经验也影响了我对 edit_file 的看法:文件工具应该替模型挡住底层文件系统细节,不应该把“目录是否存在、匹配是否唯一、写入是否冲突”这些问题全部泄漏给模型。

所以我加了 edit_file。它不接受“我要怎么改”的自然语言,只接受三件事:

{
  path: 'src/agent.ts',
  old_text: '原文片段',
  new_text: '替换后的片段'
}

我是怎么做的

实现故意保持保守:

content = await readFile(path)

if (!content.includes(old_text)) {
  return 'Error: 未找到要替换的内容'
}

newContent = content.replace(old_text, new_text)
await writeFile(path, newContent)

它只做第一次精确替换,不做模糊匹配,也不猜缩进。old_text 必须完全一致,包括换行、空格、缩进。

这听起来麻烦,但对 Agent 是好事。匹配失败时,模型会被迫重新读文件确认当前内容,而不是靠猜继续改。

返回结果为什么要带上下文

修改成功后,我没有只返回“OK”,而是返回替换位置前后几行:

return `已完成替换: ${filePath}
替换后内容(第 ${start}-${end} 行):
${contextLines}`

这是一个小设计,但很有用。模型下一轮能看到自己到底改成了什么,减少重复编辑和幻觉总结。

比如它把一个 import 改错了,下一轮至少能看到修改片段,而不是只知道“工具成功了”。

和 Claude Code 文件编辑的差距

Claude Code 的文件编辑体验比我现在完整很多。它通常会围绕读取、编辑、diff、权限、快照、LSP 诊断形成一条链。

我的 edit_file 现在只解决了一个核心点:不要全文覆盖,必须精确命中。

但还缺这些能力:

  • 修改前快照,失败时能回滚。
  • mtime/hash 校验,避免覆盖用户刚刚改过的文件。
  • 多处匹配时让模型或用户选择,而不是只替换第一处。
  • 编辑后触发 LSP / TypeScript diagnostics。
  • 把 diff 作为结构化结果返回给 UI。

一个更成熟的伪代码

我希望下一版更接近这样:

before = stat(path)
content = read(path)
snapshot = saveSnapshot(path, content)

match = findExactMatch(content, oldText)
if (!match) return explainMismatch()

write(path, replace(content, match, newText))

after = stat(path)
if (before.mtimeChangedByOthers) rollback(snapshot)

diagnostics = notifyLsp(path)
return { diff, diagnostics, snapshotId }

现在的实现还没到这里,但第一步是对的:让 Agent 习惯“小范围、可验证”的编辑,而不是拿全文写入当万能锤子。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
09 · Tools · 已完成

glob / grep 与代码探索效率

为什么搜索工具很早就要做

Agent 写代码前必须先理解代码。只靠 read_file 会让模型像摸黑翻书:它不知道文件在哪里,不知道某个函数被谁调用,也不知道项目里有没有类似实现。

所以 globgrep 是工具系统里的基础设施。它们不直接修改项目,但会决定模型能不能快速建立地图。

这不是事后想出来的漂亮设计。v1.5 那次效率分析里,我看到模型一轮一轮读文件:BashTool.tsReadFileTool.tsWriteFileTool.tsEditFileTool.tsTodoWriteTool.ts……每个文件一个 round。等它终于理解结构,round 预算已经快见底。那次之后我才真正把“先搜索,再阅读”写进工具策略。

我是怎么做的

我没有自己遍历文件系统,而是直接包了一层 ripgrep

glob 用:

rg --files --glob "src/**/*.ts"

grep 用:

rg -n -C 3 --glob "*.ts" "pattern" src

这样做很现实:rg 足够快,默认尊重 .gitignore,对代码库搜索比自己手写递归靠谱得多。

工具参数也控制得比较少:

grep({
  pattern: 'function run',
  path: 'src',
  glob: '*.ts',
  '-C': 3,
  '-i': false,
})

不暴露太多开关,是为了避免模型把搜索命令写成另一个 Bash。

截断是必要的,不是体验缩水

搜索最容易把上下文撑爆。比如搜一个常见变量名,可能返回几千行。对模型来说,这些结果不是越多越好,超过一定量之后只会变成噪音。

所以我给两个工具都做了截断:

if (lines.length > MAX_RESULTS) {
  return first100 + `仅显示前 100 个`
}

grep 还会提示模型缩小范围:

使用 path 或 glob 参数缩小搜索范围,或加 -C 0 减少上下文行数

这句话不是给用户看的,更多是给下一轮模型看的。工具结果应该教模型修正搜索策略。

外部进程保护也是同一个问题的另一面。旧的踩坑总结里有过一次 grep 看似卡住的排查,最后给 exec() 补了 maxBuffertimeout

exec(cmd, { maxBuffer: 10 * 1024 * 1024, timeout: 30_000 }, callback)

这不是“更安全一点”,而是调用外部进程的底线。Agent 不能因为一次搜索输出过多、命令挂住、等待 stdin,就把整个任务拖死。

rg 不存在时怎么办

这里也有一个工程细节:rg 没安装时,不能让工具直接抛一堆 shell 错误。我的处理是把 command not found 归一成可读提示:

Error: 需要安装 ripgrep 才能使用 grep 工具。
macOS: brew install ripgrep
Linux: apt install ripgrep

这比原始 stderr 更适合进入模型上下文。模型看到后可以告诉用户缺依赖,而不是继续尝试同一个失败命令。

和 Claude Code 的对照

Claude Code 也把 Glob / Grep 放在内置工具里,这是很合理的分层:搜索工具比 Bash 更可控,比 read_file 更高效。

成熟实现还会继续做几件事:

  • 根据结果数量自动建议更窄的 glob。
  • 把搜索结果按文件聚合,而不是纯文本输出。
  • 和 IDE/LSP 索引结合,区分定义、引用、文本匹配。
  • 对大仓库做缓存,避免重复搜索。

现在还差什么

我现在的实现还是“rg 包装器”,优点是简单可靠,缺点是结构化程度不够。下一步可以把结果变成结构化数组:

{
  file: 'src/agent.ts',
  line: 120,
  text: 'async run(...)',
  before: [],
  after: []
}

这样 UI 可以做更好的折叠展示,模型也更容易按文件路径继续 read。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
10 · Tools · 已完成

工具描述质量与截断续读指引

工具描述不是注释

我以前容易低估 tool description。觉得只要参数 schema 对,模型自然会用。实际不是。

对 Agent 来说,工具描述就是“行动手册”。它会影响模型什么时候读文件、什么时候写文件、什么时候用 grep、什么时候该停下来问用户。

早期工程调研把工具系统拆成四个目标:可调用性、可靠性、安全性、可扩展性。这个拆法很朴素,但很实用。工具不是“函数暴露给模型”就完了,它还要让模型准确理解用途,失败时知道怎么恢复,危险时知道该停。

我踩过的坑

这点在子代理工具上踩得最明显。v1.6 时 agent 工具已经注册成功,schema 也进了模型上下文,但模型 21 轮都在自己 glob/read_file/grep,一次都没调用 agent。

问题不在注册链路,而在描述太弱:没有 whenToUse、没有 whenNot、没有示例,也没有“超过 2-3 个文件就用 explore”的命令式决策树。

我是怎么做的

我把工具描述分成两层。

第一层写在每个工具里,说明这个工具自己的边界:

edit_file:
- old_text 必须与文件内容完全匹配
- old_text 不存在时返回 Error,文件不变
- 相对路径不能穿越 cwd

第二层写在统一的工具使用指南里,说明工具之间怎么配合:

修改前先读取文件
搜索优先 glob / grep
现有文件局部修改优先 edit_file
创建或全文重写才用 write_file

这两层不要混在一起。工具 description 解决“这个工具怎么用”,全局 guidance 解决“这类任务应该走什么流程”。

截断提示要写给下一轮模型

工具输出一旦太长,就必须截断。但截断不能只写:

output truncated

这样模型不知道下一步怎么办。更好的结果应该带续读指引:

[注意:仅显示前 200 行,共 1200 行匹配。
使用 path 或 glob 参数缩小搜索范围,或加 -C 0 减少上下文行数]

这类提示本质上是给模型的“纠偏信号”。它看到后应该缩小范围,而不是继续要求同一个大输出。

我现在的实践规则

我给工具描述定了几个朴素标准:

1. 说明适用场景,不只说明功能。
2. 明确危险边界,比如路径、覆盖、执行命令。
3. 失败消息必须告诉模型下一步怎么恢复。
4. 长输出必须告诉模型怎么缩小范围。
5. 能用结构化参数,就不要让模型拼 shell。
6. 新工具必须写 whenToUse / whenNot / examples。

后面工具多起来以后,我会把“渐进式工具发现”补进来:初始只给内置工具和能力摘要,需要时再加载 MCP tool / skill 的详情。否则工具越多,系统提示词越胖,模型反而更难选对工具。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
11 · Tools · 已完成

工具目录化与依赖注入

为什么工具要目录化

工具一开始少的时候,放一个文件里最省事。但 Agent 工具会很快膨胀:读文件、写文件、编辑、Bash、搜索、Todo、子代理、图片生成、以后还有 MCP。

如果所有工具都堆在一起,两个问题会同时出现:工具描述难维护,依赖关系也会变乱。

旧的系统提示词分析里有个观点也适用于代码结构:工具签名和工具使用策略最好分离。签名要精确,策略要能讲场景。映射到代码里,就是工具实现、工具 prompt、工具常量、工具注册不要搅在一个文件里。

所以我把工具按能力拆目录:

tools/
  bash/
  read-file/
  write-file/
  edit-file/
  glob/
  grep/
  todo/
  agent/

每个目录负责自己的实现、常量和 prompt。tools/index.ts 只负责注册。

我是怎么做的

工具注册现在是一个工厂函数:

export function createTools(deps?: CreateToolsDeps): Tool[] {
  const rawTools = [
    ReadFileTool,
    WriteFileTool,
    EditFileTool,
    BashTool,
    TodoWriteTool,
    GlobTool,
    GrepTool,
  ]

  if (deps?.provider) {
    rawTools.push(createAgentTool(deps.provider, parentMap))
  }

  return rawTools.map(wrapWithLogger)
}

普通工具不需要外部依赖,直接注册。子代理工具需要 provider 和父工具表,所以通过 deps 注入。

这也是我后来做 DI 容器的原因:Agent、Provider、ToolMap、GlobalState 的生命周期不一样,不能全靠模块级单例。

为什么不把 confirmFn 注入工具表

Bash 的危险命令确认最开始很容易写成工具自己的依赖。但我后来把 confirmFn 放进 ToolContext,由 Agent 执行工具时传进去。

这样分层更清楚:

Tool 定义:我能做什么
ToolContext:这次运行允许我访问什么
Agent:决定什么时候执行工具
UI:决定怎么和用户确认

同一个 BashTool,在 REPL 里可以弹确认框,在单次模式里可以直接拒绝,在测试里可以注入假的确认函数。工具本身不用知道自己运行在哪种界面里。

和 Claude Code / Codex 的对照

Claude Code 的工具系统更像一个完整平台:内置工具、MCP 工具、Skill 工具、Agent 工具都进入统一调度和权限系统。

我的实现还只是轻量版本,但目录化和依赖注入是同一个方向:工具不应该是散落的函数,而应该是可注册、可描述、可授权、可观测的能力单元。

现在还差什么

现在工具注册还缺几个字段:

{
  name,
  description,
  parameters,
  annotations: {
    readOnly: true,
    destructive: false,
    requiresApproval: false,
  }
}

有了这些 metadata,Agent 才能自动判断并发策略、权限策略、UI 展示方式。否则每次新增工具,都要在 Agent 里继续写硬编码分支。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
12 · Tools · 未开始

WebFetch / WebSearch 工具

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
04

安全边界

危险命令、路径、权限与验证。这一组不是功能清单,而是按“为什么做、怎么做、踩了什么坑、和 Claude Code / Codex 怎么对照”来写。

13 · Safety · 已完成

Bash 危险命令检测

Bash 危险命令检测与确认流程

背景

Bash 是本地 Coding Agent 里最容易“从帮忙变成闯祸”的工具。

读文件最多是把上下文撑大,写文件最多是改坏项目;但 Bash 可以删除文件、改权限、杀进程、联网、执行二级脚本,甚至用一行看似普通的命令把风险藏起来。所以我不想把 Bash 当成普通工具处理,它必须先过一层风险判断,再决定是否需要用户确认。

这里的目标不是做一个完美 shell sandbox。第一版只解决一个更小的问题:明显危险的命令不能静默执行

官方做法

我现在看 Claude Code / Codex 的 Bash 安全,不能只看某一个工具函数。更好的读法是分三层:

第一层是 agentic loop。ShareAI 的 Agent 循环教程讲得很直白:模型先决定要用工具,程序真的执行工具,再把结果喂回模型,继续下一轮。Bash 的风险正是在这一轮一轮循环里放大的。一次命令不是孤立事件,它会影响下一轮模型看到的世界。

第二层是 terminal-native 架构。Agent Aura 的 Claude Code 架构文档把 Claude Code 定义成运行在本地终端里的 agentic coding system。它的优势是能直接读项目、改文件、跑命令;代价也很明确:既然拥有完整 shell 能力,就必须有对应的权限和安全机制。

第三层是 源码级工具系统。轩辕的 Claude Code 源码专题把工具、权限、上下文、子 Agent、MCP、LSP 拆成一组模块来看。这个视角对我很有用:Bash 不是“工具列表里的一个按钮”,而是会和权限规则、hook、审计、上下文压缩、任务编排互相牵连的核心能力。

所以 Claude Code 的思路更完整,它不是只在 Bash 工具里写一个 isDangerous()

它有几层控制面:

  • permission rules:settings 里可以配置 allow / ask / deny,例如允许 Bash(git diff:*),拒绝 Bash(curl:*) 或读取敏感文件。
  • permission mode:默认、计划、接受编辑、绕过权限等模式会改变工具执行策略。
  • PreToolUse hook:工具执行前可以运行 hook。对于 Bash,hook 可以返回 allow / deny / ask,甚至把拒绝原因反馈给模型。
  • PostToolUse hook:工具执行后可以做验证,比如格式化、跑检查、把失败信息喂回模型。
  • MCP / plugin 工具名统一进入权限匹配:权限系统不只面对内置 Bash,也面对外部工具。

Codex 的方向则更强调 approval mode + sandbox。也就是说,Bash 能不能跑,不只看命令文本,还要看当前模式、沙箱是否允许访问相关目录、用户是否愿意让 agent 自动执行。

所以成熟实现的核心思想是:Bash 安全不是一个函数,而是一条权限管线

我是怎么做的

我目前做的是这条权限管线里最前面的静态判定层。

第一步,把命令拆成 token,拿第一个 token 当主命令:

const tokens = command.trim().split(/\\s+/).filter(Boolean)
const baseCmd = tokens[0].split('/').pop()

这一步虽然粗糙,但能挡住一类常见写法:rm/bin/rmsudochmod 都会归一到命令名本身。

第二步,维护一组高风险二进制:

const dangerous = [
  'rm', 'mv', 'sudo', 'chmod', 'chown',
  'dd', 'mkfs', 'kill', 'pkill', 'truncate',
]

这些命令不一定永远危险,比如 mv a b 很常见;但从第一版实现角度看,我宁愿多问一次,也不想让模型静默执行破坏性操作。

第三步,识别几类“藏起来的危险”:

if (baseCmd === 'bash' || baseCmd === 'sh') && tokens.includes('-c') => dangerous
if (baseCmd === 'eval' || baseCmd === 'exec') => dangerous
if (command.includes('$(') || command.includes('`')) => dangerous
if (command has single '>') => dangerous
if (command contains ';' or '|') => split and re-check subcommands

这些规则的重点是防止模型把真实操作包进二级解释器、命令替换、管道或重定向里。比如:

bash -c "rm -rf dist"
echo "$(cat ~/.ssh/id_rsa)"
grep foo file | xargs rm
echo bad > src/index.ts

第四步,危险命令不直接执行,而是走 confirmFn

if (isDangerous(command)) {
  const result = await confirm(command)
  if (result === 'no') return `已取消执行: ${command}`
  if (result === 'always') sessionAlwaysAllow = true
}

这里我把确认函数放在 ToolContext 里,而不是写死在 Bash 工具中。这样 REPL 可以弹交互确认,单次模式可以直接拒绝,测试也可以注入假的确认结果。

最后,无论危险与否,真正执行时都加了超时和输出截断:

const { stdout, stderr } = await execAsync(command, {
  timeout: TOOLS.BASH_TIMEOUT_MS,
})

return truncateOutput(stdout + stderr)

这不是安全系统的全部,但它能防止另一个常见问题:命令跑太久或输出太多,把终端和上下文一起拖死。

当前实现的伪代码

把现在的实现压缩成伪代码,大概是这样:

function runBash(command, context) {
  if (!command) return error('missing command')

  if (isDangerous(command)) {
    decision = context.confirm(command)

    if (decision === 'no') {
      return cancelled(command)
    }

    if (decision === 'always') {
      skipFutureDangerPromptsInThisSession()
    }
  }

  try {
    result = exec(command, { timeout })
    return truncate(result.stdout + result.stderr)
  } catch (error) {
    return normalizeExecError(error)
  }
}

现在还差什么

现在这套规则能挡住一批明显风险,但它和 Claude Code / Codex 的完整权限体系还有很大距离。

第一,命令解析还不是 shell AST。 现在主要靠空格切 token 和少量正则,遇到引号、子 shell、复杂管道、xargsfind -exec、变量展开时都可能判断不准。Claude Code 的权限限制文档也提醒 Bash pattern 有局限,复杂命令需要更强的 hook 或策略检查。

第二,还没有 allow / ask / deny 配置。 现在只有“危险就问”。成熟体验应该允许用户配置:git status 永远允许,git push 总是询问,curl 或访问 .env 直接拒绝。Claude Code 的 settings 里就是这套思路。

第三,没有 sandbox。 确认只是人机交互,不是隔离。用户点了允许以后,命令仍然在真实环境里跑。Codex 的 approval mode 会和 sandbox 一起使用,这是我现在缺的关键层。

第四,没有 PreToolUse / PostToolUse hook。 现在风险判断写死在 BashTool 里。更好的做法是:BashTool 只声明“我要执行什么”,权限系统和 hook 决定能不能执行,执行后再由 PostToolUse 做验证或审计。

第五,没有审计日志和风险解释。 我现在只记录了 dangerous command detected,还没有把“为什么危险”结构化保存下来。更好的 UI 应该能展示:命中了哪个规则、为什么需要确认、这次确认是否只对当前命令有效。

所以这章的下一步不是再加几个正则,而是把 Bash 从“工具内部自检”升级成“权限管线的一环”:规则配置、hook、sandbox、审计日志要一起接上。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
14 · Safety · 已完成

safePath 路径穿越防护

safePath 相对路径越界检测流程

这个问题为什么必须早做

本地 Agent 一旦能读写文件,就必须回答一个问题:相对路径能不能跑出当前项目?

用户让它改 src/index.ts,这很正常;但如果模型或者恶意 prompt 让它读 ../../.ssh/id_rsa 呢?如果工具只做字符串拼接,很容易把“项目内文件工具”变成“任意文件读取工具”。

所以我把 path safety 放在文件工具的底层公共函数里,而不是只靠 prompt 约束模型。

早期实现里我把这类问题叫“越界保护”:外部进程要有 timeout/maxBuffer,文件路径要有 cwd 边界,写文件要处理父目录。它们看起来分散,其实是一类原则:Agent 工具不能把宿主机器当成无限可信环境。

我是怎么做的

核心函数叫 safePath()

export function safePath(filePath: string) {
  const cwd = process.cwd()
  const resolved = resolve(cwd, filePath)

  if (filePath.startsWith('/')) {
    return { safe: true, resolved }
  }

  const rel = relative(cwd, resolved)
  const safe = rel !== '' && !rel.startsWith('..') && !resolve(rel).startsWith(sep)
  return { safe, resolved }
}

这里有一个刻意的取舍:绝对路径直接允许,相对路径必须留在 cwd 里。

为什么绝对路径允许?因为本地 Agent 有时确实需要操作用户明确给出的绝对路径,比如 /Users/me/project/foo.ts。但相对路径是模型最容易“顺手写错”的地方,所以要挡住 ../../ 这种穿越。

为什么不用字符串前缀判断

最容易写错的版本是:

resolved.startsWith(cwd + '/')

这个写法看起来能用,但跨平台和边界情况都不稳。比如路径分隔符、符号链接、同名前缀目录都会让判断变脆。

所以我用了 path.relative()。它的语义更接近我们真正想问的问题:

从 cwd 到 resolved 的相对路径,是否以 .. 开头?

如果是,说明 resolved 已经跑到 cwd 外面了。

文件工具怎么使用它

edit_file 里第一步就是检查路径:

const { safe, resolved } = safePath(filePath)
if (!safe) {
  return `Error: 路径超出工作目录范围: ${filePath}`
}

这比在 system prompt 里写“不要访问工作目录外文件”可靠得多。Prompt 是软约束,工具层检查是硬边界。

和成熟工具的差距

Claude Code / Codex 的文件安全不会只停在 path normalize。更完整的系统还会考虑:

  • denylist:.env、密钥、凭据文件。
  • 二进制文件拒绝。
  • 设备文件和特殊文件屏蔽。
  • symlink 是否允许跟随。
  • workspace sandbox。
  • 用户显式授权某些外部目录。

我的 safePath() 现在只解决相对路径穿越这一类问题。它是底线,不是完整权限系统。

现在还差什么

下一版我希望把路径检查升级成策略对象:

checkPath(path, {
  cwd,
  allowAbsolute: true,
  allowSymlink: false,
  denyGlobs: ['**/.env', '**/.ssh/**'],
  operation: 'read' | 'write' | 'edit',
})

这样 read、write、edit、bash 的文件访问都能走同一套策略,而不是每个工具各写各的判断。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
15 · Safety · 已完成

ConfirmFn 与交互式确认

为什么确认函数要抽出来

Bash 危险命令检测只能判断“这条命令可能危险”,但它不应该决定“怎么问用户”。因为同一个工具在不同运行模式下,确认方式完全不同。

REPL 模式里,用户就在终端前,可以弹确认框;单次模式里,命令可能跑在脚本里,没人能交互;测试里,我们只想注入一个假结果,验证工具逻辑。

所以我没有把确认 UI 写进 BashTool,而是抽成 confirmFn

这也和后面 Agent Team 文档里的“权限继承”是同一个方向:真正成熟的系统里,权限不是每个工具自己拍脑袋,而是运行时上下文的一部分。单 Agent 里是 ToolContext.confirmFn,多 Agent 里还会变成 Team Lead 权限、Teammate 权限和任务级权限的组合。

我是怎么做的

Agent 构造时接收确认函数:

new Agent({
  provider,
  toolMap,
  stateStore,
  confirmFn,
})

执行工具时,Agent 把它放进 ToolContext

const context = {
  getState,
  setState,
  confirmFn,
  signal,
}

result = await tool.execute(params, context)

BashTool 只关心一件事:如果命令危险,就调用 context 里的确认函数。

if (isDangerous(command)) {
  const decision = await context.confirmFn?.(command)

  if (decision === 'no') return cancelled
  if (decision === 'always') sessionAlwaysAllow = true
}

这样工具层和交互层就解耦了。

单次模式和 REPL 模式的差异

单次模式的确认函数很保守:

const confirm = async (message: string) => {
  process.stderr.write(`单次模式跳过危险命令: ${message}\n`)
  return 'no'
}

这意味着:

mc "删除 dist 后重新构建"

如果模型尝试执行危险命令,会被拒绝。这个默认值是故意的:非交互模式不应该偷偷等待确认,也不应该默认放行。

REPL 模式则可以把确认交给 UI。用户能看到命令,选择允许、拒绝,或者本会话总是允许。

为什么需要 always

如果一个任务需要多次执行相似命令,每次都弹确认会打断节奏。比如用户明确在做清理构建:

rm -rf dist
npm run build
rm -rf coverage
npm test

所以 BashTool 里有一个会话级的 sessionAlwaysAllow。它不是全局配置,只在当前工具实例生命周期里生效。

这个范围很重要:always 不能跨会话记住,否则一次临时放行会变成长期安全洞。

和 Claude Code / Codex 的对照

Claude Code 的权限系统比 confirmFn 完整得多:allow / ask / deny、permission mode、PreToolUse hook、PostToolUse hook、MCP 工具匹配都会参与决策。

Codex 的 approval mode 也不是简单弹窗,它会和 sandbox 绑定:允许某个操作,不代表它能越过沙箱边界。

我的 confirmFn 更像第一块积木。它先把“工具想执行”和“用户是否同意”之间的通道打通,后面才能接权限规则和 hook。

现在还差什么

下一步不应该继续把判断堆在 BashTool 里,而是做一个统一权限管线:

decision = await permissionPipeline.evaluate({
  toolName,
  params,
  cwd,
  mode,
  rules,
})

if (decision.ask) decision = await confirmFn(decision.message)
if (decision.deny) return denied(decision.reason)

到那时,confirmFn 只负责用户交互,权限系统负责判断,工具只负责执行。这个分层才比较接近成熟 Coding Agent。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
16 · Safety · 未开始

权限系统 default / plan / auto

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
17 · Safety · 未开始

allowedTools / disallowedTools 白名单

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
18 · Safety · 未开始

settings.json 权限与策略配置

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
19 · Safety · 开发中

验证闭环 test / tsc / lint / doctor

基础实现已经有了:项目提供 npm testnpx tsc --noEmitnpm run lint 作为最小验证闭环,AGENTS.md 也把它们写进提交前检查。

还没做完的是 doctor。现在没有一个统一命令去检查 API Key、base URL、模型、终端能力、图片能力和 workspace 状态。所以这一章只能写“验证闭环已具备,doctor 仍在开发中”。

后续真正补 doctor 时,应该把它做成本地诊断工具,而不是又一个普通测试命令。

开发中
这章已有一些基础实现或工程铺垫,但还不能写成完整完成态。正文会明确哪些已经落地、哪些还在补。
20 · Safety · 已完成

LLM 400 孤儿 tool 消息修复

我第一次认真处理这个问题,是因为 OpenAI 兼容接口对消息顺序非常敏感:只要历史里留下孤儿 tool 消息,下一轮请求就会直接 400。Agent 表面上看是“模型坏了”,实际是上下文尾巴不合法。

现在的做法是把合法边界抽成 trimToLegalStart(),compact 前后都走一遍。它不再盲目保留最近 N 条消息,而是确认历史必须从 user 开始,且 tool 结果一定能找到对应 assistant tool_call。

伪代码大概是:先找最近的合法 user 边界,再过滤掉没有父级 tool_call 的 tool result。这个处理放在发 LLM 前兜底,避免 compact 中间态把协议搞坏。

这章可以写成一次很典型的 Agent 工程事故:越是长会话,越不能只关心 token 数,还要关心协议结构。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
21 · Safety · 开发中

安全审计与敏感信息防护

安全审计目前有一些基础点:provider 限制 http/https,bash 有危险命令检测,日志走本地时区,图片 Base64 会在上下文里清掉。

但这还不是完整的敏感信息防护。还缺 API Key 脱敏、路径/文件敏感规则、审计日志分级、工具执行记录,以及“用户确认过什么”的可追溯记录。

所以这章要写成开发中:可以讲已有的安全底线,也要明确距离 Claude Code 那种权限和审计体系还差一层。

开发中
这章已有一些基础实现或工程铺垫,但还不能写成完整完成态。正文会明确哪些已经落地、哪些还在补。
05

终端交互

Ink UI、状态栏、diff 与滚动。这一组不是功能清单,而是按“为什么做、怎么做、踩了什么坑、和 Claude Code / Codex 怎么对照”来写。

22 · Interface · 已完成

从 readline 到 React + Ink

最早的 CLI 只需要 readline:输入一句、等一轮、打印结果。但 Coding Agent 一旦有工具执行、确认弹窗、状态栏、图片、模型选择器,readline 很快就撑不住了。

我现在把交互层迁到 React + Ink:App.tsx 管 Agent 生命周期,MessageList 管消息展示,InputBar 管输入,StatusBar 管运行态。这个拆分的好处是每个组件只关心一块终端 UI,不再把交互状态塞进一个 while loop。

Claude Code 的体验重点不是“会聊天”,而是运行时可观察:用户知道它正在执行什么、卡在哪里、还能不能中断。Ink 版本就是朝这个方向补。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
23 · Interface · 已完成

斜杠命令与模型选择器

斜杠命令解决的是另一个问题:不是所有操作都应该发给模型。清屏、切模型、重试、resume、搜索历史,这些都属于本地控制面。

现在命令集中在 slash-commands.tsApp.tsx 根据命令类型做本地动作。模型选择器也走同一套 overlay 机制,避免把“切模型”伪装成自然语言请求。

这类设计和 Claude Code / Codex CLI 很像:对话是数据面,slash command 是控制面。两者分开以后,Agent 不会因为一句 /status 去浪费一轮 LLM。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
24 · Interface · 未开始

自定义 slash command 文件化

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
25 · Interface · 未开始

UserPromptSubmit 与提示词扩展

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
26 · Interface · 已完成

ToolStatus 与 Diff 渲染

工具状态最开始只是打印一行“正在执行”。真正用起来才发现,用户关心的是工具类型、参数摘要、结果形态,以及写文件时到底改了什么。

ToolStatus.tsx 现在按工具类型分派渲染,DiffView.tsx 单独负责变更展示。read、grep、bash、edit、agent、image_generate 都可以有自己的状态 UI。

这章适合配一个 edit_file 的 diff 示例:同样是“执行工具”,读文件需要摘要,写文件需要 diff,bash 需要命令和退出码。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
27 · Interface · 已完成

StatusBar spinner / token / 当前工具

StatusBar 是我后来才意识到必须做的东西。Agent 跑起来以后,屏幕上如果没有计时、token、当前工具,用户会很难判断它是正常工作还是卡住。

现在 StatusBar.tsx 自己维护 spinner 和 elapsed,token 通过外部 store 订阅,当前工具从 live tool message 里传入。这个设计刻意避免让整个 App 因为 spinner 每几十毫秒重绘。

这里的取舍很终端化:宁愿信息密一点,也不要让用户盯着一片空白等模型。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
28 · Interface · 已完成

InputBar 两排布局与运行中输入策略

InputBar 改成两排,是因为单行输入框塞不下运行上下文。用户在终端里最常问的是:我在哪个目录、哪个分支、当前用什么模型。

现在上排只保留输入,下一排展示 user@host dir branch | model。运行中输入还支持 queue / interrupt 两种策略,这比简单禁用输入更贴近真实编码场景。

这章可以写一个小细节:UI 信息不要抢输入框的空间,输入框永远是主角。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
29 · Interface · 开发中

Notification 与 idle 提醒

现在有运行态提示、超时感知、Ctrl+C 中断提示这些基础交互,但还没有系统级通知和真正的 idle reminder。

这章可以先写“终端内提醒”:StatusBar 告诉用户当前工具和耗时,输入策略告诉用户运行中输入会排队还是中断。未完成部分是 OS notification、任务完成提醒、长时间空闲后的主动提示。

我会把它保留在开发中,避免读者误以为已经有完整通知系统。

开发中
这章已有一些基础实现或工程铺垫,但还不能写成完整完成态。正文会明确哪些已经落地、哪些还在补。
30 · Interface · 已完成

Ink Static 架构与终端原生滚动

Ink 的 <Static> 是一次关键转向。没有它,运行中每次状态刷新都会重绘历史消息,终端滚动会飘,用户上滑看历史也会被拉回底部。

现在 MessageList 分成已完成消息和 liveMessage:完成的消息打印到 scrollback 后不再改,正在执行的工具留在动态区。这就是终端原生滚动能稳定工作的原因。

代价也很明确:已经打印出去的历史不能再修改、删除或高亮定位。/search 只能显示命中片段,不能像网页一样跳到旧位置。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
31 · Interface · 已完成

Token Store 外部订阅状态

Token 计数一开始放在 React state 里,结果运行时输出越频繁,UI 重绘越容易影响滚动。后来我把它拆成 token-store.ts

这个 store 用 useSyncExternalStore 订阅,只让 StatusBar 消费 token 变化。App 和 MessageList 不跟着 token 刷新,终端滚动自然稳定很多。

这章可以当作一个小型 React 性能案例:不是所有运行态数据都应该进入顶层组件 state。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
06

上下文工程

session、compact、memory 与 skills。这一组不是功能清单,而是按“为什么做、怎么做、踩了什么坑、和 Claude Code / Codex 怎么对照”来写。

32 · Context · 已完成

TodoWrite 与多步任务计划

TodoWrite 不是为了做一个漂亮清单,而是让 Agent 在多步任务里显式留下计划。没有 todo,模型很容易在修改、验证、补文档之间丢步骤。

当前实现把 todos 放进 GlobalState,工具调用后更新状态,agent loop 再根据情况注入 reminder。这个 reminder 是临时 user 消息,不写进真实会话历史。

这点很重要:提醒模型继续执行可以,但不能污染用户和助手的长期对话记录。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
33 · Context · 已完成

GlobalState 与 ToolContext

我把运行期共享数据收进 GlobalStateToolContext,是为了避免每个工具各自偷拿全局变量。工具执行时拿到 cwd、confirmFn、signal、stateStore 等上下文。

这样做以后,测试也更容易:createToolMap(deps?) 可以注入 provider 或状态,不需要启动完整 CLI 才能测工具行为。

这章的核心不是类型定义,而是边界感:工具是能力,context 是运行环境。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
34 · Context · 已完成

会话持久化、resume、sessions

resume 和 sessions 解决的是“Agent 工作不能只活在当前进程里”。用户关掉终端后,下一次应该能恢复最近会话,而不是从零开始解释项目。

session.ts 负责保存、加载、列出会话,UI 层再把 /resume 做成一个本地命令。它现在是基础版:能恢复对话和状态,但还不是完整任务时间线。

这里可以对比 Claude Code:成熟工具会把会话、项目、权限、工具结果都纳入恢复语义,而不是只存聊天文本。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
35 · Context · 已完成

工具结果清理 tool-result-compaction

为什么先清工具结果

很多 Agent 的上下文不是被聊天撑爆的,而是被工具结果撑爆的。

读一个文件,几百行。搜一次代码,几十个命中。跑一条命令,输出上千行。模型已经用这些内容做完判断了,可这些结果还会跟着后续每次请求一起发送。

所以我后来意识到,上下文工程不是一上来就做 summarization。最便宜、最稳的一步,是先把旧工具结果清掉。

我是怎么做的

tool-result-compaction.ts 做的是旧工具结果清理:保留近期必要信息,把更早的大块输出替换成占位符。它不追求语义总结,而是先保证协议合法和窗口可控。

伪代码大概是:

function clearOldToolResults(messages, keepRecent = 6) {
  const toolResults = findToolResultIndexes(messages)
  const stale = toolResults.slice(0, -keepRecent)

  return messages.map((message, index) => {
    if (!stale.includes(index)) return message
    return {
      ...message,
      content: '[tool result omitted; call can be repeated if needed]',
    }
  })
}

这里有个关键点:不要删掉工具调用记录。模型还需要知道自己调用过什么,也需要保持 assistant.tool_calls 和 tool.tool_call_id 成对合法。真正被缩掉的是旧 result 的大块内容。

和 Claude Code / nano-claude-code 的对照

上下文压缩报告里提到 Claude Code 会先做 MicroCompact:局部清理图片、tool_use、tool_result 这类高成本 block;nano-claude-code 也会先 snip old tool results。如果这一步已经把窗口压下来,就不必进入更贵的 LLM 摘要。

我现在的优先顺序也是:

先清工具结果
再用 Session Notes
最后才调用 LLM 摘要

总结是有损的,清理旧工具结果更像垃圾回收。它少了一点“智能”,但稳定、便宜,而且容易测试。

现在还差什么

目前的清理还比较粗:主要看新旧,没有足够理解工具结果价值。后续可以补三件事:

  • 白名单:比如最新 edit_file diff、失败的 bash、用户明确引用的结果不要轻易清。
  • 时间衰减:越旧越容易清,但最近几轮完整保留。
  • 外部卸载:大块搜索结果、网页、PDF 不直接进上下文,只留下可重新读取的文件引用。

这一步做好以后,compact 的压力会小很多。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
36 · Context · 已完成

Session Notes 持续更新

为什么需要 Session Notes

工具结果清理只能处理 tool 消息。用户说过什么、模型做过什么判断、任务推进到哪一步,它管不了。

Session Notes 是给长会话准备的中间层。compact 会压缩历史,但如果每次都只靠 LLM 临场总结,很容易把项目约定、用户偏好和未完成事项弄丢。

我把它理解成“工作现场笔记”:不一定跨项目永久保存,但要能让这一轮长任务继续干。

我是怎么做的

现在 session-notes.ts 负责读取、更新和判断是否需要刷新 notes。Agent 在合适时机把最近变化沉淀进去,再在后续上下文中带回来。

触发逻辑不是每轮都写。上下文压缩报告里我把它总结成双阈值:token 增长到一定程度,并且工具调用次数足够多,或者刚好进入自然暂停点,才值得更新。

shouldUpdate = tokenGrowth >= threshold
  && (toolCalls >= threshold || lastRoundHasNoToolCall)

这样可以避免两个问题:写得太频繁会拖慢主流程;写得太少又会在 compact 时没有可靠素材。

写入为什么要小心

Session Notes 是文件状态,不能随便写一半。当前实现采用临时文件再 rename 的方式,避免进程中断时留下半截 notes。

write notes.tmp
rename notes.tmp -> notes.md

更新 notes 也有超时保护。它是辅助链路,不应该因为 notes 写慢了阻塞用户的主任务。

和 Claude Code 的对照

Claude Code 的 Session Memory Compact 思路更进一步:如果 session memory 已经足够可靠,压缩时可以不再调用摘要模型,直接用已有记忆作为摘要。这比临场总结更便宜,也更稳定。

我现在还没做到这个程度。mini-code 的 Session Notes 更像基础版:先把现场笔记攒起来,compact 时优先使用它,再兜底 LLM 摘要。

现在还差什么

后面要补的是 notes 的结构质量:

  • 当前任务目标。
  • 用户明确要求和纠正。
  • 已修改文件和关键决策。
  • 已尝试但失败的方向。
  • 下一步要做什么。

这些字段如果稳定下来,Session Notes 才能从“长一点的摘要”变成真正可恢复的现场笔记。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
37 · Context · 已完成

compact boundary 与对话压缩

压缩不是简单截断

compact 最容易踩的坑是边界:不是截到 token 预算以内就完事。OpenAI 工具协议要求消息结构合法,孤儿 tool、半截 tool_call 都会炸。

早期做法如果只是 messages.slice(-MAX_MESSAGES),很容易切到一个 tool result 开头。模型还没看到对应的 assistant tool_call,API 就会直接 400。

我是怎么做的

compact.ts 里把 legal boundary、trim、Session Notes compact、LLM compact 串起来。压缩前先找能安全保留的消息起点,压缩后再兜底修一次合法尾巴。

核心判断是:发给 LLM 的历史必须从 user 消息开始,而且 tool 结果必须能找到对应的 tool_call。

function trimToLegalStart(messages) {
  const firstUser = messages.findIndex(message => message.role === 'user')
  if (firstUser === -1) return []
  return dropOrphanToolResults(messages.slice(firstUser))
}

这也是 v1.83 修复 400 的核心:没有 user 消息时宁可返回空数组,也不要把必炸的孤儿 tool result 发出去。

三层压缩顺序

上下文压缩报告里把成熟实现拆成三层,我现在也基本按这个方向组织:

1. MicroCompact:先清旧工具结果、图片、大块输出。
2. Session Notes Compact:如果现场笔记足够新,优先用它恢复上下文。
3. LLM Summary Compact:兜底调用模型总结旧历史。

这个顺序的重点是成本和可靠性。能不用 LLM 摘要就不用,因为摘要一定有损;能保留最近完整对话就保留,因为最近几轮通常包含模型正在模仿的工具使用示例。

compact boundary 到底给谁看

我一开始容易把 compact boundary 理解成“给模型看的消息”。后来发现它更像审计事件:这里发生过压缩,压缩前大概多少 token,压缩到哪里,为什么触发。

但如果 boundary 做成普通 system 历史消息,而发 LLM 前又会 trimToLegalStart(),它可能根本不会进入模型上下文。所以这件事要拆开:

  • 给系统和调试看的 boundary,应该写入 history/transcript。
  • 给模型看的恢复信息,应该合并进 summary 或专门的 restored context 消息。

现在最缺的是重新注入

压缩报告里指出一个很重要的差距:压缩后不能只塞 summary。成熟实现会重新注入关键上下文,比如正在编辑的文件、项目指令、已加载技能、最近失败的工具结果。

mini-code 现在已经维护了部分 compactContext,比如 recentFiles 和图片 Base64 清理,但“压缩后重新注入关键上下文”还不完整。理想流程应该像这样:

compact -> summary
summary + restored recent files + project instructions + session notes
-> new legal message window

否则模型压缩后知道“要继续修文件”,却可能丢掉刚刚读过的文件内容和用户纠正。

下一步

这章后续要补三块:

  • 工具对完整性更强的保护,不只从 user 起点裁剪。
  • compact boundary 元数据增强,区分 auto / manual / micro。
  • 压缩后重新注入关键上下文,避免 summary 变成失忆现场。
当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
38 · Context · 已完成

长期记忆 user / feedback / project / reference

记忆不是聊天历史

长期记忆现在分成 user、feedback、project、reference 四类。这个分类来自实际使用:用户偏好、纠错反馈、项目事实、外部参考混在一起会很难检索。

早期工程调研里把 Memory Zone 放在上下文五分区的第四区:它不是动态对话,也不是系统固定规则,而是跨会话可复用的知识。这个定位很关键。记忆如果混进聊天历史,很快会被裁掉;如果全部塞进 system prompt,又会变成新的上下文垃圾。

我是怎么做的

memory.ts 负责校验、写入、搜索和按机会抽取。现在的粒度还很基础,但已经能表达一个原则:记忆不是越多越好,要能说明来源和类型。

user      用户长期偏好
feedback  用户纠正过的行为
project   项目长期事实
reference 外部资料和方法论

每轮构建 system prompt 时,只检索和当前 query 相关的记忆,而不是全量注入。

nanobot 给我的启发

nanobot 的记忆设计更像个人助手:SOUL.md 定义个性,USER.md 放用户画像,memory/MEMORY.md 做记忆索引,history.jsonl 保存历史。它还把 Consolidator 和 Dream 分开:前者做近期整合,后者像睡眠一样做异步深度整理。

mini-code 现在没有 Dream,也不该一开始就做那么复杂。但这个分层很有启发:

实时上下文:当前任务马上要用
Session Notes:这次长任务的现场笔记
Long-term Memory:跨会话仍然有价值的偏好和事实
Dream / Consolidation:低优先级异步整理

把这几层混在一起,就会出现“什么都想记,最后什么都找不到”的问题。

什么不该记

记忆系统最危险的不是忘,而是乱记。比如临时调试路径、已经过期的错误猜测、一次性任务状态,都不应该进入长期记忆。

我现在的判断是:只有用户明确偏好、重复出现的反馈、稳定项目约定、可复用参考资料,才适合长期保存。

后续差距

后续还要补的是:

  • 去重:同一偏好不要反复写。
  • 过期:项目事实会变化,不能永久可信。
  • 冲突合并:用户今天纠正了昨天的偏好,要有优先级。
  • 隐私边界:敏感信息不能因为“有用”就写入记忆。
  • 异步整理:把高频碎片定期合并,而不是每次请求都现场处理。
当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
39 · Context · 开发中

context rot 与上下文健康

什么是 context rot

context rot 不是“上下文太长”这么简单。更准确地说,是上下文里有太多旧的、重复的、过期的、互相冲突的信息,导致模型注意力被污染。

做 Agent 久了会遇到这种现象:前面刚定过的约定,后面又绕回去了;已经排除过的方向,几轮后又被重新尝试;用户提醒它看过某个文件,它像是想起来了,但下一次工具调用又暴露出它根本没留住那段上下文。

这不一定是模型的问题。很多时候,是工程上只把上下文当成了一个不断追加的数组。

我现在的四层防护

mini-code 现在已经有几块分散的缓解手段:

Layer 1: tool result compaction,减少旧工具输出噪音。
Layer 2: Session Notes,保留长任务现场。
Layer 3: compact boundary + summary,处理窗口压力。
Layer 4: long-term memory,保存跨会话偏好和项目事实。

再加上图片 Base64 清理,至少不会因为一次图片生成把后续窗口撑爆。

上下文窗口不是仓库

我更愿意把上下文窗口理解成工作台。工作台上应该放当前正在用的材料。文件全文、搜索结果、API 返回、旧轮次里的推理过程,都不应该无限期堆在上面。

一个能跑得久的 Agent,要分清几种不同寿命的信息:

当前任务目标 -> 最近消息和摘要
大块工具结果 -> 用完可清,必要时重取
用户偏好 -> 长期记忆
项目决策 -> 项目记忆
临时尝试过程 -> session notes 或压缩摘要

Claude Cookbook、Manus、Claude Code、nano-claude-code 的叫法不同,但方向一致:能放到文件里的别塞窗口,能检索的别常驻,能分给子任务的别挤在一个脑子里。

还没有做成健康度

虽然已有这些机制,但我还没有一个明确的“上下文健康度”模块。比如它应该能告诉我:

  • 最近是否重复读取同一文件。
  • 工具结果占了多少 token。
  • Session Notes 是否过期。
  • memory 有没有冲突条目。
  • compact 后是否丢了正在编辑的文件。
  • 图片、PDF、搜索结果是否被正确卸载。

现在这些判断散在不同模块里,不够可观察。

下一步

我想把 context health 做成 compact 前的一份报告:不是给用户看热闹,而是让系统知道该先清工具结果、先更新 notes、还是直接压缩。

理想上,Agent 在接近窗口上限时不是突然“总结一下”,而是先做一次诊断:哪些信息该保留,哪些该卸载,哪些该变成长期记忆。

开发中
这章已有一些基础实现或工程铺垫,但还不能写成完整完成态。正文会明确哪些已经落地、哪些还在补。
40 · Context · 开发中

Prompt Cache 与系统提示词成本

系统提示词已经做了静态区和动态区的分离,这对未来 prompt cache 是有帮助的:稳定前缀越稳定,缓存命中越可能。

但项目里目前没有真正统计 prompt cache 命中,也没有把 provider 返回的 cache usage 接进成本面板。因此这章只能写“为缓存做结构准备”,不能写成缓存功能已完成。

后续要补的是 usage 采集、缓存边界测试,以及不同 provider 的 cache 字段兼容。

开发中
这章已有一些基础实现或工程铺垫,但还不能写成完整完成态。正文会明确哪些已经落地、哪些还在补。
41 · Context · 开发中

运行预算与 cost limit

为什么预算不是财务问题

运行预算现在有几个基础护栏:MAX_TOOL_ROUNDS、消息数量上限、token 运行态展示、工具结果清理。这些能防止 Agent 无限跑或窗口暴涨。

但 cost limit 还没真正实现。用户还不能设置“本轮最多多少钱 / 最多多少 token / 超过后必须确认”。

早期工程调研里的提醒

早期工程调研把循环效率放进公式里:

Agent 能力 = 上下文质量 × 工具可用性 × 循环效率

循环效率不是性能优化的小事。一个 Agent 如果 20 轮都在逐文件探索,哪怕每一步都“合理”,整体也已经失败了。它消耗 token、污染上下文、拖慢用户等待,最后还可能因为达到 max rounds 被迫停下。

我现在已有的护栏

现在的基础护栏主要是:

  • max tool rounds,避免无限循环。
  • token 运行态展示,让用户知道消耗趋势。
  • tool-result-compaction,减少上下文膨胀。
  • timeout / maxBuffer,避免 bash 输出拖死进程。
  • 接近上限时注入提醒,让模型总结进展。

这些更像“刹车”,还不是完整预算系统。

真正的 cost limit 应该长什么样

后续我希望它能做到:

max_rounds: 50
max_input_tokens: 120000
max_output_tokens: 16000
max_cost_usd: 2.00
on_exceed: ask | stop | summarize

超过预算时,Agent 不应该突然失败,而应该告诉用户:已经花了多少、还剩多少、继续会做什么、是否要切换更便宜模型或先压缩上下文。

这章保留开发中,因为现在只是有预算意识,还没有完整预算产品。

开发中
这章已有一些基础实现或工程铺垫,但还不能写成完整完成态。正文会明确哪些已经落地、哪些还在补。
42 · Context · 未开始

Skills:把工作流固化下来

为什么我想做 Skills

这章在 mini-code runtime 里还没开始,但这次实践页更新本身已经用到了 skill 的思路:把“更新实践页”这件事写成一套可复用工作流,而不是每次靠临场记忆。

nanobot 的设计里,Skill 是一个 Markdown 文件就能扩展 Agent 能力。这个方向很吸引我,因为它把复杂流程从代码里拿出来,变成可以读、可以改、可以版本管理的操作手册。

我现在已有的雏形

目前项目里有 .codex/skills/coding-agent-practice-log。它不是 mc runtime 的内置 skill,而是 Codex 工作流层面的 skill,用来约束这份实践页怎么更新:

读取 release notes / README / package.json
判断章节状态
更新 docs/practice/**/*.md
运行 npm run docs:practice
检查移动端和左侧导航

这已经证明了一个点:很多 Agent 能力并不一定要写成 TypeScript 插件,先写成 Markdown workflow 也能复用。

真正进 runtime 还差什么

如果要把 Skills 做进 mini-code,我不想只做“加载一段提示词”。至少要有:

  • skill metadata:名字、触发条件、适用场景。
  • 渐进式加载:先给摘要,真正命中时再加载全文。
  • 工具权限声明:skill 需要哪些工具。
  • 版本管理:skill 改动能影响复盘。
  • 安全边界:外部 skill 不能随便注入危险指令。

所以这章仍然标记为未开始:思想和工作流已有,但 mc 本身还没有 runtime skill 系统。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
07

子代理

探索、通用子代理与事件透传。这一组不是功能清单,而是按“为什么做、怎么做、踩了什么坑、和 Claude Code / Codex 怎么对照”来写。

43 · Subagents · 已完成

为什么需要子代理

子代理出现的原因很简单:主 Agent 一边改代码,一边大范围探索,很容易把上下文塞满。探索任务应该能被隔离出去,只把结论带回来。

当前 AgentTool 把子代理作为一个工具暴露给主循环。主 Agent 可以让 explore 子代理读代码、归纳结构,再决定自己怎么改。

这章可以写成一次上下文分工:主线程做决策,子代理做旁路调查。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
44 · Subagents · 已完成

explore / general 两种子代理模式

我现在先做了两类子代理:explore 和 general。explore 偏只读,用来查代码和总结;general 能拿更多工具,适合处理更完整的小任务。

subagent.ts 里分别构造提示词,AgentTool.ts 决定给它们哪些工具和上下文。这样比一个万能子代理更清楚:不同代理的权限和目标不同。

Claude Code 的 subagents 也强调专用性,这点很值得保留。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
45 · Subagents · 已完成

子代理工具事件透传

子代理如果只是返回最终文本,主界面会像卡住一样。用户看不到它读了什么、跑了什么、在哪里失败。

现在 AgentTool 支持 onToolStart / onToolEnd 透传,子代理内部工具事件可以冒泡到主 UI。这样主界面仍然能显示工具状态,而不是只显示“agent 正在运行”。

这章适合讲 UI 和运行时的连接:事件透传比最终总结更能建立信任。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
46 · Subagents · 已完成

子代理与图片生成工具

general 子代理可以拿到 image_generate,是因为多模态任务经常不是主 Agent 的核心路径。比如让子代理做素材探索或图片生成,主 Agent 继续保留工程判断。

当前实现是在创建 agent tool 时按模式注入工具,不是所有子代理都默认拥有图片能力。这能避免 explore 子代理不小心做高成本动作。

这章可以写“工具不是越多越强,越多越需要权限边界”。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
47 · Subagents · 已完成

Signal 级联中断

中断是子代理必须处理的底线。用户按 Ctrl+C 时,不能只停主 Agent,子代理里的 bash、read 或生成任务也要一起停。

现在 ToolContext 里有 signal,AgentTool 会把 signal 传给子代理和工具链。这个设计还比较基础,但已经形成级联中断的骨架。

后续要补的是更清晰的取消原因、超时策略和后台任务清理。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
08

多模态

图片生成、终端渲染与上下文清理。这一组不是功能清单,而是按“为什么做、怎么做、踩了什么坑、和 Claude Code / Codex 怎么对照”来写。

48 · Multimodal · 已完成

image_generate 工具实现

image_generate 最开始看起来像一个独立工具,其实它牵涉模型配置、输出路径、本地预览、终端渲染和上下文清理。

当前 ImageGenerateTool 支持本地服务、URL/Base64 路径,并把生成结果以工具结果返回给 Agent。UI 层再决定是否渲染图片、隐藏图片或打开文件。

这章可以写多模态工具的核心取舍:图片是产物,不应该把完整 Base64 长期塞在对话里。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
49 · Multimodal · 已完成

Ollama NDJSON 与 Base64 图片协议

Ollama 的图片流不是标准 SSE,而是 NDJSON。这里踩到的坑是:不能用同一套 OpenAI streaming parser 硬解析所有 provider。

现在图片生成服务对 Ollama 的 NDJSON 和 Base64 输出做了专门处理,再统一转换成本地可展示的结果。协议差异被收在工具内部,Agent loop 不需要知道太多。

这章适合讲 provider 兼容的边界:兼容不是假装所有协议一样,而是在边界层归一。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
50 · Multimodal · 已完成

终端图片渲染 Kitty / iTerm2 / ANSI

终端图片渲染不是简单打印路径。不同终端支持的能力不同:Kitty graphics、iTerm2 inline image、ANSI fallback 都要考虑。

现在项目通过 terminal image 能力做基础展示,并保留“用系统看图工具打开”的路径。也就是说,终端能显示就内联,不能显示也不阻断工作流。

这章可以配一张渲染链路图,但不需要每章都画图。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
51 · Multimodal · 已完成

图片快捷键 Ctrl+I / Ctrl+B / Ctrl+S

图片出来以后,用户马上会需要三个动作:隐藏、打开、保存。于是有了 Ctrl+I、Ctrl+B、Ctrl+S。

这几个快捷键都在 UI 层处理:Ctrl+I 影响未来图片和当前动态区,Ctrl+B 打开最新图片,Ctrl+S 保存最新图片。因为 MessageList 用 Static,已经打印的图片不能再擦掉。

这章可以和 Ink Static 放在一起看:终端 UI 的能力边界会反过来影响快捷键语义。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
52 · Multimodal · 已完成

图片上下文清理,避免 Base64 撑爆窗口

Base64 图片最容易把上下文撑爆。模型并不需要在后续每一轮都重新看到完整图片字节,用户也不希望一次生成拖慢后面所有请求。

现在 Agent 在整理 compactContext 时会清理图片 Base64,只保留必要元信息和文件路径。这是多模态上下文工程的第一步。

后续要补的是更细的图片引用策略:什么时候保留缩略信息,什么时候要求模型重新读取文件。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
09

扩展系统

Hooks、MCP、插件与 agent team。这一组不是功能清单,而是按“为什么做、怎么做、踩了什么坑、和 Claude Code / Codex 怎么对照”来写。

53 · Extension · 未开始

Hooks 生命周期 PreToolUse / PostToolUse

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
54 · Extension · 未开始

Hook 与权限、验证、通知

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
55 · Extension · 未开始

MCP 与外部工具连接

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
56 · Extension · 未开始

MCP tool 命名与 hook matcher

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
57 · Extension · 未开始

Plugin 到 MCP Server 到 MCP Tool

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
58 · Extension · 未开始

Plugin 打包与分发

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
59 · Extension · 未开始

Elicitation 结构化用户输入

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
60 · Extension · 未开始

Agent Team 团队协议

为什么会想到 Agent Team

这章还没有在 mini-code 里实现,但我已经有一版比较明确的设计草案。

单个 Agent 最大的问题不是“不够聪明”,而是所有事都挤在一个上下文里。探索、实现、测试、审查、总结都由一个模型线程完成时,长任务会越来越乱。Agent Team 的目标是把任务拆给多个独立成员,每个成员有自己的上下文和工作边界。

设计草案

我把 Team 先拆成两块:共享任务列表和消息系统。

共享任务列表提供四个操作:

TaskCreate  创建任务
TaskUpdate  更新状态 / 领取任务
TaskList    查看任务池
TaskGet     获取任务详情

任务状态先保持简单:

pending -> in_progress -> completed

任务之间可以声明 blockedBy,防止依赖还没完成时就被认领。

消息协议

团队成员之间不能只靠共享任务列表,还需要消息。设计里有几种消息类型:

message            定向消息
broadcast          广播
shutdown_request   请求成员优雅退出
shutdown_response  成员确认或拒绝退出
plan_approval      复杂任务执行前提交计划

消息可以先用文件 mailbox 做,不急着引入数据库:

SendMessage -> TeamMailbox -> inbox file -> member poll -> 注入 session

这个方案不够实时,但足够透明,也容易调试。

一个典型场景

并行代码审查很适合 Team:

Team Lead 创建三个任务:安全、性能、测试
三个 Teammate 并行审查
各自 SendMessage 汇报
Lead 合并去重,生成最终报告

这比一个 Agent 依次审查三个维度更省上下文,也更容易让每个成员保持专业视角。

设计原则

这套草案里最重要的几个约束是:

  • 合理划分任务粒度,避免多个成员改同一文件。
  • 用 blockedBy 管理依赖。
  • 成员完成后主动 TaskList 找下一项。
  • Teammate 继承 Team Lead 的权限边界。
  • shutdown 必须有请求-响应,不直接杀进程。

当前仍是未开始,因为 mini-code 还没有 Team runtime、任务池、mailbox 和成员进程管理。先把协议写清楚,是为了后面实现时不把它做成一堆临时 prompt。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
61 · Extension · 未开始

Worktree 隔离与并行开发

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
62 · Extension · 未开始

ACP Server 与编辑器集成

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
10

任务运行时

后台任务、调度与自主代理。这一组不是功能清单,而是按“为什么做、怎么做、踩了什么坑、和 Claude Code / Codex 怎么对照”来写。

63 · Runtime · 未开始

任务系统:持久化任务图

任务系统为什么不能只靠 TodoWrite

TodoWrite 适合单个 Agent 的当前任务计划,但它不是持久化任务图。Agent Team、后台任务、Cron Agent 都需要一个更稳定的任务系统:能记录任务、依赖、认领、状态变更和审计历史。

Agent Team 草案里,任务池是协作的核心。Team Lead 创建任务,Teammates 轮询任务列表,按依赖关系认领任务,完成后继续找下一项。

设计草案

最小任务模型可以先长这样:

type Task = {
  id: string
  title: string
  status: 'pending' | 'in_progress' | 'completed'
  assignee?: string
  blockedBy?: string[]
  createdAt: string
  updatedAt: string
}

事件不要直接覆盖状态,最好追加写 JSONL:

tasks/{teamName}/{sessionId}.jsonl
TaskCreated
TaskClaimed
TaskUpdated
TaskCompleted

这样后面出问题能回放,也方便做审计。

为什么是持久化

如果任务只在内存里,进程一退出就全丢。Agent Team 这种模式尤其不能接受:一个成员跑到一半退出,Lead 至少要知道它认领了什么、完成到哪一步、是否需要重新分配。

所以任务系统应该先选最透明的本地文件,而不是一开始上复杂数据库。文件状态慢一点,但适合本地 CLI,也方便用户自己检查。

这章未开始:现在只有 TodoWrite,还没有任务图 runtime。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
64 · Runtime · 未开始

后台任务与通知队列

为什么需要后台任务

现在 mini-code 的执行模型还是前台交互:用户输入,Agent 跑,结果回来。这个模型适合 REPL,但不适合长时间等待的任务,比如跑完整测试、等待 CI、周期性检查文档、异步整理记忆。

nanobot 里有 heartbeat 和 gateway 的设计,让 Agent 不只活在一次 CLI 输入里。这个方向对 mini-code 后续也有参考价值。

设计草案

后台任务至少需要两层:任务队列和通知队列。

BackgroundTask
  id
  prompt
  cwd
  status
  createdAt
  startedAt
  completedAt
  result

Notification
  taskId
  level
  message
  delivered

任务可以慢慢跑,通知负责告诉用户“跑完了”“失败了”“需要确认”。

和当前实现的关系

现在已有的 StatusBar、timeout warn、Ctrl+C 中断,都是前台运行态体验。后台任务要解决的是另一个问题:用户不盯着终端时,Agent 还能不能可靠地完成、记录、提醒。

这章未开始,后续要和任务系统、session 持久化、通知机制一起做。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
65 · Runtime · 未开始

定时调度与 Cron Agent

Cron Agent 想解决什么

定时调度不是为了让 Agent “自己瞎跑”,而是把明确、重复、低风险的工作交给它。比如每天检查 TODO、每周整理 release notes、定时扫描失败测试、周期性更新实践页状态。

nanobot 的 cron / heartbeat 给我的启发是:个人 Agent 不一定只在用户输入时存在,它也可以有节律。但这个节律必须受限,不能变成无边界自主执行。

设计草案

最小 Cron Agent 可以先长这样:

schedule: every day 09:00
cwd: /path/to/project
prompt: 检查最近 release notes 是否需要同步实践页
mode: plan | ask-before-write | auto
notify: on_complete | on_error

关键不是 cron 表达式,而是权限:定时任务默认应该更保守。能读就读,写文件和跑危险命令要么禁用,要么必须确认。

当前状态

mini-code 现在没有后台 scheduler,也没有 detached execution。单次命令模式只是给 headless 留了入口,还不能算 Cron Agent。

这章未开始,后续应依赖任务系统、通知队列和权限系统。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
66 · Runtime · 未开始

自主代理:任务板扫描与原子认领

自主代理不能等于“随便行动”

如果以后要做 autonomous agent,我希望它的起点不是“给模型一个大目标让它自由发挥”,而是任务板扫描与原子认领。

Agent Team 草案里,Teammate 完成任务后会重新 TaskList,找可执行任务并认领。这个过程要原子化,否则多个成员可能同时拿到同一个任务。

设计草案

认领任务应该像这样:

list pending tasks
filter blockedBy 已完成
attempt claim task with compare-and-swap
if success: run
if failed: reload task list

文件系统版本可以用 lock file 或原子 rename 实现。重点是不要靠 prompt 约束“大家别抢同一个任务”。并发正确性要靠系统保证。

权限和审计

自主代理必须有更严格的审计:

  • 它认领了什么任务。
  • 为什么判断任务可执行。
  • 执行了哪些工具。
  • 有没有修改文件。
  • 失败后是否重试或释放任务。

这章未开始。现在先把协议写清楚,后续实现时再接任务图、worktree 隔离和权限系统。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
11

工程运维

测试、发布、日志与文档同步。这一组不是功能清单,而是按“为什么做、怎么做、踩了什么坑、和 Claude Code / Codex 怎么对照”来写。

67 · Operations · 已完成

Git diff / commit / release 工作流

Git 工作流现在更多是工程实践而不是内置大工具:改动前看 diff,改动后跑验证,发布时写 release note,再让文档反哺实践页。

项目里已经有 release notes 和 docs 生成脚本,说明功能不是写完就结束,还要被解释、验证和记录。

这章可以写我现在的最低闭环:代码改动、测试验证、发布说明、实践文档同步。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
68 · Operations · 已完成

测试覆盖率与回归测试策略

测试覆盖已经覆盖 agent loop、provider、tools、session、compact、UI 组件和 slash commands。它不是只测函数,而是在保护几个高风险边界:工具协议、终端渲染、键盘行为、上下文裁剪。

AGENTS.md 里也明确了提交前要跑 npm testnpx tsc --noEmitnpm run lint。这章可以写测试不是附属,而是 Agent CLI 能继续迭代的护栏。

后续可以补 coverage 报告和更系统的端到端测试。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
69 · Operations · 已完成

日志系统与本地时区

日志里最容易忽视的是时间。toISOString() 永远是 UTC,排查本地 CLI 问题时很不直观。

现在 logger.ts 提供 localISOString(),输出本地时区,比如 +08:00。这个小改动对排查用户反馈很有用,尤其是 CLI 工具没有服务端统一日志时。

这章可以写成本地工具的一个原则:日志首先服务当前机器上的人。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
70 · Operations · 开发中

Headless / SDK 模式

为什么 CLI 之后会需要 SDK

单次命令模式和可注入的 Agent / Provider 类,已经让 mini-code 具备一点 headless 的影子:不一定非要进入完整 REPL 才能跑。

但 SDK 模式还没有正式设计。现在没有稳定导出的 npm API,也没有文档化的事件协议、错误协议和调用示例。

nanobot 的启发

nanobot 同一个核心有三种入口:CLI、Gateway、SDK。这个设计很好,因为它没有把 Agent Loop 绑死在某一种交互界面里。CLI 是皮肤,Gateway 是驻留服务,SDK 是嵌入式调用,核心 loop 仍然是一套。

mini-code 后面也应该朝这个方向拆:

core agent loop
  <- CLI / Ink UI
  <- headless runOnce
  <- SDK
  <- future background worker

一个可能的 API

未来 SDK 至少要能表达事件流,而不是只返回最后文本:

for await (const event of agent.run({ prompt, cwd })) {
  if (event.type === 'tool_start') renderTool(event)
  if (event.type === 'tool_end') renderResult(event)
  if (event.type === 'message') appendText(event.text)
}

这和 Claude Code SDK 的事件模型也更接近:调用者应该能看到 tool、cost、session、compact boundary,而不是只拿一个字符串。

这章继续标记开发中:runOnce 和依赖注入是基础,但正式 SDK 还没有。

开发中
这章已有一些基础实现或工程铺垫,但还不能写成完整完成态。正文会明确哪些已经落地、哪些还在补。
71 · Operations · 未开始

GitHub Actions / PR 自动化

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
72 · Operations · 未开始

CI/CD 中的 Agent 审计

未开始。

未开始
这章目前还没有进入实现阶段。页面先保留标题,避免把规划误写成已经完成的能力。
73 · Operations · 已完成

版本发布说明如何反哺实践文档

发布说明不是写给过去看的。每次 v1.5、v1.6、v1.8、v1.83 的 release note,都能反过来告诉实践页:哪些能力已经完成,哪些坑值得展开。

现在这份实践页就是从 release notes、代码和 docs/practice markdown 里汇总出来的。后续每个版本完成后,都应该先更新 release note,再同步实践章节状态。

这章可以作为文档工作流的桥:版本历史不是流水账,而是实践报告的素材库。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
74 · Operations · 已完成

实践页自动更新 workflow skill

为了避免每次手写 HTML,我把实践页拆成 docs/practice/**/*.mdscripts/build-practice-page.mjs。Markdown 写内容,脚本负责目录、状态、样式和输出两个 HTML。

同时补了 .codex/skills/coding-agent-practice-log,以后只要是版本行为、章节状态或实践页更新,都按同一套检查清单来走。

这章已经可以写成“文档自动化”的基础版:还不是 CI 自动发布,但已经不再靠手改整页 HTML。

当前状态
这一章对应的能力已经落地。正文只讲实现核心和边界,不再把阅读负担丢给文件路径。
REF

参考资料

  1. Claude Code Agent Loop
  2. Claude Code Tools Reference
  3. Claude Code Hooks
  4. Claude Code Subagents
  5. OpenAI Codex CLI Getting Started
  6. 学习 Claude Code 源码专题
  7. Claude Code Architecture: What is Claude Code
  8. Learn Claude Code: Agent 循环
  9. Learn Claude Code: 权限系统
  10. Claude Code Architecture: BashTool 安全设计