一个自己开发的 Agent Harness-Tool/Skill/Mcp篇

本文讲 区域 5 · 能力来源:模型在对话里能"动手"的工具从哪来、怎么被描述、怎么被冻结、又怎么在安全的边界内被真正执行------涵盖内置工具、Skill 技能、MCP 外部工具三大类。

0. 先建立心智模型

模型的手,是"能力"

上一篇文章说,模型看到的世界是一张纸(prompt)。但光能看不能动,编码 agent 就只是个聊天机器人。真正让它"干活"的,是能力------模型可以在对话中发起调用的那些工具。

Grodex 里有三大类能力来源:

类别 是什么 例子
内置工具 编译进二进制的文件/命令操作 read_fileexecedit_file...
Skill 技能 磁盘上的 Markdown 指令集 deployteststyle...
MCP 工具 外部 MCP 服务器提供的远程操作 mcp_github_search_issues...

这一层要回答的问题很实际:

  • 模型调用工具时,按哪一版的定义执行(准备时看到的 schema vs 执行时的 schema)?
  • 工具在对话中途增删改了(新 MCP 连上、Skill 文件被改),正在进行的回合怎么处理?
  • 安全收紧时,怎么让已经冻结的工具调用立刻失效?
  • 大工具结果怎么回传、崩溃后怎么区分"只读可重放"和"有副作用要人工裁决"?

答案不是把工具做成一个简单的 name → handler 哈希表,而是把能力的一生管起来:注册 → 冻结 → 派发 → 执行,每一步都带上可审计的凭证。

1. 一条流水线:从"注册"到"执行"

先把三类能力在系统里流转的完整链路看一遍:

  • 注册 :内置工具注册进 ToolRegistry;Skill 被 SkillCatalog 发现并校验;MCP 服务器被适配器包装。三路最终都汇入 loop 层的 CapabilityManager(能力注册表,按代保管);
  • 冻结 :每个 Turn 开始,snapshot_base() 把当前代的最新工具 schema、运行时句柄、代数号冻结成一份不可变基线(呼应 01 篇 §3 的"冻结"------能力是它冻结的对象之一);
  • 派发 :模型看到的清单由 effective_specs(base, overlay) 算出;真正执行某个调用前,生成 PreparedCapabilityCall,把"按哪一版执行"钉死;
  • 执行:通过权限门 + 沙箱(区域 6)后,调用内置运行时或 MCP 进程。

后面几节分别讲三类来源,再讲"冻结与叠加"和"与安全的接缝"。

2. 内置工具:七件套 + 两阶段

七件套

ToolRegistry::builtin() 注册了 7 个工具,每个都带着完整的元数据(并发等级、副作用等级、默认策略):

工具 作用 并发 副作用 默认策略
read_file 带行号读文件,支持 offset/limit/字节上限 Parallel 只读 允许
read_artifact 读被外置的大工具结果 Parallel 只读 允许
write_file 创建/覆盖文件 Serial 非幂等 询问
edit_file 精确字符串替换(要求完全匹配) Serial 非幂等 询问
exec 跑 shell 命令,返回 stdout/stderr/退出码 Serial 非幂等 询问
apply_patch 结构化文件操作(create/modify/delete/rename,一次多个) Serial 非幂等 询问
process_io 与后台进程交互(轮询输出、写 stdin、发信号) Serial 非幂等 允许

注意这个分工的保守取向:只读的放 Parallel + 允许(无副作用、可放心并发);凡是碰状态的一律 Serial + 询问(可能产生不可逆副作用,先问用户)。"并发等级"决定工具批次能不能并行(01 篇 §4 说过的整批降级),"副作用等级"是崩溃恢复时判断能不能安全重放的依据,"默认策略"是权限引擎的第一层默认值。

两阶段:prepare 与 execute

内置工具实现了一个两阶段 trait:BuiltInTool 把一次调用拆成 prepareexecute:

  • prepare:不产生副作用,只做参数校验、文件快照、静态检查;
  • execute:真正动手。

拆开的收益是:准备阶段可以被安全地并行、可被审批打断、可被回放验证;而"有副作用"的瞬间被压缩到 execute 一个点。这与权限门(区域 6)"批准的是意图、执行的是动作"是同一套逻辑的两面。

跨工具原语

7 个工具不是各自为政,它们共享一套"地基原语":

  • FileSnapshot / ReadRange / HashlineAnchor:读的稳定性。给文件拍快照、按行号锚定,保证"读的时候"和"改的时候"看到的是同一版本;
  • ToolResultEnvelope + HeadTailBuffer + TruncationInfo :大结果的处理。长输出只留头尾、中间截断,完整内容外置成 blob,由 read_artifact 按需读回;
  • ProcessHandle + ExecStatus :长进程管理。exec 跑很久的命令时,句柄化交给 process_io 轮询;
  • PatchPlan + PatchFile :apply_patch 的原子性。先计划、再落盘,避免半改状态;
  • StaleFile + AtomicityLevel:版本栅栏 + 原子写。文件在"准备→执行"之间被别人改了,能发现并拒绝(见 §6 的 stale 语义)。

3. Skill:不是代码,是指令集

Skill 是三大能力里最特别的一类------它不执行任何东西 ,它只是给模型看的指令集。一个 Skill 就是磁盘上的一个 Markdown 文件(带可选 YAML frontmatter),比如:

yaml 复制代码
---
name: deploy
description: Deploy the app
---
# Deploy
Run `deploy.sh`, then verify the health endpoint.

发现与校验

SkillCatalog::discover() 从两个位置找 Skill:项目级 (.grodex/skills/)和用户级 (全局配置目录),项目优先、按名字去重,最后排序。发现时还跑一遍 lint ------有 error 级问题的 Skill 整个被排除出目录,不参与注入。

信任策略:未信任的工作区,只给"目录"

Skill 是否把正文注入 prompt,取决于信任状态:

  • 内置和用户级 Skill:永远信任,正文完整注入;
  • 项目级 Skill:只有工作区被 --trusted 标记才信任;
  • 未信任 :prompt 里只显示名字 + 描述,并明确告诉模型"内容被扣留了,用 --trusted 才能启用"。正文不进 prompt,而不是蒙混过关。

这延续了总览的 fail-closed 原则------别人的工作区里躺着一个 Skill,里面写的可能是"执行 rm -rf /",不能因为发现了一个文件就把它当作神圣指令执行。

按 Turn 冻结

Skill 在 Turn 开始被拍成 SkillSnapshot 冻结 。之后即使磁盘上的文件被改了,进行中的 Turn 仍然用冻结的版本------模型规划时看到的 Skill 和执行的完全一致(01 篇 §3 冻结纪律的又一兑现)。冻结靠 SHA-256 内容哈希做变更检测:哈希变了就 bump skill_generation,但普通变更默认下一 Turn 才采纳

4. MCP:外部世界怎么接进来

MCP(Model Context Protocol)是让 Grodex 连上外部工具的协议。Grodex 把 MCP 服务器跑成独立的子进程,通过 stdio 走 JSON-RPC 通信。

命名空间:避免和内置工具打架

MCP 工具的名字被加前缀:mcp_{server}_{tool} 。比如 GitHub 服务器上的 search_issues,在 Grodex 里叫 mcp_github_search_issues。这样外部工具永远不会和内置工具撞名;调用时靠前缀反解出 (server, tool),生成对应身份的能力 ID。

适配器:把"远程函数"包装成本地工具

McpToolAdapter 把 MCP 服务器上的一个工具包装成标准的 Tool + ToolRuntime:

  • metadata():名字、描述、schema 照搬;并发/副作用/策略按最保守的默认:Serial、NonIdempotent、Ask------外部工具的语义不可信任,宁可每次先问;
  • execute():按需 spawn 服务器进程(进程对象 drop 时自动 kill,kill_on_drop),发 tools/call JSON-RPC,拿回结果。

契约修订栅栏:防"按旧 schema 规划、按新 schema 执行"

MCP 工具带一个 contract_revision。调用准备时,PreparedMcpCall 把当时的修订号 + 参数哈希成一个 plan_hash 钉住;执行前 verify_revision 检查当前修订是否还一致------不一致就直接拒绝,报 "stale MCP tool call"。这堵住了分布式系统里最阴险的一类 bug:模型看着旧版本文档规划,执行时却是新版实现

5. 冻结、叠加与代数:能力的一生

这是区域 5 的枢纽:loop 层的 CapabilityManager 怎么让"能力的集合"既有活性、又可控。

代数(Generation):不可变快照 + 环形缓冲

能力不是一份可变的大哈希表,而是一列不可变的代数快照 (ring buffer)。每次 register_tool / unregister_tool:

  1. 代数号 +1;
  2. 克隆上一代、插入/移除该项 → 形成新一代;
  3. 挤掉最旧的代(默认保留若干代,至少 2 代)。

代与代之间完全隔离 :对某一代做的查询,永远基于那一代的内容,不会"回落"到更新的代。代码里有一条明确的 P1-1 防漂移修复:某代被挤出缓冲后,调用若还引用它,必须拒绝,而不是偷偷用最新代顶替------宁可报错,不能"假装"。

冻结:Turn 开始 snapshot_base

每回合 snapshot_base() 冻结"最新代"的 schema + 运行时 + 代数号。之后这一 Turn 内,模型看到的工具清单和执行的运行时都出自这份冻结基线(加 overlay),中途注册新工具不影响本回合------这正是 01 篇 §3 说的"模型看到的世界,在 Step 内不许变"在工具维度上的形态。

叠加:回合内的 promote / demote

回合内允许受控地增删工具,通过 overlay(叠加层):

  • promote(name, spec, runtime) / demote(name) 只改 overlay,不碰冻结的 base;
  • 每个 Step 算 effective_specs(base, overlay):base + overlay 新增 − overlay 移除;
  • 按名字排序 输出------注释原话是:HashMap 的迭代顺序是随机的,不排序的话,工具数组每个 Step 都会变,前缀一变,prompt cache 命中率直接归零。这是对 02 篇 §4 缓存纪律的又一次呼应;
  • 回合结束 adopt_overlay() 把叠加原子采纳进注册表,代数 +1------本回合的变更从下一回合开始生效,永不中途悄悄变。

发布统一:别让"管能力的"和"看能力的"各说各话

注册/注销工具时,还会 bump 一个共享的 CapabilityPublisher(同时推进工具代数与根代数),让 CapabilityManager、ACP 协议、StepContext 这些观察者永远看到同一个推进------避免"执行时用的是新工具,前端还展示旧清单"的错位。

6. 与安全内核的接缝(区域 6 的预告)

能力层不负责"放行",但它是安全内核的对象凭证:

  • 默认策略 :每个工具带 default_policy(允许/询问),是权限引擎的第一层输入;
  • 派发凭证 :每个 PreparedCapabilityCall 钉住 capability_revisionpolicy_generationargs_hashoperation_idpolicy_ceiling。审批如果基于某版参数和策略,之后任何一项变了,旧审批自动失效;
  • 吊销栅栏 :一个单调的 LiveRevocationFence。安全策略收紧(如 kill-switch)时 revoke_all() 把代数 epoch +1------已经被冻结的旧调用,只要还没产生副作用,就会被实时拒绝;回合内的 overlay 若还"假装"停在旧 epoch,也会被拒绝(stale-epoch 守卫);
  • 崩溃分类 :side_effect_map(工具 → 副作用等级)供区域 7 的恢复器使用:只读/幂等的进行中调用可以安全自动重放;非幂等的必须交给人类裁决(01 篇 §7 的"结果未知"就是这么来的)。

一句话:能力层回答"有什么、按哪版、冻结到什么程度",安全内核回答"准不准、在什么边界内跑"。 两者在派发瞬间接上电。

7. 一张表:三类能力的异同

内置工具 Skill MCP 工具
本质 可执行的代码 给模型看的指令 远程函数
来源 编译进二进制 磁盘 Markdown 外部服务器进程
命名 read_file... deploy... mcp_{server}_{tool}
并发默认 读类并行 / 写类串行 --- 串行
副作用默认 读类只读 / 写类非幂等 --- 非幂等
策略默认 读类允许 / 写类询问 信任则注入 询问
变更时 注册新代 下一 Turn 采纳 契约修订栅栏
谁执行 本地 Rust 无(纯提示) 子进程 JSON-RPC

8. 小结

一句话总结区域 5:能力的集合被当成"版本化的资产"来管,而不是一把钥匙。 三类来源(内置 / Skill / MCP)汇入注册表后,每个 Turn 冻结一份基线,回合内靠叠加层受控增删、回合末原子采纳;每代不可变、代间隔离、挤出即拒绝;每个调用派发时带上修订号、参数哈希、策略版本和幂等键的完整凭证------能力因此既灵活(可热更新、可叠加、可吊销)又可审计(任何时候都能说清"按哪一版、凭什么")。


相关推荐
一开1 小时前
一个自己开发的 Agent Harness-Agent Loop篇
后端
思考着亮1 小时前
10.MySQL 锁机制
后端
思考着亮1 小时前
9.MySQL 性能分析与优化
后端
我的div丢了肿么办1 小时前
go语言中基本数据类型的转换
后端·go
XuCoder1 小时前
你写的每条 SQL 都没加过锁,可 MySQL 凭什么不怕两个事务打架?
数据库·后端
尼古拉斯-托尔斯泰-赵四1 小时前
Go 语言,你需要了解的一些规则
开发语言·后端·golang
程序员贺加贝1 小时前
列表导出不够用-SaaS-ERP-单据详情导出的-Provider-模板与文档型-Excel-设计
java·后端·设计模式·架构·excel
SimonKing1 小时前
写文档的最佳搭档:Typora+PicList+SM.MS
java·后端·程序员