Skip to content

MewCode 工具层(internal/tool)深度分析笔记 ​

分析对象:/Users/binhy/Binhy-Projects/EasyCoding/mewcode/internal/tool/(12 个文件,1662 行) 关联阅读:internal/llm/{provider,anthropic,openai}.go、internal/agent/{agent,run_to_completion,agent_tool}.go、internal/permission/{engine,settings,sandbox,blacklist,rule}.go、internal/skills/*、internal/compact/{layer1,const}.go 说明:所有行号均为源码真实行号;凡源码没有的内容一律标注「源码未体现」。

模块职责 ​

一句话:internal/tool 定义「工具」统一抽象(接口 + 结果类型 + 注册中心),并以零外部依赖的纯 Go 实现了 6 个内置编码工具(read/write/edit/bash/glob/grep),把一切失败都收敛成可回灌给模型的结构化错误。

要点:

  • 包注释即契约:// 所有工具执行失败均以 Result{IsError:true} 返回,绝不 panic。(tool.go:1-3)——工具层不向 agent 抛 Go error,错误是「给模型的观察值」而非「给调用者的异常」。
  • 三个关注点分区:tool.go(接口与截断工具函数)、registry.go(注册/查询/导出/执行/超时)、6 个 *_tool 或 *Tool 实现文件;filter.go 是给子 Agent 用的工具名过滤策略,install_skill.go/load_skill.go 是 Skill 系统在工具面的两个入口。
  • 依赖方向干净:internal/tool 只 import 标准库 + internal/llm(仅用于 ToolDefinition 类型)+ internal/skills(仅两个 Skill 工具)。不 import 任何 SDK,协议适配在 internal/llm 完成。
  • 注册中心是可扩展的:NewDefaultRegistry() 只注册 6 个内置工具(registry.go:135-143),MCP 工具(cmd/mewcode/main.go:79-81)、Task 四件套与 Agent 工具(internal/tui/tui.go:258-273)、Skill 两件套(internal/tui/tui.go:230-238)都是运行期追加注册。

Tool 接口与 Result 类型 ​

Result:永远以值返回的结果对象 ​

go
// tool.go:12-16
// Result 工具执行结果——永远以值类型返回,从不返回 Go error。
type Result struct {
	Content string // 回灌给模型的文本(已截断/带行号等)
	IsError bool   // true 表示结构化错误,Content 即错误描述
}

逐字段说明:

  • Content:直接成为 llm.ToolResult.Content(agent.go:883-887、agent.go:934-938),最终进入 provider 的 tool_result 块。约定里它「已截断、已带行号」,即面向模型可读性做了预处理,不是原始 stdout。
  • IsError:映射到 llm.ToolResult.IsError(llm/provider.go:30-34),Anthropic 侧落到 SDK 的 is_error 字段(anthropic.go:82 anthropic.NewToolResultBlock(tr.ToolCallID, tr.Content, tr.IsError));OpenAI 的 chat 协议没有对应字段,toOpenAIMessages 只传 tr.Content(openai.go:84-87)——同一份错误语义在两种协议下表达力不对等,OpenAI 路径只能靠文案表达。

构造错误的唯一工厂(约定所有错误都走它):

go
// tool.go:53-58
// errorResult 生成一个 IsError 的结构化错误结果。
func errorResult(format string, args ...interface{}) Result {
	return Result{
		Content: fmt.Sprintf(format, args...),
		IsError: true,
	}
}

Tool 接口:五个方法 ​

go
// tool.go:18-26
// Tool 统一工具抽象(F1)。
// 每个工具暴露名称、给模型看的描述、参数 Schema、执行入口、只读标记。
type Tool interface {
	Name() string               // 模型看到的工具名,如 "read_file"
	Description() string        // 给模型的用途说明
	Parameters() map[string]any // 手写 JSON Schema(type/properties/required/description)
	ReadOnly() bool             // true=只读工具(可并发执行 & Plan Mode 放行)
	Execute(ctx context.Context, args json.RawMessage) Result
}
  • Name():既是模型可见名,也是注册中心的 key(registry.go:26-30)。注意 6 个内置工具用 snake_case(read_file),而 LoadSkillTool/InstallSkillTool 用 PascalCase(load_skill.go:23、install_skill.go:31)——这个不一致在 filter.go 里引发了一个真实缺陷(见下文 filter 一节)。
  • Description():直接进工具定义给模型看,是工具选择率的第一抓手。edit_file 与 bash 的 description 末尾都做了「行为约束式强化」(edit_file.go:26-29 要求先 read_file;bash.go:31-34 要求优先用专用工具)。
  • Parameters():返回的是手写的 map,不是反射生成。注释明确要求包含 type/properties/required/description。
  • ReadOnly():双语义,见下。
  • Execute():唯一执行入口;args 是 json.RawMessage(模型原始参数串),下游一律 json.Unmarshal。所有实现都先把空 args 归一化为 {}(如 read_file.go:40-42),保证 nil 输入不炸。

ReadOnly() 的作用与调用方 ​

ReadOnly() 是一个标志服务两个正交需求:并发执行分组 + Plan Mode 工具集裁剪。真实调用方如下:

调用点位置用途
Registry.IsReadOnly(name)registry.go:75-78未知工具返回 false(保守)
executeBatched 分批agent.go:537、agent.go:540吃入「连续只读区间」并发跑;遇到 false 立即中断区间,串行执行
ReadOnlyDefinitions()registry.go:59-72Plan Mode 只导出只读工具
Agent Plan Mode 取工具集agent.go:207-211mode == ModePlan → 只读定义集
子 Agent Plan Moderun_to_completion.go:82-88同上(优先级高于白名单)
/compact 手动压缩tui/commands.go:98-103传给 RunForceCompact 以保持与请求一致的工具定义
权限分类permission/engine.go:101-102 → settings.go:89-102categorize(internal, readOnly):readOnly==true → CategoryRead,直接 Allow

六个内置工具的取值:read_file/glob/grep = true,write_file/edit_file/bash = false(各文件 ReadOnly() 一行)。扩展工具同规则:LoadSkillTool = true(load_skill.go:45)、InstallSkillTool = false(install_skill.go:53)、MCP 工具取 annotations.readOnlyHint(mcp/tool.go:49,136)、TaskListTool/TaskGetTool = true、TaskStopTool/SendMessageTool/AgentTool = false(task/tools.go:22,69,134,177、agent_tool.go:131)。

SystemTool 可选接口:白名单豁免 ​

go
// tool.go:41-51
// SystemTool 可选接口:实现此接口的工具标记为系统工具。
// 系统工具在工具过滤时总是可见,不受白名单约束。
type SystemTool interface {
	IsSystem() bool
}

// IsSystemTool 检测一个工具是否为系统工具。
func IsSystemTool(t Tool) bool {
	st, ok := t.(SystemTool)
	return ok && st.IsSystem()
}

用类型断言做「能力探测」,避免给 Tool 接口加一个只有极少数实现关心的方法(接口最小化)。唯一实现是 LoadSkillTool.IsSystem() bool { return true }(load_skill.go:48),唯一消费点是 DefinitionsFiltered(registry.go:108)。

truncate:包级截断工具函数 ​

go
// tool.go:28-39
// truncate 对字符串做行数和字符数双重截断,超出尾部加 [truncated] 标注。
func truncate(s string, maxLines, maxChars int) string {
	lines := strings.Split(s, "\n")
	if len(lines) > maxLines {
		lines = lines[:maxLines]
		s = strings.Join(lines, "\n") + "\n[truncated]"
	}
	if len(s) > maxChars {
		s = s[:maxChars] + "\n[truncated]"
	}
	return s
}

两个细节值得记住:① 用的是 len(s)(字节)而不是 rune 数,切片可能落在多字节字符中间 → 可能产出非法 UTF-8;② 若先触发行截断、再触发字节截断,先追加的 [truncated] 会被第二次切片连同尾部一起切掉(只剩末尾那一个标记,语义上没问题,但「标记数」不固定)。仅 2 个调用点:read_file.go:80、bash.go:116。

注册中心 ​

数据结构与注册语义 ​

go
// registry.go:12-13
// DefaultTimeout 单个工具执行的默认超时(N1,不可配)。
const DefaultTimeout = 30 * time.Second

// registry.go:15-19
// Registry 集中登记工具、按名查找、导出定义、按名执行。
type Registry struct {
	order []string // 保持注册顺序,导出稳定
	tools map[string]Tool
}

// registry.go:21-31
// Register 注册一个工具。同名后注册覆盖先注册。
func (r *Registry) Register(t Tool) {
	if r.tools == nil {
		r.tools = make(map[string]Tool)
	}
	name := t.Name()
	if _, exists := r.tools[name]; !exists {
		r.order = append(r.order, name)
	}
	r.tools[name] = t
}
  • 惰性初始化 map(零值 Registry{} 可直接用)。
  • order 只在首次见到该名字时追加 → 同名覆盖不改变导出顺序,导出的工具定义序列在进程生命周期内稳定。稳定顺序对 prompt 前缀缓存有实际价值(Anthropic 的 cache_control 打在 system 块上,anthropic.go:123-137;工具定义紧随其后,顺序抖动会破坏缓存前缀)。
  • 注册时机全在启动期:NewDefaultRegistry()(6 个)→ MCP 工具(main.go:79-81)→ TUI 构造期追加 Skill/Task/Agent 工具(tui.go:230-273)。

查询与导出 ​

go
// registry.go:33-42
func (r *Registry) Count() int                      { return len(r.tools) }
func (r *Registry) Get(name string) (Tool, bool)    { t, ok := r.tools[name]; return t, ok }

Definitions()(registry.go:44-56)按 order 导出协议无关定义:

go
// registry.go:49-53
defs = append(defs, llm.ToolDefinition{
	Name:        t.Name(),
	Description: t.Description(),
	InputSchema: t.Parameters(),
})

llm.ToolDefinition 本体(internal/llm/provider.go:36-41)只有三个字段:Name / Description / InputSchema map[string]any——协议无关的中间表示,是「一份工具定义喂两家协议」的关键接缝。

三个变体:

  • ReadOnlyDefinitions()(registry.go:58-72):过滤 t.ReadOnly(),Plan Mode 用。
  • DefinitionsFiltered(allowed []string)(registry.go:92-125):allowed 为空 → 等价 Definitions();非空 → 只保留白名单内工具 + 所有系统工具(if IsSystemTool(t) { ...; continue },registry.go:107-115)。子 Agent 走这条路径(run_to_completion.go:84-88)。
  • Names()(registry.go:127-132):拷贝一份 order,供 ApplyAgentToolFilter 的 All 入参使用(agent_tool.go:173)。

如何转成 Anthropic 与 OpenAI 两种 schema ​

Anthropic 路径(internal/llm/anthropic.go:14-31):

go
// anthropic.go:16-31
func toAnthropicTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
	result := make([]anthropic.ToolUnionParam, 0, len(tools))
	for _, t := range tools {
		schema := anthropic.ToolInputSchemaParam{
			Properties: t.InputSchema["properties"],
			Required:   toStrings(t.InputSchema["required"]),
		}
		tool := anthropic.ToolParam{
			Name:        t.Name,
			Description: anthropic.String(t.Description),
			InputSchema: schema,
		}
		result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
	}
	return result
}

关键点:Anthropic SDK 的 ToolInputSchemaParam 结构是「Type 固定 object + Properties + Required」,所以这里只取 properties 与 required 两个 key,顶层 "type":"object" 由 SDK 自己补。toStrings(anthropic.go:33-54)同时兼容 []string 与 []interface{}——因为本项目 schema 手写时给的是 []string{"path"}(read_file.go:34),而经过 JSON 反序列化的 schema 会是 []interface{},两种形态都要吃得下。

OpenAI 路径(internal/llm/openai.go:17-28):

go
// openai.go:18-28
func toOpenAITools(tools []ToolDefinition) []openai.ChatCompletionToolUnionParam {
	result := make([]openai.ChatCompletionToolUnionParam, 0, len(tools))
	for _, t := range tools {
		result = append(result, openai.ChatCompletionFunctionTool(shared.FunctionDefinitionParam{
			Name:        t.Name,
			Description: openai.String(t.Description),
			Parameters:  shared.FunctionParameters(t.InputSchema),
		}))
	}
	return result
}

OpenAI 侧整块 map 透传(FunctionParameters 就是 map[string]any),所以 type/properties/required/additionalProperties 全都能带过去。差异结论:目前 6 个工具只用了 type/properties/required/description 四个关键字,两条路径等价;一旦将来在 Parameters() 里加顶层关键字(如 additionalProperties、oneOf),OpenAI 会生效、Anthropic 会被静默丢弃(源码未体现任何兼容处理或告警)。

执行语义与并发安全 ​

go
// registry.go:80-90
// Execute 按名查找工具并执行。未知工具兜底为 IsError。
func (r *Registry) Execute(ctx context.Context, name string, args json.RawMessage) Result {
	t, ok := r.Get(name)
	if !ok {
		return Result{
			Content: fmt.Sprintf("未知工具: %s", name),
			IsError: true,
		}
	}
	return t.Execute(ctx, args)
}

是否有锁:没有。 Registry 是裸 map + slice,源码未体现任何 sync.* 原语(整个 registry.go 无 sync import)。它之所以在运行期没问题,依赖两条前提:

  1. 注册只发生在启动期(main.go / tui.go 构造阶段),此后 tools/order 只读;
  2. 读侧只有 map 查找和 slice 遍历,无写。

因此:Registry 本身不是并发安全的类型,但「启动期写 + 运行期读」的用法是并发安全的。若将来支持运行期热注册(例如 MCP server 动态上线新工具),必须自己加锁 —— 源码未体现。

Execute 作为方法本身无共享状态 → 并发调用安全;安全性实际取决于工具实例。6 个内置工具全是空结构体(type readFileTool struct{} 等),无字段、无缓存,因此并发执行安全。

真正的并发编排不在 tool 包,而在 agent 的 executeBatched(agent.go:516-979):

go
// agent.go:537-543
if a.registry.IsReadOnly(calls[i].Name) {
	// 吃入连续只读区间 [i, j)
	j := i
	for j < len(calls) && a.registry.IsReadOnly(calls[j].Name) {
		j++
	}
	...
	// agent.go:599-618
	// 并发执行未被拒的只读工具
	var wg sync.WaitGroup
	for k := i; k < j; k++ {
		if _, denied := preDenied[k]; denied {
			continue
		}
		wg.Add(1)
		go func(idx int) {
			defer wg.Done()
			tctx, cancel := context.WithTimeout(ctx, tool.DefaultTimeout)
			defer cancel()
			r := a.registry.Execute(tctx, calls[idx].Name, calls[idx].Input)
			results[idx] = llm.ToolResult{...}
		}(k)
	}
	wg.Wait()

语义总结:「保序分批并发」——只读连续段并发、每次 Execute 各自套一层 tool.DefaultTimeout(30s);写工具逐个串行并夹权限判定与人在回路;结果按 idx 写回预分配切片(不同索引写入 + WaitGroup 同步)→ 无数据竞争且回灌顺序与模型请求顺序一致。被拒(hook 拦截 / 权限 Deny)的调用不进入 goroutine,而是预填错误结果(agent.go:589-597)。

顺带一个易忽略的边界:Execute 的未知工具兜底不是唯一的防线,agent 还有「连续整轮未知工具」熔断(agent.go:372-376、agent.go:394-400,maxUnknownRun = 3;子 Agent 为 2,run_to_completion.go:20),依赖 allUnknown(agent.go:1024-1035)判定。

六个内置工具逐个剖析 ​

1. read_file ​

参数 schema(read_file.go:25-36)

go
return map[string]any{
	"type": "object",
	"properties": map[string]any{
		"path": map[string]any{
			"type":        "string",
			"description": "要读取的文件路径",
		},
	},
	"required": []string{"path"},
}

注意:没有 offset / limit 分页参数,也没有 max_lines 之类的逃生口——这是与 Claude Code 的 Read 最大的能力差(Claude Code 的 Read 支持 offset/limit,本项目只能读头部 2000 行)。

实现要点

go
// read_file.go:52-62 目录与存在性检查
info, err := os.Stat(a.Path)
if err != nil {
	if os.IsNotExist(err) {
		return errorResult("文件不存在: %s", a.Path)
	}
	return errorResult("无法读取文件: %v", err)
}
if info.IsDir() {
	return errorResult("路径是目录而非文件: %s", a.Path)
}

// read_file.go:64-80 读 + 行号 + 截断
data, err := os.ReadFile(a.Path)
...
lines := strings.Split(string(data), "\n")
for i := range lines {
	lines[i] = fmt.Sprintf("%6d\t%s", i+1, lines[i])
}
content := strings.Join(lines, "\n")
const maxLines = 2000
const maxChars = 256 * 1024
content = truncate(content, maxLines, maxChars)
  • os.Stat 先行 → 区分「不存在」「是目录」「权限问题」三种,全部走 errorResult,错误文案对模型是可执行信息。
  • 行号格式 %6d\t(cat -n 风格):每行前缀 7 字节(5 位数字 + Tab,超过 6 位宽会自然溢出不影响正确性);之所以带行号,是为了让 edit_file 的 old_string 定位有共同锚点、并让模型在回答里能引用行号。
  • 先全量读入内存再截断:os.ReadFile 无上限,256KB 上限只作用于「已加行号的字符串」,所以读一个 2GB 文件的内存峰值就是 2GB。源码未体现 io.LimitReader 或 os.Stat().Size() 预检。
  • 切分用 strings.Split(s, "\n"):文件以换行结尾时会产生一个尾部空元素 → 多出一行带行号的空行;这也是 tool_test.go:89-94 断言行号的方式("1\tline1"、"3\tline3")。

硬编码常量:maxLines = 2000、maxChars = 256*1024(read_file.go:78-79)。

错误语义:参数空 → 参数 path 不能为空;JSON 坏 → 参数解析失败: %v;不存在 → 文件不存在: %s;目录 → 路径是目录而非文件: %s;其它 stat 错 → 无法读取文件: %v;读失败 → 读取文件失败: %v。全部 IsError=true。

安全考量:工具层不做路径校验(无 .. 检查、无绝对路径拒绝、无符号链接检查)。它跟随符号链接读任意目标。安全边界完全在权限层:Engine.Check 对文件类工具调用 sandboxOK(permission/engine.go:112-119),后者做 filepath.Clean → evalSymlinksOrAncestor(不存在则回退到最近存在祖先解析,sandbox.go:24-49)→ 前缀比对 resolved == e.root || strings.HasPrefix(resolved, e.root+sep)(sandbox.go:71-72)。注意这是个「工具层不设防、权限层兜底」的架构选择——一旦有人绕过 Engine.Check 直接 registry.Execute(例如测试、子 Agent 的 dontAsk 路径仍走 Engine,但 hook 路径不走 Engine),文件访问就没有任何边界。

2. write_file ​

参数 schema(write_file.go:26-41)

go
"properties": map[string]any{
	"path":    map[string]any{"type": "string", "description": "要写入的文件路径"},
	"content": map[string]any{"type": "string", "description": "要写入的文件内容"},
},
"required": []string{"path", "content"},

实现要点

go
// write_file.go:56-67
// 创建父目录
dir := filepath.Dir(a.Path)
if err := os.MkdirAll(dir, 0o755); err != nil {
	return errorResult("创建父目录失败: %v", err)
}
// 写入文件
if err := os.WriteFile(a.Path, []byte(a.Content), 0o644); err != nil {
	return errorResult("写入文件失败: %v", err)
}
return Result{Content: fmt.Sprintf("已写入 %s(%d 字节)", a.Path, len(a.Content))}
  • 覆盖语义:无条件整文件覆盖,不做「文件已存在则先读」的检查、无备份、无 diff 预览,也不进 RecoveryState。
  • 父目录自动创建(MkdirAll 0755)——这是 tool_test.go:157-172 覆盖的嵌套路径场景;副作用是模型写错一层目录也会静默创建出目录树。
  • 权限位固定 0644:与 edit_file 保留原权限(edit_file.go:92-97)形成不一致;覆盖一个原本 0600 的敏感文件会把它放宽到 0644(受 umask 影响)。源码未体现权限保留或 O_CREATE|O_TRUNC 显式控制。
  • 成功文案里的 %d 字节 是 len(a.Content)(UTF-8 字节数),中文内容会显示约 3 倍字符数的值。
  • 无内容大小上限:模型可以一次写几十 MB(受限于模型输出长度,实际上限来自 provider 的 MaxTokens: 4096,anthropic.go:172)。

错误语义:参数 path 不能为空 / 创建父目录失败: %v / 写入文件失败: %v,均 IsError=true。

安全考量:同样没有 .. 检查、没有 filepath.IsAbs 拒绝、没有符号链接判定——依赖权限层 CategoryWrite 分类(settings.go:93-97)与沙箱前缀校验。写入目标在项目根之外会被 sandboxOK 拒(除非 bypassPermissions:modeFallback 里 mode == ModeBypass 直接 Allow,engine.go:145-147,但黑名单仍拦命令类、沙箱仍拦文件类,见 engine.go:106-119 位于 modeFallback 之前)。

3. edit_file ​

参数 schema(edit_file.go:31-50)

go
"properties": map[string]any{
	"file_path":  map[string]any{"type": "string", "description": "要编辑的文件路径"},
	"old_string": map[string]any{"type": "string", "description": "要在文件中定位并替换的文本"},
	"new_string": map[string]any{"type": "string", "description": "替换为新文本"},
},
"required": []string{"file_path", "old_string", "new_string"},

注意 file_path 与另外两个工具的 path 命名不一致(这是刻意的 Claude-Code 风格对齐,但让权限层不得不同时处理两种 key,见 permission/settings.go:123-133:extractTarget 对 read_file/write_file/edit_file 统一读 m["path"] → edit_file 的 file_path 实际取不到值!)细读 extractTarget:

go
// permission/settings.go:122-133
switch call.Name {
case "read_file", "write_file", "edit_file":
	v, exists := m["path"]
	if !exists {
		return "", true, false
	}
	...

edit_file 的参数里没有 path 键 → exists == false → 返回 ("", true, false) → Engine.Check 里 if !ok { return Deny, "无法解析文件路径参数,安全拒绝" }(engine.go:113-115)。这意味着走权限引擎主路径时 edit_file 会被一律 Deny。这是一个静态可验证的跨模块缺陷(工具层 key 与权限层 key 不一致),除非有其它路径改了参数名;edit_file.go:13-15 的结构体 tag 明确是 json:"file_path"。源码未体现任何兼容/映射逻辑。(同类检查:grep/glob 读 m["path"] 与 schema 一致,bash 读 m["command"] 一致。)

实现要点

go
// edit_file.go:68-90
// 安全检查: 路径不能包含 .. 越界
if strings.Contains(a.FilePath, "..") {
	return errorResult("安全限制: file_path 不支持路径穿越")
}
content, err := os.ReadFile(a.FilePath)
...
text := string(content)
// 检查 old_string 唯一性
count := strings.Count(text, a.OldStr)
if count == 0 {
	return errorResult("未找到匹配: old_string 在文件中不存在")
}
if count > 1 {
	return errorResult("old_string 在文件中不唯一: 出现 %d 次——请用 read_file 查看文件,选择唯一匹配片段", count)
}
// 执行替换(仅第一处——唯一时等价于唯一替换)
newText := strings.Replace(text, a.OldStr, a.NewStr, 1)

// edit_file.go:92-100 保留文件权限写回
info, _ := os.Stat(a.FilePath)
perm := os.FileMode(0644)
if info != nil {
	perm = info.Mode().Perm()
}
if err := os.WriteFile(a.FilePath, []byte(newText), perm); err != nil {
	return errorResult("写入文件失败: %v", err)
}
  • 唯一性强制是核心设计:count > 1 不是「替换第一处」,而是报错并要求模型缩小范围,把「如何消歧」的决策推回给模型(错误文案本身就是修复指令:请用 read_file 查看文件,选择唯一匹配片段)。
  • strings.Replace(..., 1) 的注释解释了为何用 1:唯一时二者等价。
  • 权限保留:os.Stat 取原 mode,失败则回退 0644(info, _ := 忽略错误,源码里是刻意容错)。
  • 读-改-写非原子:os.ReadFile → 内存替换 → os.WriteFile,中间没有锁、没有 mtime/哈希校验 → ① 外部进程在窗口期内改文件会被静默覆盖;② 同一轮里两个并发 edit_file 打同一文件会丢更新。不过 agent 层把非只读工具串行化了(agent.go:657+),同轮不会并发;跨轮/跨进程仍无保护。
  • 完全字节匹配:old_string 必须与文件字节一致(含 \r\n、缩进、尾随空格);无 CRLF 归一化、无空白宽松匹配、无 fuzzy 匹配、无 replace_all 参数。
  • new_string 允许为空串(可做纯删除),因为只校验了 path 与 old_string 非空(edit_file.go:61-66)。
  • 成功文案固定 文件 %s 已编辑: 1 处替换(edit_file.go:102)——不返回 diff 或上下文,模型看不到改动后的样子(要再看必须再 read_file,多一轮往返)。

错误语义(四种,全部 IsError=true):参数 file_path 不能为空、参数 old_string 不能为空、安全限制: file_path 不支持路径穿越、未找到匹配: old_string 在文件中不存在、old_string 在文件中不唯一: 出现 %d 次…、读取文件失败: %v、写入文件失败: %v。对应测试:tool_test.go:196-250。

安全考量:唯一的工具层路径校验是 strings.Contains(a.FilePath, "..")。两个问题:① 这是子串判断而非路径分段判断 → 合法文件名被误杀,例如 pkg/a..go、internal/foo..bar/baz.go;② 它给 edit_file 一种「有防护」的错觉,而 read_file/write_file 完全没有同类检查,真正的边界在权限层。符号链接方面:os.ReadFile 会跟随符号链接,若目标是符号链接则替换的是链接指向的真实文件内容(不是替换链接本身),越界与否由 sandboxOK 的 EvalSymlinks 决定。

4. bash ​

参数 schema(bash.go:36-59)

go
"properties": map[string]any{
	"command":     map[string]any{"type": "string", "description": "要执行的 shell 命令"},
	"description": map[string]any{"type": "string", "description": "命令用途说明"},
	"timeout":     map[string]any{"type": "integer", "description": "超时(毫秒),默认 120000"},
	"workdir":     map[string]any{"type": "string", "description": "工作目录,不填则使用当前工作目录"},
},
"required": []string{"command"},

description 字段被声明但执行时完全未使用(Execute 只读 Command/Timeout/Workdir,bash.go:66-90)——它是给审批 UI/人工看的信息,实际未消费(源码未体现它进入 ToolEvent.Args,argPreview 只 prefer path/command/pattern/old_string,agent.go:1068)。

实现要点

go
// bash.go:74-98
timeout := time.Duration(a.Timeout) * time.Millisecond
if a.Timeout <= 0 {
	timeout = 120 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

// 安全检查: workdir 不能使用绝对路径或路径穿越
if a.Workdir != "" {
	if filepath.IsAbs(a.Workdir) || strings.Contains(a.Workdir, "..") {
		return errorResult("安全限制: workdir 不支持绝对路径或路径穿越")
	}
}

cmd := exec.CommandContext(ctx, "sh", "-c", a.Command)
cmd.Dir = a.Workdir

// 不继承环境变量(N5: 密钥不泄漏)
cmd.Env = []string{
	"HOME=" + os.Getenv("HOME"),
	"PATH=" + os.Getenv("PATH"),
	"USER=" + os.Getenv("USER"),
	"TERM=" + os.Getenv("TERM"),
}
go
// bash.go:100-142 输出合并 + 截断 + 错误语义
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()

output := stdout.String()
if stderr.Len() > 0 {
	if output != "" { output += "\n" }
	output += stderr.String()
}

// 结果截断
output = strings.TrimSpace(output)
output = truncate(output, 200, 8000)

if err != nil {
	if ctx.Err() == context.DeadlineExceeded {
		return errorResult("命令超时: %s", err)
	}
	// 提取退出码
	exitCode := -1
	if exitErr, ok := err.(*exec.ExitError); ok {
		exitCode = exitErr.ExitCode()
	}
	if output == "" {
		return Result{Content: fmt.Sprintf("exit_code: %d\n命令执行失败: %v", exitCode, err), IsError: false}
	}
	return Result{Content: fmt.Sprintf("exit_code: %d\n%s", exitCode, output), IsError: false}
}

if output == "" {
	output = "(无输出)"
}
return Result{Content: "exit_code: 0\n" + output}

硬编码常量:内置默认超时 120 * time.Second(bash.go:76);截断 truncate(output, 200, 8000) → 200 行 / 8000 字节(bash.go:116);固定 sh -c(不是 bash -c,也不是用户的 $SHELL);env 白名单固定 4 个变量。

超时双轨(重要坑):agent 每次执行都先套 context.WithTimeout(ctx, tool.DefaultTimeout)(30s,agent.go:608、agent.go:879、agent.go:930、agent.go:1004),bash 内部再派生子 context(120s)。context 的语义是取最紧的 deadline → 实际生效上限是 30s;模型把 timeout 调到 60000 或更大不会生效(除非调用方不经 agent 直接 Execute)。测试里用的是自建 5s ctx(tool_test.go:259)。

错误语义(分层刻意不同):

  • 超时(ctx.Err() == context.DeadlineExceeded)→ errorResult("命令超时: %s"),IsError=true(bash.go:119-121)。
  • 非零退出码 → IsError=false,内容形如 exit_code: 1\n<output>(bash.go:133-136)。意图是让模型把「命令失败」当成正常观察值去分析(编译错误、测试失败都属于这种),而不是被当成工具故障去重试。tool_test.go:289-304 对这个语义做了「两种都接受」的宽松断言。
  • 无法拿到退出码(如 cmd.Dir 不存在导致 fork 失败)→ exitCode = -1 + 命令执行失败: %v,仍然是 IsError=false。
  • 用户取消(context.Canceled)不等于 DeadlineExceeded → 落入非零退出分支,exit_code: -1、IsError=false;不过 agent 在批处理层面会先把结果替换为 noticeCancelled 并终止循环(agent.go:523-535、agent.go:965-976),所以这个细节通常不外显。
  • stdout 与 stderr 合并(stdout 在前,空行分隔),模型无法区分两者;截断发生在合并之后 → 输出爆长时被切掉的总是 stderr 尾部。
  • strings.TrimSpace 会吃掉首尾空白(对 xxd、od 这类依赖前导对齐的输出有损)。

安全考量(这是整个工具包风险最集中的地方):

  1. 无命令注入防护,也不需要:command 本身就是交给 sh -c 的任意程序(bash.go:89),模型输什么就跑什么;不存在「把用户输入拼进命令」的二次注入面。真正的防护在权限层:命令串黑名单(permission/blacklist.go:10-40,10 条正则:rm -rf /、dd of=/dev/sd*、fork 炸弹、mkfs.、> /dev/sd*、chmod -R 777 /etc 等)+ 三段规则引擎 + 模式矩阵。脚本自陈「启发式、非完备、不可配置放开」,且黑名单仅在 cat == CategoryExec && target != "" 时生效(engine.go:106-109)——extractTarget 解析不出 command 时会 target="",于是完全不查黑名单(settings.go:148-158:command 键缺失或类型不符 → 返回 ok=false,同时 target="")。绕过黑名单的常规手法(base64 解码执行、变量拼接、python -c)源码层完全没有覆盖。
  2. 环境变量白名单:只传 HOME/PATH/USER/TERM(bash.go:93-98),其余(AWS_*、GITHUB_TOKEN、OPENAI_API_KEY...)不进子进程 → 兑现「密钥不泄漏」(N5)。代价:GOPROXY/HTTP_PROXY/JAVA_HOME/NODE_OPTIONS/GOPATH 等开发必需变量也丢了,模型跑 go build/npm install 时的行为可能和用户终端不一致(离线环境尤其明显)。
  3. 无沙箱:无容器、无 namespace、无 seccomp、无 rlimit、无网络隔离;命令以当前用户身份全权限运行,唯一约束是黑名单 + 审批 + 30s 超时。
  4. 超时后子进程可能残留:exec.CommandContext 在 ctx 到期时 Kill 的是直接子进程(sh),没有设置 SysProcAttr.Setpgid + 进程组 Kill → sh -c "sleep 100 &" 或后台服务类命令的孙子进程可能继续存活(源码未体现进程组管理)。
  5. workdir 校验不对称:只校验 IsAbs 与子串 ..,不校验「是否在项目根内」(这是权限层 extractTarget 不覆盖 bash 的路径字段导致的——bash 的目标串是 command 而非 workdir)。同时 workdir 是相对 cmd.Dir 的相对当前进程 cwd 解析的,不是相对项目根。

5. glob ​

参数 schema(glob.go:28-43)

go
"properties": map[string]any{
	"pattern": map[string]any{"type": "string", "description": "glob 模式,如 **/*.go、*.md"},
	"path":    map[string]any{"type": "string", "description": "搜索起始路径,默认为当前工作目录"},
},
"required": []string{"pattern"},

实现要点

go
// glob.go:58-96
root := a.Path
if root == "" { root = "." }

var matches []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
	// 检查 ctx 是否已取消
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	if err != nil {
		return nil // 跳过无法访问的路径
	}

	// 跳过隐藏目录
	if d.IsDir() && strings.HasPrefix(d.Name(), ".") && d.Name() != "." {
		return fs.SkipDir
	}
	if d.IsDir() { return nil }

	relPath, err := filepath.Rel(root, path)
	if err != nil { relPath = path }

	if matchGlob(a.Pattern, relPath) {
		matches = append(matches, relPath)
	}
	return nil
})
go
// glob.go:102-113 无匹配 / 排序 / 截断
if len(matches) == 0 {
	return Result{Content: fmt.Sprintf("无匹配到模式 %s 的文件", a.Pattern)}
}

// 排序并限制 ≤100
sort.Strings(matches)
if len(matches) > 100 {
	matches = matches[:100]
	matches = append(matches, "[truncated]")
}

return Result{Content: strings.Join(matches, "\n")}

自实现的 ** 匹配(标准库 filepath.Match 不支持 **):

go
// glob.go:116-127
func matchGlob(pattern, path string) bool {
	pattern = filepath.ToSlash(pattern)
	path = filepath.ToSlash(path)
	patParts := strings.Split(pattern, "/")
	pathParts := strings.Split(path, "/")
	return matchSegments(patParts, pathParts)
}

// glob.go:129-159:递归 DP
// ** 匹配 0 层(跳过 **)或 1+ 层(跳过 path 首段)
if pat[0] == "**" {
	return matchSegments(pat[1:], path) || matchSegments(pat, path[1:])
}
matched, _ := filepath.Match(pat[0], path[0])
  • 逐段递归展开,** 有两种分支(吃掉 0 段或吃掉 1 段);单段交给 filepath.Match(支持 *、?、[a-z],不支持 {a,b} 花括号展开,且 filepath.Match 的 error 被丢弃 matched, _ :=——[[ 未闭合这类非法 pattern 会静默匹配失败而不是报错])。
  • 匹配对象是相对 root 的路径(glob.go:86-92),所以 *.go 只匹根层、**/*.go 才能递归——与用户直觉常不符,但描述里给了例子。
  • path 为空时 root = ".",返回结果是相对路径(internal/tool/tool.go 这种),不是绝对路径。

硬编码常量/行为:结果上限 100(glob.go:108)+ 追加 [truncated] 元素;跳过 . 开头的目录(glob.go:77-79,注意只跳目录,隐藏文件如 .gitignore 仍会出现在 **/* 结果里);无 maxDepth、无结果大小上限(只限条数)。

错误语义:参数 pattern 不能为空;遍历出错(唯一来源是 ctx 取消 → ctx.Err())→ 遍历文件失败: %v(IsError=true,glob.go:98-100);无匹配不是错误(返回普通 Result,tool_test.go:339-352 明确断言 IsError == false);单个路径访问错误被静默吞掉(return nil,glob.go:72-74)。

安全考量:filepath.WalkDir 使用 Lstat 语义,不跟随目录符号链接 → 不会因链接逃出 root;跳过隐藏目录顺带避免了遍历 .git(同时也意味着 .github/、.vscode/ 里的文件找不到了)。path 参数本身可以是任意目录(含绝对路径 "/"),会全盘遍历——工具层没有任何 root 约束,只有权限层 sandboxOK(extractTarget 对 glob/grep 的 path 做文件类校验,settings.go:134-146,空值默认 ".")。ctx 取消检查在每个目录项上做(glob.go:66-70),响应及时。

6. grep ​

参数 schema(grep.go:39-58)

go
"properties": map[string]any{
	"pattern": map[string]any{"type": "string", "description": "搜索的正则表达式(RE2 语法)"},
	"path":    map[string]any{"type": "string", "description": "搜索起始路径,默认为当前工作目录"},
	"glob":    map[string]any{"type": "string", "description": "文件名过滤模式,如 *.go(仅搜索匹配文件)"},
},
"required": []string{"pattern"},

实现要点

go
// grep.go:73-85
re, err := regexp.Compile(a.Pattern)
if err != nil {
	return errorResult("正则表达式无效: %v", err)
}
root := a.Path
if root == "" { root = "." }

var matches []grepMatch
maxResults := 100
longLineWarned := false
go
// grep.go:99-126 遍历 + 过滤 + 提前退出
// 跳过隐藏目录
if d.IsDir() && strings.HasPrefix(d.Name(), ".") && d.Name() != "." {
	return fs.SkipDir
}
if d.IsDir() { return nil }

// glob 文件名过滤
if a.Glob != "" {
	matched, _ := filepath.Match(a.Glob, d.Name())
	if !matched { return nil }
}

// 已收集足够结果则停止
if len(matches) >= maxResults {
	return fs.SkipAll
}

relPath, _ := filepath.Rel(root, path)
fileMatches := grepFile(re, path, relPath, &longLineWarned, maxResults-len(matches))
matches = append(matches, fileMatches...)
go
// grep.go:137-156 排序 + 渲染
sort.Slice(matches, func(i, j int) bool {
	if matches[i].file != matches[j].file {
		return matches[i].file < matches[j].file
	}
	return matches[i].lineNum < matches[j].lineNum
})

var out strings.Builder
for _, m := range matches {
	fmt.Fprintf(&out, "%s:%d: %s\n", m.file, m.lineNum, m.content)
}
if len(matches) >= maxResults {
	out.WriteString("[truncated]\n")
}
if longLineWarned {
	out.WriteString("[warning: 部分超长行被跳过,搜索结果可能不完整]\n")
}
return Result{Content: strings.TrimRight(out.String(), "\n")}
go
// grep.go:160-192 单文件扫描
scanner := bufio.NewScanner(f)
// 设置 1MB 缓冲区限制,超出则标注
scanner.Buffer(make([]byte, 0, 64*1024), 1*1024*1024)

lineNum := 0
for scanner.Scan() {
	lineNum++
	line := scanner.Text()
	if re.MatchString(line) {
		// 截断超长行
		if len(line) > 500 {
			line = line[:500] + "..."
		}
		matches = append(matches, grepMatch{relPath, lineNum, line})
		if len(matches) >= max { return matches }
	}
}
if scanner.Err() != nil && !*longLineWarned {
	*longLineWarned = true
}

硬编码常量:maxResults = 100(grep.go:85);单行截断阈值 500 字符 + "..."(grep.go:179-181);scanner 初始缓冲 64KB、行长上限 1MB(grep.go:171)。

错误语义:参数 pattern 不能为空;非法正则 → 正则表达式无效: %v(IsError=true,tool_test.go:394-404);遍历失败 → 搜索过程中出错: %v;无命中不是错误(未找到匹配 %s 的内容,tool_test.go:378-392)。

安全考量与语义陷阱:

  • 正则引擎是 Go 的 regexp(RE2,线性时间、无回溯)→ 天然免疫 ReDoS,代价是不支持反向引用与 lookaround;description 里明确告诉模型「RE2 语法」,减少无效调用。
  • 二进制文件无跳过:源码未体现 NUL 字节检测或扩展名黑名单 → 扫到二进制会产出乱码行(可能污染上下文)。
  • [truncated] 可能是假阳性:条件写的是 len(matches) >= maxResults(grep.go:149),当命中数恰好=100 且全库再无其它命中时也会打标记;与 glob 的 > 100 严格判断不一致。
  • 超长行警告的置位条件是「scanner.Err() != nil」(grep.go:190-192),任何 scanner 错误(含读取中途 I/O 错误、1MB 行长溢出)都会置位同一句文案,精度有限。
  • 排序是字典序(文件名字典序 + 行号升序):确定性好、便于 diff 与缓存,但未按相关性排序(命中次数多的文件不会靠前)。
  • 逐行匹配(re.MatchString(line))→ 不支持跨行正则,也没有 -i、-w、-A/-B/-C 上下文行、-v 反选等参数。
  • ctx 检查同样在目录项粒度(grep.go:89-93);SkipAll 是提前退出的机制(grep.go:117-119),但已打开的其它文件句柄靠 defer f.Close()(grep.go:167)——因为串行遍历,实际任一时刻只开一个。

filter.go 与 install_skill/load_skill ​

filter.go:子 Agent 的工具名过滤策略 ​

它不是「工具实现」,而是策略常量 + 纯函数,服务于子 Agent(Agent 工具)在启动时计算自己的工具白名单。

go
// filter.go:3-19
// ALL_AGENT_DISALLOWED_TOOLS 是任何子 Agent 永远不能用的工具名列表(spec F26)。
// 本期最小列表:Agent。后续可扩展 AskUserQuestion / TaskStop 等。
var ALL_AGENT_DISALLOWED_TOOLS = []string{"Agent"}

// CUSTOM_AGENT_DISALLOWED_TOOLS 是自定义(user/project/plugin 来源)Agent 比内置 Agent 多禁用的工具(spec F27)。
// 本期为空,接口预留。
var CUSTOM_AGENT_DISALLOWED_TOOLS = []string{}

// ASYNC_AGENT_ALLOWED_TOOLS 是后台 Agent 工具白名单(spec F28)。
var ASYNC_AGENT_ALLOWED_TOOLS = []string{
	"read_file", "write_file", "edit_file",
	"glob", "grep",
	"bash",
	"load_skill", "install_skill",
}

五层过滤流水线(filter.go:30-62):

go
func ApplyAgentToolFilter(p FilterParams) []string {
	// 1. 起点:全部工具副本
	result := make([]string, len(p.All)); copy(result, p.All)
	// 2. 去掉 ALL_AGENT_DISALLOWED_TOOLS
	result = removeTools(result, ALL_AGENT_DISALLOWED_TOOLS)
	// 3. 如果是后台 → 与 ASYNC_AGENT_ALLOWED_TOOLS + MCP/Skill 取交集
	if p.Background { result = intersectAsyncTools(result) }
	// 4. 去掉定义的 disallowedTools 黑名单
	if len(p.Disallowed) > 0 { result = removeTools(result, p.Disallowed) }
	// 5. 如果定义了 tools 白名单 → 取交集
	if len(p.Allowed) > 0 { result = intersectTools(result, p.Allowed) }
	return result
}

要点与缺陷:

  • 顺序语义清晰:「先砍永远不能用的(递归 Agent)→ 再收窄后台能力 → 再应用 Agent 自定义黑白名单」,deny 在 allow 之前。
  • FilterParams.Source 字段被声明(filter.go:24)但函数体内完全未使用——CUSTOM_AGENT_DISALLOWED_TOOLS 因此也无消费点,是纯接口预留(源码注释已自陈「本期为空,接口预留」)。
  • 动态放行只认 mcp__ 前缀:isAsyncAllowed(filter.go:113-122)if len(name) >= 5 && name[:5] == "mcp__" { return true },其余一律 false;注释说 Skill 工具「已在白名单中」。
  • 真实缺陷:白名单里写的是 "load_skill"/"install_skill"(snake_case),而注册中心里的名字是 "LoadSkill"/"InstallSkill"(load_skill.go:23、install_skill.go:31,tui.go:230-238 注册的就是这两个实例)。intersectAsyncTools 用 All = registry.Names()(agent_tool.go:172-178)逐名匹配 → 两个 Skill 工具在后台 Agent 里被静默剔除;LoadSkillTool 虽是系统工具,但 ApplyAgentToolFilter 只输出名字列表,而豁免逻辑只在 Registry.DefinitionsFiltered(registry.go:107-115)里生效——而 run_to_completion.go:84-86 只在「非 Plan 且 allowedTools 非空」时才走 DefinitionsFiltered。「系统工具豁免」在子 Agent 路径上实际失效(后台子 Agent 拿不到 LoadSkill)。这是一个跨文件、可静态验证的命名不一致问题,值得作为「设计/评审」样本。

load_skill.go:把 Skill SOP 钉进环境上下文 ​

LoadSkillTool 是有状态工具(持有 *skills.Executor),不走 NewDefaultRegistry(),由 TUI 构造期注入(tui.go:229-231)。

go
// load_skill.go:13-15
type LoadSkillTool struct {
	executor *skills.Executor
}

// load_skill.go:23-24, 44-48
func (t *LoadSkillTool) Name() string { return "LoadSkill" }
...
// ReadOnly 返回 true——LoadSkill 无外部副作用。
func (t *LoadSkillTool) ReadOnly() bool { return true }
// IsSystem 返回 true——系统工具,不受白名单约束。
func (t *LoadSkillTool) IsSystem() bool { return true }
go
// load_skill.go:66-75
if t.executor == nil {
	return errorResult("Skill 执行器未初始化")
}
_, err := t.executor.RenderAndActivate(a.Name, "")
if err != nil {
	return errorResult("激活 Skill 失败: %v", err)
}
return Result{Content: fmt.Sprintf("Skill %s 已激活,SOP 已钉到环境上下文。", a.Name)}
  • 参数只有 name(无 args)→ RenderAndActivate(name, "") 把参数渲染为空串(skills/executor.go:116-124:RenderBody(skill, args) → host.ActivateSkill(name, body) → Agent 侧实现为 runtime.ActiveSkills.Activate,agent.go:133-139)。
  • 返回值刻意极简(一句话),因为真正的 SOP 正文是通过 ActiveSkills 在每轮重建的 env 段里注入的(agent.go:266-277 prompt.RenderActiveSkillsBlock)——工具结果不含正文,避免正文在历史里重复一份(省 token)。
  • ReadOnly=true + IsSystem=true 是「只读但要始终可见」的组合:既能进 Plan Mode 工具集,也不会因为白名单收窄而消失(在 DefinitionsFiltered 路径上)。
  • 语义上它是Skill 的「渐进式披露」第二级:系统提示里先给 Skill 目录(prompt/modules.go:80 的 skills-catalog,agent.go:185-192),模型按需 LoadSkill 拉全文。这与 Claude Code 的 Skill 二阶段加载(catalog → Skill 工具加载全文)是同一设计思路。

install_skill.go:从 URL 远程安装 Skill ​

go
// install_skill.go:15-19
type InstallSkillTool struct {
	workDir    string
	catalog    *skills.Catalog
	onInstalled func(name string) // 安装后回调(注册命令)
}

// install_skill.go:52-53
// ReadOnly 返回 false——InstallSkill 有写盘 + 网络副作用。
func (t *InstallSkillTool) ReadOnly() bool { return false }
go
// install_skill.go:71-98
name, apiURL, err := skills.ParseSkillURL(a.URL)
if err != nil { return errorResult("URL 解析失败: %v", err) }

home, err := os.UserHomeDir()
if err != nil { return errorResult("获取用户目录失败: %v", err) }
installRoot := filepath.Join(home, ".mewcode", "skills")

report, err := skills.Install(name, apiURL, installRoot)
if err != nil { return errorResult("安装失败: %v", err) }

// 重载 Catalog
if t.catalog != nil { t.catalog.Reload(t.workDir) }
// 回调注册命令
if t.onInstalled != nil { t.onInstalled(name) }
  • 与 load_skill 的对照:InstallSkill 是普通工具(ReadOnly=false,受权限模式约束,写盘+联网需要审批),LoadSkill 是系统工具(只读、始终可见)——「读便宜、写昂贵」的权限分级。
  • 安装限额在 internal/skills/install.go:16-22:单文件 1 MiB、总量 8 MiB、文件数 64、目录深度 4、HTTP 超时 60s;下载到 staging temp dir → 校验必须含 SKILL.md(install.go:96-100)→ os.Rename 原子落位到 ~/.mewcode/skills/<name>(install.go:102-111)。
  • 支持三种 URL(ParseSkillURL,install.go:36-75):skills.sh/<org>/<name>/<version>、github.com/<owner>/<repo>/tree/<ref>/<path>、raw.githubusercontent.com/...;未知 host 直接报错。skills.sh 与 github.com 实际都转成 GitHub Contents API,隐含依赖 GitHub 可用性(403 会报 rate limit)。
  • 安装成功后重载 Catalog 并回调注册 /skillname 命令(tui.go:234-237),实现「装完即可用」。

三者与 Skill 系统的关系(一张图) ​

系统提示中的 Skill 目录(prompt/modules.go:80, agent.go:185-192)
        │  模型看到 name + description
        ▼
LoadSkill(name)  ──► skills.Executor.RenderAndActivate ──► Agent.ActivateSkill
(工具入口,系统工具)                                        └─► runtime.ActiveSkills
                                                              └─► 每轮 env 段注入 SOP 正文
InstallSkill(url) ──► skills.ParseSkillURL → skills.Install(限额+staging+原子 rename)
(工具入口,普通工具)      └─► catalog.Reload(workDir) + onInstalled → 注册 /<name> 命令
filter.go(ASYNC_AGENT_ALLOWED_TOOLS)──► 决定后台子 Agent 能否看见这两个工具(当前有命名 bug)

输出截断策略 ​

按「谁截、截多少、为什么」汇总(工具包内 + 紧邻的 agent/compact 层,因为它们是同一套 token 预算工程的不同闸门):

层位置常量行为与动机
通用tool.go:29-39调用方给 maxLines、maxChars先按行、再按字节;尾部追加 \n[truncated]
read_fileread_file.go:78-80maxLines=2000、maxChars=256*10242000 行是「整文件编辑器视图」的惯例上限(Claude Code Read 同值);256KB 兜底防单行巨长文件
bashbash.go:115-116truncate(output, 200, 8000)命令输出极易爆炸(cat 大文件、日志、go test ./...);200 行/8KB 是为了把「够判断成败的上下文」留下,同时不挤爆窗口
globglob.go:106-111>100 → 截前 100 + [truncated]条数上限;先 sort.Strings → 截断结果确定可复现(对测试与缓存友好)
grepgrep.go:85,117-119,149-151maxResults=100条数上限;配合 fs.SkipAll 提前终止遍历(不是先收集再截),必要时给出 [truncated](可能是假阳性:>= 判断)
grep 行内grep.go:179-181>500 → 前 500 + "..."压缩压缩过后的单行——minify 的 JS/一行超长 JSON 会让「一行命中」吃掉整个预算
grep 行长grep.go:171,190-192scanner 上限 1MB(初始 64KB)防单行撑爆内存;超限触发 scanner.Err() → 置位并输出 [warning: 部分超长行被跳过,搜索结果可能不完整](向模型显式声明结果不完整,是很好的一步)
grep 末尾grep.go:156—strings.TrimRight(..., "\n") 去掉尾换行
glob/grep 目录glob.go:77-79、grep.go:100-102跳过 . 开头目录不是截断但属于「裁剪」:直接不进入 .git/.cache,避免无意义的遍历与噪声
agent 事件摘要agent.go:1072-107460 字符argPreview 只给 TUI 显示 ● tool(args)
agent 结果摘要agent.go:1089-10968 行 + ...truncateLines 在 ToolEvent.Result 上——只影响 UI,不影响回灌给模型的 Content(这点容易看错)
compact 单条compact/const.go:7,950000 字节 / 聚合 200000 字节超阈值 → 落盘 SpillDir/<tool_use_id>,用 [content offloaded] original size: %d bytes + [saved to] <path> + 前 20 行/2048 字节预览替换(layer1.go:24-45)
compact 预览compact/const.go:44-4520 行 / 2048 字节headPreview(layer1.go:23-34)
compact 恢复段compact/const.go:24-25最近 5 个文件 × 5000 token摘要后重挂「最近读过的文件」+ 当前工具列表(recovery.go:19-45)

为什么这么设计(可讲的逻辑链):

  1. 两道闸门分工:工具层截断是「单次观察的卫生」,compact 层截断是「历史累积的卫生」。工具层不为 token 总量负责(它没有全局视野),compact 层不知道单次语义(它只按字节切)。
  2. 行数优先、字节兜底:行数保证结构可读(模型靠行号定位),字节数防「一行 10MB」。两者的组合正好覆盖最常见的两种爆炸形态。
  3. 截断即告知:[truncated]、[warning: ...]、[content offloaded] 三处都显式声明,且 compact 的预览文案还带行动指令(如需查看请用文件读取工具读取该路径,不要凭头部预览猜测全文,layer1.go:43)——防止模型把「部分」当「全部」。
  4. 已知瑕疵:truncate 用字节切片 → 可能产出非法 UTF-8(应在 rune 边界回退);[truncated] 位置在不同工具不一致(切片末元素 vs 文本后缀);glob 的 >100 与 grep 的 >=100 不一致;工具层截断不给落盘路径(而 compact 层给了)→ 模型看到 [truncated] 只能重读或换命令,无法拿到被截掉的部分。

设计决策与权衡 ​

1. 决策:Execute 返回 Result 值而不是 (string, error),错误靠 IsError 标记。

  • 为什么:工具失败几乎都是「预期的业务失败」(文件不存在、正则非法、命令非零退出),必须原样回灌给模型让它自我纠错;Go 的 error 语义会诱导调用方 early-return 并把错误抛给用户,破坏 ReAct 环。errorResult 统一兜底,匹配「绝不 panic」的包级契约(tool.go:1-3)。
  • 替代方案:返回 (Result, error) 交由 agent 判断;或 panic + recover。前者让 6 个工具各写一遍错误包装,后者不适合长驻进程。

2. 决策:参数 Schema 手写 map[string]any,不用结构体反射/代码生成。

  • 为什么:description 文案是工具选择率的主要抓手,需要逐字推敲(例:edit_file.go:27-28 用一整句阻止「未读就改」);同时避免引入 schema 生成库。测试也直接断言 schema 非空(tool_test.go:33-41)。
  • 替代方案:从 struct tag 生成(易漏 description 文案、且 old_string 这类需要长解释的字段不适合 tag);官方 SDK 的类型化 schema(会绑定协议、污染 tool 包)。

3. 决策:Registry 不加锁。

  • 为什么:注册发生在启动期,运行期只读;加锁会给出「支持热注册」的错觉,而热注册还牵扯工具定义变更 → prompt 缓存失效 → 需要更多一致性设计(源码未体现)。
  • 替代方案:sync.RWMutex 或 sync.Map(后者丢失顺序,会破坏 order 语义,不可行);或干脆用不可变快照 + atomic.Pointer。

4. 决策:一个 ReadOnly() 同时驱动「并发分组」与「Plan Mode 裁剪」。

  • 为什么:两个判断的语义完全重合(「无副作用」),合并可减少实现者出错面(漏写一个方法会导致工具在 Plan Mode 里消失)。
  • 替代方案:独立的能力位(Capabilities() ToolCaps,可表达 ReadOnly/Idempotent/Destructive/Network)。目前无法表达「只读但慢」「可并发但不幂等」这类细分(例如 MCP 只读工具是远程调用,并发是否安全无法声明——mcpTool.ReadOnly 直接取 readOnlyHint,mcp/tool.go:49,136)。

5. 决策:并发只在「连续只读区间」内做,写工具一律串行。

  • 为什么:agent.go:537-543 的区间贪心实现简单、无死锁风险,并且保证副作用顺序(模型可能依赖上一条 bash mkdir 完成后才 write_file);同时结果写回同长度切片、WaitGroup 同步,天然保序。
  • 替代方案:全并发 + 依赖分析(复杂且模型参数里没有依赖声明);顺序树(并发只读、写操作成为屏障——本项目等价于这个的简化版)。

6. 决策:edit_file 要求 old_string 全文件唯一,count>1 直接报错。

  • 为什么:把「消歧」的责任交回模型(错误文案自带修复指令),比「替换第一处」这种静默错误安全得多;strings.Count 成本可忽略。
  • 替代方案:replace_all 参数(本项目未提供);行号 + 范围定位(对模型要求更高、更易偏移);模糊匹配(Aider 风格,容错但可能改错地方)。

7. 决策:路径安全分层——工具层基本不校验,权限层做沙箱与黑名单。

  • 为什么:只有权限层知道项目根(Engine.root 经 EvalSymlinks 解析,sandbox.go:10-16),工具层没有根的概念;把根感知下放到工具会让每个工具都要注入配置。
  • 替代方案:工具构造时注入 root 并各自校验(重复代码但 Defense in Depth)。现实代价:edit_file 那个孤立的 .. 子串检查既不成体系(read/write 没有)又会误伤(a..go 被拒),而 edit_file 的参数名 file_path 又和权限层的 m["path"] 读取不匹配 → 主路径上一律 Deny(settings.go:122-133 + engine.go:113-115)。这说明「分层」必须配一个显式的契约测试。

8. 决策:bash 子进程只继承 4 个环境变量。

  • 为什么:凭证(*_TOKEN、*_API_KEY)绝不进模型可触发的子进程(N5);白名单优于黑名单(未知变量默认不传)。
  • 替代方案:全量继承 + 敏感变量黑名单(漏一个就漏全部);容器内执行 + 显式 env 注入(最正解,但工程量大)。代价是开发工具链变量缺失导致的「行为漂移」。

9. 决策:bash 非零退出码 IsError=false,超时 IsError=true。

  • 为什么:非零退出是「有效观察」——编译错误、测试失败、grep 没匹配(返回 1)都是模型的正常工作输入;而超时意味着「没有拿到有效观察」,需要模型改变策略(拆分命令/加超时)。
  • 替代方案:一律 IsError=false(模型可能对超时反复重试同一条命令);一律 IsError=true(模型会把退出 1 当作工具故障而道歉)。测试对这个语义做了宽松处理(tool_test.go:296-303),说明这确实是权衡点。

10. 决策:bash 超时「双层」——tool.DefaultTimeout=30s 外层 + 参数 120s 内层。

  • 为什么:外层是 agent 的统一保护(所有工具都套,agent.go:608 等),内层是 bash 自己的兜底(直接调用 Execute 的测试/子路径)。
  • 权衡/坑:context 取最紧 deadline → 模型无法通过 timeout 参数把命令跑超过 30 秒,而 schema 里的描述仍写「默认 120000」,形成「文档与行为不一致」;同时外层超时的文案是统一的「命令超时: context deadline exceeded」,模型无法区分是哪一层掐的。替代方案:让 DefaultTimeout 可配(注释明确写「不可配」,registry.go:12)或让 bash 把外层 deadline 视为可协商。

11. 决策:glob 自实现 ** 匹配(分段递归 DP),不引第三方库。

  • 为什么:标准库不支持 **;自己实现约 40 行,零依赖,且能顺手把 Windows 路径分隔符归一(filepath.ToSlash,glob.go:120-121)。
  • 替代方案:doublestar 库(更完整:支持 {a,b}、[]、!);调 find/rg --files(性能好但引入外部进程依赖与转义问题)。代价:无花括号展开、filepath.Match 的 error 被吞、** 未做记忆化(最坏是指数级重复子问题,但对真实路径深度影响可忽略)。

12. 决策:grep 自己遍历,不 shell out 到 ripgrep。

  • 为什么:跨平台(不假设用户装了 rg)、结果结构可控(file:line:content 格式由自己保证)、无子进程注入面、RE2 保证线性时间。
  • 替代方案:rg --json 子进程(性能通常 5–20×,且原生支持 .gitignore、二进制检测、并行遍历)。代价:无 .gitignore 支持 → 会扫到 node_modules、dist、vendor 的噪声(且 100 条上限会被噪声吃掉,导致「真正命中被挤出」)。

13. 决策:截断按字节做(len(s))。

  • 为什么:实现最短,且与 token 预算的粗糙估算对齐(compact 层也用 estimateCharsPerToken = 3.5 这种字符近似,compact/const.go:47)。
  • 替代方案:utf8.RuneCountInString + 在 rune 边界回退(正确性更好);按 token 精确截断(需 tokenizer,重)。代价:中文/emoji 内容可能被切断成非法 UTF-8,下游若要 json.Marshal 会得到替换字符或报错(Go 的 json.Marshal 会把非法 UTF-8 替换为 \ufffd,不报错,所以问题会被掩盖)。

14. 决策:工具名混用 snake_case(内置)与 PascalCase(Skill/MCP)。

  • 为什么:内置 6 个工具对齐 Claude Code 的 read_file/glob/grep 风格;LoadSkill/InstallSkill 对齐命令名与 Skill 生态;MCP 强制 mcp__<server>__<tool> 前缀去重(filter.go:113-122)。
  • 权衡:权限层的 friendlyName 需要维护映射表(settings.go:68-85 → Bash/Read/Write/Edit/Glob/Grep),filter.go 的白名单因此写错(load_skill vs LoadSkill)。替代方案:单一命名规范 + 显式 alias 表(当前是隐式约定,无测试守护)。

15. 决策:SystemTool 用可选接口(类型断言)而非接口方法。

  • 为什么:只有 1/N 个工具需要,避免所有实现写样板;断言失败即「非系统工具」(保守)。
  • 权衡:豁免语义分散在 Registry.DefinitionsFiltered 一处,而 ApplyAgentToolFilter(名字列表层)不感知它 → 系统工具在子 Agent 路径上会被白名单剔除(见前述 LoadSkill 案例)。替代方案:把 SystemTool 的豁免上提到「名字过滤」也能表达的位置(例如过滤后再并集系统工具名)。

16. 决策:read_file 返回带行号文本,而非结构化行数组。

  • 为什么:模型消费纯文本最稳,行号是 edit_file 定位与「引用行号」的共同语言;%6d\t 格式对模型友好(对齐、易解析)。
  • 替代方案:JSON 结构(token 开销大、易被模型误读);不加行号(无法可靠引用,old_string 消歧变难)。代价:行号本身占 token(每行约 2 字节 + Tab),且 RecoveryState.RecordFile 必须重新读一遍纯净内容来抵消行号(agent.go:471-476 二次 os.ReadFile)——这是行号设计带来的一处额外 I/O。

面试官可能追问(20 条) ​

Q1:为什么工具执行不返回 Go 的 error,而是 Result{IsError}? A:Result 有两个字段 Content/IsError(tool.go:12-16),包注释明确「永远以值类型返回,从不返回 Go error」。核心理由是错误是给模型的观察值:IsError 会被映射到 llm.ToolResult.IsError(agent.go:883-887)并在 Anthropic 侧落到 SDK 的 is_error 字段(anthropic.go:82),于是模型能明确区分「工具坏了/参数错了」与「工具正常返回了失败信息」(如 bash 的非零退出刻意保持 IsError=false)。统一由 errorResult(tool.go:53-58)构造文案。补充:OpenAI 的 chat 协议没有 is_error 字段,toOpenAIMessages 只传 Content(openai.go:84-87),两边表达力不对等。

Q2:Registry 是并发安全的吗?有锁吗? A:没有锁——整个 registry.go 无 sync 引用,Registry 就是 order []string + tools map[string]Tool(registry.go:15-19)。它的安全性来自使用约束:Register 只在启动期调用(main.go:73-81、tui.go:230-273),运行期只发生 Get/遍历。Execute(registry.go:80-90)除一次 map 读之外无共享状态,因此并发调用安全。Count/Get/Names 也是纯读。若要在运行期热注册(例如 MCP server 动态上线),必须自己加锁——源码未体现。

Q3:并发执行工具时,工具实例本身安全吗? A:6 个内置工具都是无字段的空结构体(type readFileTool struct{} 等),无缓存无状态 → 实例并发安全。真正的问题是文件系统层面的并发:edit_file 是 os.ReadFile → strings.Replace → os.WriteFile(edit_file.go:73-100),非原子、无锁、无 mtime 校验,两个并发编辑同一文件会丢更新。agent 的 executeBatched 把非只读工具串行化(agent.go:657+)规避了同轮并发,但跨进程(用户 IDE 同时改)无保护。

Q4:ReadOnly() 到底被谁用?为什么一个标志够? A:三类调用方:① agent.executeBatched 的 IsReadOnly(agent.go:537,540)做并发分组;② ReadOnlyDefinitions()(registry.go:59-72)供 Plan Mode(agent.go:207-211、run_to_completion.go:82-88、tui/commands.go:98-103);③ 作为 Engine.Check 的 readOnly 入参,在 categorize 里优先判定为 CategoryRead 直接 Allow(engine.go:101-102、settings.go:89-102)。够用的原因是三个判断的语义一致(「无副作用」)。不够的地方是无法表达「只读但远程/慢/不可并发」,MCP 工具只能取 annotations.readOnlyHint(mcp/tool.go:49,136)。

Q5:Plan Mode 怎么保证不写文件? A:双保险。① 工具集层面只发只读工具的 definitions(agent.go:207-211);② 即使模型幻觉出一个写工具名,权限层 modeFallback 对 ModePlan 下 CategoryWrite/CategoryExec 返回 Ask(engine.go:143-157),而 Ask 会弹人在回路(agent.go:760-909)。注意不存在「Plan 模式必须 Allow」的路径——最坏情况是询问用户而不是静默放行。

Q6:bash 工具自己的 timeout 参数有效期是多少? A:schema 描述「默认 120000 毫秒」,内部实现也是 120 * time.Second(bash.go:74-77),但 agent 每次执行前会套一层 context.WithTimeout(ctx, tool.DefaultTimeout),而 DefaultTimeout = 30 * time.Second(registry.go:12-13,注释写「不可配」),调用点见 agent.go:608/879/930/1004。context 取最紧 deadline → 经 agent 路径的实际上限是 30s,模型把 timeout 写到 60000 以上无效;只有绕过 agent 直接调 Execute 才能吃到 120s(tool_test.go:256-272 就是直接调)。

Q7:bash 超时后子进程真的都死了吗? A:不一定。用的是 exec.CommandContext + sh -c(bash.go:89),ctx 到期杀的是直接子进程(sh),源码未体现 SysProcAttr.Setpgid 与进程组 Kill(-pgid, SIGKILL),所以 sh -c "长期任务 &" 或后台服务可能残留。工程上应补:Setpgid + 进程组杀 + 必要时进程树清理,或在容器/worktree 里执行。

Q8:命令注入怎么防的? A:不防注入,因为不存在拼接面——command 参数整串交给 sh -c(bash.go:89),没有把用户输入拼进模板的代码,工具层只对 workdir 做了 IsAbs/.. 校验(bash.go:83-87)。真实防线在权限层:黑名单正则(permission/blacklist.go:10-40)仅对 cat == CategoryExec && target != "" 生效(engine.go:106-109)、三段规则引擎(rule.go:235-251,deny 优先)、模式矩阵(engine.go:143-157,Bypass 全 Allow)+ 人在回路。黑名单自陈「启发式、非完备」,对 base64 -d | sh、变量拼接、python -c 等无效;且 extractTarget 解析不出 command 时 target 为空 → 黑名单完全跳过(settings.go:148-158)。

Q9:为什么 bash 只继承 4 个环境变量?代价是什么? A:cmd.Env = []string{HOME, PATH, USER, TERM}(bash.go:93-98),注释直指 N5「密钥不泄漏」——白名单策略(未知变量默认不传)比黑名单安全。代价:GOPROXY/HTTP_PROXY/HTTPS_PROXY/JAVA_HOME/NODE_OPTIONS/GOPATH/SSH_AUTH_SOCK 等全部缺失 → 模型跑 go mod download、npm install、需要代理或 SSH 的命令会与用户终端行为不一致(尤其离线/内网环境),且报错信息对模型来说是「莫名网络失败」。补齐方向是「显式允许列表 + 容器内执行 + 只读挂载」。

Q10:为什么 edit_file 有 .. 检查而 read_file/write_file 没有? A:安全边界的真正位置在权限层的 sandboxOK(engine.go:112-119 → sandbox.go:54-73:filepath.Clean → evalSymlinksOrAncestor → 前缀比对项目根)。edit_file 那行 strings.Contains(a.FilePath, "..")(edit_file.go:69-71)是孤立的、不成体系的:它是子串判断而非路径分段判断,会把 a..go、pkg/x..y/z.go 这类合法路径误杀,同时给「工具层已设防」的错觉。要么删掉它统一交给权限层,要么三个文件类工具统一做 filepath.Clean + 根前缀校验(Defense in Depth)。另外权限层 extractTarget 对 edit_file 读的是 m["path"](settings.go:123-124)而工具参数名是 file_path(edit_file.go:13)→ 主路径上 edit_file 会被判「无法解析文件路径参数」直接 Deny。

Q11:符号链接攻击怎么处理? A:权限层做了符号链接解析:resolveRoot 用 EvalSymlinks(sandbox.go:10-16),sandboxOK 用 evalSymlinksOrAncestor——目标存在就解析,不存在就逐级回退到最近存在的祖先再拼回剩余段(覆盖「新建文件+未创建中间目录」,sandbox.go:24-49),最后前缀比对 resolved == root || strings.HasPrefix(resolved, root+sep)。glob/grep 用 filepath.WalkDir(Lstat 语义,不跟随目录符号链接)→ 不会因链接越出 root。read_file/write_file 会跟随链接,越界由权限层兜。风险点:直接 registry.Execute 的调用方(测试、自定义代码)没有任何保护;bypassPermissions 模式下沙箱仍生效(engine.go:112-119 在 modeFallback 之前),但那段逻辑只对「文件类」生效。

Q12:edit_file 为什么坚持 old_string 唯一而不是「替换第一处」? A:唯一性检查 count > 1 → errorResult(edit_file.go:85-87)把消歧责任交回模型,错误文案自带修复指令「请用 read_file 查看文件,选择唯一匹配片段」——这是「工具设计承担纠错教学」的典型。若改成替换第一处,会出现静默的、难以察觉的错误编辑(尤其在重复样板代码里)。代价:需要更多上下文 token 来构造唯一片段;替代方案是 replace_all 参数或无唯一性要求 + diff 回显。

Q13:old_string 匹配有哪些坑? A:① 完全字节匹配:\r\n 与 \n、缩进(Tab vs 空格)、尾随空格都必须一致,无归一化、无 fuzzy(strings.Count,edit_file.go:81);② 没有「必须先读」的硬门禁——只在 Description(edit_file.go:27-28)和系统提示(prompt/modules.go:51)里软约束,工具不校验是否读过、不校验收缩上下文,也不做 mtime 漂移检测;③ 每次只能改一处,一组改动要 N 轮往返;④ 成功结果只有一句 文件 X 已编辑: 1 处替换(edit_file.go:102),不给 diff,模型要复核就得再 read_file。

Q14:glob 的 *.go 为什么匹配不到子目录文件? A:匹配对象是相对 root 的路径,逐段 DP:`` 段内匹配、** 跨段(glob.go:118-159)。.go只有一段模式,只匹根层文件;要递归必须写**/*.go。matchGlob先filepath.ToSlash归一(Windows 兼容),单段交给filepath.Match(不支持 ,且其 error 被 _` 吞掉 → 非法 pattern 静默不匹配)。

Q15:glob/grep 怎么处理隐藏目录和大仓库? A:只跳 . 开头的目录(glob.go:77-79、grep.go:100-102 的 fs.SkipDir,注意 d.Name() != "." 保护根节点),所以 .git 不会被遍历;但 .gitignore、node_modules、vendor、dist 完全不处理(源码未体现)→ 在大前端仓库里 grep 的 100 条上限很容易被 node_modules 噪声吃满,真正的命中被挤出。补齐点:解析 .gitignore(或调 rg)、内置默认排除目录、结果按「是否在源码目录」加权。

Q16:grep 用的正则引擎有什么取舍? A:Go regexp = RE2(grep.go:74),线性时间、无回溯 → 天然免疫 ReDoS(模型可以随便写恶意正则),代价是不支持反向引用与 (?=) 之类 lookaround。语义上只做逐行匹配(re.MatchString(line),grep.go:177),不支持跨行;也没有 -i/-w/-v/-A/-B/-C、没有二进制跳过(源码未体现)。

Q17:grep 的结果什么时候会不完整?如何告知模型? A:四个限制叠加:maxResults=100(grep.go:85)达上限时 fs.SkipAll 提前终止遍历(grep.go:117-119);单文件扫描按剩余额度截断(grep.go:123 传 maxResults-len(matches));scanner 1MB 行长上限触发 Err → 该文件剩余行丢失并置位 longLineWarned(grep.go:171,190-192);单行超 500 字符被截断加 "..."(grep.go:179-181)。告知方式:命中达上限输出 [truncated],长行问题输出 [warning: 部分超长行被跳过,搜索结果可能不完整](grep.go:149-154)——显式声明不完整比静默截断重要得多。瑕疵:>= maxResults 的判断在「恰好 100 条且再无命中」时也会打 [truncated](假阳性),而 glob 用的是 > 100。

Q18:输出截断会带来什么正确性问题? A:truncate 用 len(s) 字节切片(tool.go:35-37)→ 可能切断多字节 UTF-8 字符(中文/emoji),产生非法 UTF-8;Go 的 json.Marshal 会静默替换为 \ufffd,所以问题不易暴露。另外 read_file 的 256KB 上限作用在「已加行号的文本」上(read_file.go:71-80),意味着实际能读到的原始字节少于 256KB;bash 的 8KB 上限在 stdout+stderr 合并后施加(bash.go:106-116)→ 被切掉的总是尾部(通常是 stderr)。修复方向:按 rune 边界对齐 + 截断时给出完整内容落盘路径(compact 层已有 [saved to] 的先例,compact/layer1.go:37-43)。

Q19:一套工具定义怎么喂给两家协议? A:协议无关中间层是 llm.ToolDefinition{Name, Description, InputSchema map[string]any}(llm/provider.go:36-41),由 Registry.Definitions() 导出(registry.go:44-56)。Anthropic 侧只取 properties 与 required(anthropic.go:16-31,toStrings 兼容 []string/[]interface{},anthropic.go:33-54),顶层 type 由 SDK 补;OpenAI 侧整块 map 透传(openai.go:18-28)。所以目前四个关键字(type/properties/required/description)等价;一旦加入 additionalProperties、顶层 oneOf 等,Anthropic 路径会静默丢失——没有兼容层、没有告警(源码未体现)。

Q20:工具结果怎么回到模型?read_file 有什么特殊处理? A:链路是 executeBatched 产出 []llm.ToolResult(agent.go:610-615)→ recordFileReads(agent.go:379-382)→ conv.AddToolResults(agent.go:385)→ 下一轮 streamOnce(agent.go:280)→ provider 的 tool_result 块(Anthropic 要求放在 user 消息里,anthropic.go:78-85;OpenAI 用单独的 tool 消息,openai.go:84-87)。read_file 的特殊点是 recordFileReads:它会把 read_file 成功调用的路径重新读一遍纯净内容(不带行号)存进 RecoveryState(agent.go:448-478,os.ReadFile in agent.go:472-476,Recovery.RecordFile in agent.go:476),以便上下文压缩后重挂「最近读过的文件」(compact/recovery.go:19-45,上限 5 个文件 × 5000 token,compact/const.go:24-25)——这是「行号展示」与「纯净内容复用」解耦的代价:多一次磁盘读。

企业级对应方案 ​

1. 工具集对比:Claude Code 的 Read/Edit/MultiEdit/Write/Bash/Glob/Grep/Task/TodoWrite/WebFetch/WebSearch/NotebookEdit → 本项目的缺口

Claude CodeMewCode 现状差距结论
Read(offset/limit 分页、图片、PDF、大文件给行范围)read_file(read_file.go),无 offset/limit,只有头部 2000 行/256KB最痛的缺口:大文件读不到中段,模型只能反复 grep 猜行号再没法读;应加 offset/limit(与 2000 行默认上限正交)
Edit + MultiEdit(一次多处)edit_file 单处(edit_file.go)多处修改需 N 轮往返,token 与延迟都放大;应支持 edits[] 数组(同文件一次提交,原子写回)
Writewrite_file(write_file.go)基本对齐;本项目缺「写前必须读过」的门禁与其 RecoveryState 联动
Bash(含 run_in_background、输出落盘提示)bash(bash.go),无后台模式、截断不给落盘路径长命令(dev server、watch)无法后台化;截断后模型拿不到全文(compact 层有 [saved to] 先例可借鉴)
Task/TodoWrite(会话内待办清单)Task* 四件套(task/tools.go)语义是子 Agent 任务管理,无 TodoWrite长任务缺「显式计划清单」这一层,agent 只能靠 ReAct 自然推进 + maxIterations=25 兜底(agent.go:150)
WebFetch/WebSearch无(源码未体现)无法查文档/查报错,模型只能靠内部知识
Glob/Grep(内部走 ripgrep、尊重 .gitignore)自研遍历(glob.go/grep.go)性能与噪声两个维度都有差距(见第 4 条)

2. 编辑范式对比:Aider 的 diff 编辑、OpenAI 的 apply_patch

  • Aider:以 search/replace 块(或 whole-file / udiff 格式)提交多块修改,配合 git 自动提交、失败重试与 lint 回灌。本项目 edit_file 是「一次一块、完全字节匹配、失败即报错」,没有 fuzzy 容错、没有自动重试、没有 git 提交/回滚语义。
  • OpenAI apply_patch(V4A 格式):一次调用描述多个文件的增删改,用 *** Begin Patch / *** Update File / @@ 上下文 / +/- 行 表达,模型只需给足够上下文而不必保证唯一性;解析器容忍上下文漂移。
  • 本项目差异与补齐点:① 增加 apply_patch 风格的批量工具(一次提交多文件多 hunk,减少轮次与 token——对「重构改名」类任务收益最大);② edit_file 增加可选 replace_all 与「缩进/行尾空白宽松匹配」的模糊回退(回退时必须在结果里显式告知「模糊匹配命中」);③ 编辑结果返回统一 diff而非一句「已编辑 1 处」,让模型自检(也便于用户审阅);④ 引入 git 快照(改动前 git stash/临时 commit)以实现可回滚。

3. 代码检索:ripgrep / ast-grep / LSP / AST 索引 / embedding 检索的分工

  • 现状:grep(RE2 逐行 + 100 条上限 + 无 .gitignore)与 glob(自研 ** + 100 条上限)是唯一的检索手段,本质是「文本级、无符号理解、无排名」。
  • 企业级分层:① 文本层 ripgrep(并行、gitignore 感知、二进制跳过、--json 结构化输出)——把 grep 换成 rg 子进程即可拿到 5–20× 性能与噪声抑制,要点是参数转义与超时;② 结构层 ast-grep/tree-sitter query(「找出所有未处理 error 的 resp.Body 使用」这类语义模式),比正则精确得多;③ 符号层 LSP(textDocument/definition、references、rename、publishDiagnostics)——「谁调用了这个函数」用正则做是不可靠的;④ 索引层 ctags/SCIP/自建 AST 索引,支持「按符号跳转、按文件摘要」;⑤ 语义层 embedding 检索 + rerank(自然语言「用户鉴权在哪里做的」)。
  • 补齐点排序(性价比):grep 走 rg + .gitignore 支持 → 结果按相关性(命中数/文件类型/路径权重)排序而非字典序 → 引入 symbol_search(ctags/LSP)→ 最后才考虑 embedding(成本最高、维护索引一致性)。

4. 沙箱执行:容器 / bubblewrap / seatbelt / seccomp / rlimit 的工程谱系

  • 现状(bash.go + permission):无任何 OS 级隔离。约束只有三样:4 个环境变量白名单(bash.go:93-98)、10 条命令黑名单正则(blacklist.go:10-40)、30s 超时(registry.go:13 + bash.go:79)。文件边界靠权限层前缀校验(sandbox.go:54-73),但对 bash 而言这个边界形同虚设——bash 的 target 是命令串,sandboxOK 不参与(extractTarget 里只有 read/write/edit/glob/grep 被标记 isFile)。
  • 企业级做法:① Linux bubblewrap(user namespace + 只读 bind root + tmpfs /tmp + 仅挂载工作区可写);② macOS sandbox-exec profile(或直接容器);③ Docker/Dev Container(能力受限、无 --privileged、只读根 + 卷挂载工作区);④ seccomp 白名单系统调用、AppArmor/SELinux profile;⑤ cgroup CPU/内存/PID 限额 + RLIMIT_NOFILE/RLIMIT_CORE;⑥ 网络默认断网(只允许白名单域名),因为「密钥不泄漏」若配上「可以外发」就等于没隔离——当前 env 白名单防的是「凭证被命令读取」,不防「命令把工作区源码发到外部」。
  • 补齐点(本项目可落地的最小集):进程组 kill(Setpgid + kill(-pgid))消除残留子进程 → 输出流式截断(io.LimitReader on pipe)避免先缓冲 100MB 再截断 → 可选 docker/bwrap 执行后端(配置开关)→ 网络默认关闭 + 显式 allow_network 参数(进审批)。

5. 工具选择率优化(tool selection rate)

  • 本项目已做的:① Description 带「何时不要用我」的负向指令(bash.go:31-34:读文件/找文件/搜内容优先用专用工具);② edit_file Description 内嵌前置动作(edit_file.go:26-29:先 read_file 确认唯一);③ 系统提示里有专门的「工具选择优先级」模块(prompt/modules.go:46-53);④ 错误文案带修复指令(edit_file.go:86);⑤ 参数 schema 的 description 写默认值与单位(bash.go:50「超时(毫秒),默认 120000」)。
  • 企业级补齐:① 工具搜索/动态裁剪(MCP 工具多到几十个时,上下文里全量塞会显著降低选择率——Claude Code 用 ToolSearch 按需召回);② 精简工具数(能合并的合并,例如 Glob/Grep 在多模态场景下可让位于一个带模式的 search);③ few-shot 示例进 description(对复杂参数如 apply_patch 几乎是必需);④ 指标化:埋点「工具调用成功率、参数解析失败率、同一工具重复调用率、未知工具率」——本项目已有「连续未知工具熔断」(agent.go:372-376,394-400)这种信号,但没有遥测落盘(源码未体现 telemetry),无法做 A/B;
  • 现成的可量化缺陷:bash 的 description 参数无人消费(bash.go:17-20 声明、Execute 不用)→ 模型填了也没用,属于「schema 里的死参数」,会误导模型分配注意力;edit_file 的 file_path 命名与其余工具 path 不一致(并直接导致权限层 Deny,见 Q10)——这类不一致本身就是选择率/成功率杀手。

6. 权限与审批模型对比(Claude Code permission rules / Codex approval modes)

  • 本项目实现的是五层流水线(engine.go:94-140):黑名单(Exec 专属)→ 沙箱(文件类专属,EvalSymlinks 前缀校验)→ 三级规则(本地 > 项目 > 用户,deny 优先,支持 =exact/~regex/!not/glob 四种 matcher,matcher.go:76-117)→ 模式兜底矩阵(default/acceptEdits/plan/bypass,只产 Allow/Ask 不产 Deny,engine.go:142-157)+ Hook 拦截(PreToolUse 可 Block,agent.go:544-559)+ 人在回路三种结局(DenyOnce/AllowOnce/AllowForever,AllowForever 写本地规则文件,agent.go:760-909、engine.go 的 PersistLocalAllow)。
  • 对比:Codex 的 suggest / auto-edit / full-auto 三档近似本项目的 default / acceptEdits / bypass;Claude Code 的 settings-based rules 与本项目三层 ruleset 思路一致。差异点:① 本项目规则只支持「工具级 + 单目标串」匹配,无目录级写白名单语义(Write(/src/**) 可以表达,但 Bash 的 target 是命令串,无法表达「这个命令只许在工作区里跑」);② 无「只读文件系统 + 只写工作区」的强制能力(依赖用户不改 bypass);③ 无审计面(session JSONL 存在,但工具级审批结果与 deny 原因没有专门的 telemetry)。

7. 大输出/长会话的工程对比

  • Claude Code 的做法:Bash 输出超限时落盘并给路径;本项目工具层只给 [truncated](tool.go:33,36、glob.go:110、grep.go:150),不给路径;但 compact 层已经实现了完整的落盘预览(compact/layer1.go:13-45:SpillDir/<tool_use_id> + [content offloaded] original size + [saved to] + 20 行/2048 字节头部 + 「不要凭预览猜全文」的指令)。
  • 结论与补齐点:把「落盘 + 回填路径」的能力从 compact 下沉到 tool(截断时统一输出「完整输出已保存到落盘路径(尖括号占位)」),并把阈值统一到一处常量(当前散落 5 处:2000 行、256KB、200 行、8KB、100 条 ×2)。这样才能既省 token 又让模型有机会按需回读。

8. 与 MCP 生态的接缝

  • MCP 工具通过 reg.Register(t) 并入同一注册中心(main.go:79-81),名字带 mcp__ 前缀(filter.go:115-119 据此在后台 Agent 中动态放行),ReadOnly 取 annotations.readOnlyHint(缺省 false → 归 CategoryExec 最严,settings.go:98-101)。
  • 差距:MCP 工具的 schema 是透传的(mcp/tool.go 里 json.Marshal(t.InputSchema),127-128 行),可能包含本项目从未测试过的关键字(oneOf、$ref、enum)——而 Anthropic 转换路径只取 properties/required(anthropic.go:19-22),这类工具在 Anthropic 协议下会丢 schema 细节导致模型参数填错。补齐点:工具注册时对 schema 做一次「协议兼容性检查」(顶层关键字白名单 + 告警),或把 ToolInputSchemaParam 的 Type 也显式带上。

持续学习,持续构建。