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:永远以值返回的结果对象
// 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:82anthropic.NewToolResultBlock(tr.ToolCallID, tr.Content, tr.IsError));OpenAI 的 chat 协议没有对应字段,toOpenAIMessages只传tr.Content(openai.go:84-87)——同一份错误语义在两种协议下表达力不对等,OpenAI 路径只能靠文案表达。
构造错误的唯一工厂(约定所有错误都走它):
// tool.go:53-58
// errorResult 生成一个 IsError 的结构化错误结果。
func errorResult(format string, args ...interface{}) Result {
return Result{
Content: fmt.Sprintf(format, args...),
IsError: true,
}
}Tool 接口:五个方法
// 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-72 | Plan Mode 只导出只读工具 |
| Agent Plan Mode 取工具集 | agent.go:207-211 | mode == ModePlan → 只读定义集 |
| 子 Agent Plan Mode | run_to_completion.go:82-88 | 同上(优先级高于白名单) |
/compact 手动压缩 | tui/commands.go:98-103 | 传给 RunForceCompact 以保持与请求一致的工具定义 |
| 权限分类 | permission/engine.go:101-102 → settings.go:89-102 | categorize(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 可选接口:白名单豁免
// 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:包级截断工具函数
// 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。
注册中心
数据结构与注册语义
// 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)。
查询与导出
// 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 导出协议无关定义:
// 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):
// 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):
// 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 会被静默丢弃(源码未体现任何兼容处理或告警)。
执行语义与并发安全
// 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)。它之所以在运行期没问题,依赖两条前提:
- 注册只发生在启动期(
main.go/tui.go构造阶段),此后tools/order只读; - 读侧只有 map 查找和 slice 遍历,无写。
因此:Registry 本身不是并发安全的类型,但「启动期写 + 运行期读」的用法是并发安全的。若将来支持运行期热注册(例如 MCP server 动态上线新工具),必须自己加锁 —— 源码未体现。
Execute 作为方法本身无共享状态 → 并发调用安全;安全性实际取决于工具实例。6 个内置工具全是空结构体(type readFileTool struct{} 等),无字段、无缓存,因此并发执行安全。
真正的并发编排不在 tool 包,而在 agent 的 executeBatched(agent.go:516-979):
// 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)
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 行)。
实现要点
// 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)
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "要写入的文件路径"},
"content": map[string]any{"type": "string", "description": "要写入的文件内容"},
},
"required": []string{"path", "content"},实现要点
// 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。 - 父目录自动创建(
MkdirAll0755)——这是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)
"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:
// 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"] 一致。)
实现要点
// 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)
"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)。
实现要点
// 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"),
}// 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这类依赖前导对齐的输出有损)。
安全考量(这是整个工具包风险最集中的地方):
- 无命令注入防护,也不需要:
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)源码层完全没有覆盖。 - 环境变量白名单:只传
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时的行为可能和用户终端不一致(离线环境尤其明显)。 - 无沙箱:无容器、无 namespace、无 seccomp、无 rlimit、无网络隔离;命令以当前用户身份全权限运行,唯一约束是黑名单 + 审批 + 30s 超时。
- 超时后子进程可能残留:
exec.CommandContext在 ctx 到期时 Kill 的是直接子进程(sh),没有设置SysProcAttr.Setpgid+ 进程组 Kill →sh -c "sleep 100 &"或后台服务类命令的孙子进程可能继续存活(源码未体现进程组管理)。 workdir校验不对称:只校验IsAbs与子串..,不校验「是否在项目根内」(这是权限层extractTarget不覆盖 bash 的路径字段导致的——bash 的目标串是 command 而非 workdir)。同时workdir是相对cmd.Dir的相对当前进程 cwd 解析的,不是相对项目根。
5. glob
参数 schema(glob.go:28-43)
"properties": map[string]any{
"pattern": map[string]any{"type": "string", "description": "glob 模式,如 **/*.go、*.md"},
"path": map[string]any{"type": "string", "description": "搜索起始路径,默认为当前工作目录"},
},
"required": []string{"pattern"},实现要点
// 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
})// 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 不支持 **):
// 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)
"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"},实现要点
// 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// 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...)// 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")}// 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 工具)在启动时计算自己的工具白名单。
// 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):
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)。
// 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 }// 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-277prompt.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
// 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 }// 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_file | read_file.go:78-80 | maxLines=2000、maxChars=256*1024 | 2000 行是「整文件编辑器视图」的惯例上限(Claude Code Read 同值);256KB 兜底防单行巨长文件 |
| bash | bash.go:115-116 | truncate(output, 200, 8000) | 命令输出极易爆炸(cat 大文件、日志、go test ./...);200 行/8KB 是为了把「够判断成败的上下文」留下,同时不挤爆窗口 |
| glob | glob.go:106-111 | >100 → 截前 100 + [truncated] | 条数上限;先 sort.Strings → 截断结果确定可复现(对测试与缓存友好) |
| grep | grep.go:85,117-119,149-151 | maxResults=100 | 条数上限;配合 fs.SkipAll 提前终止遍历(不是先收集再截),必要时给出 [truncated](可能是假阳性:>= 判断) |
| grep 行内 | grep.go:179-181 | >500 → 前 500 + "..." | 压缩压缩过后的单行——minify 的 JS/一行超长 JSON 会让「一行命中」吃掉整个预算 |
| grep 行长 | grep.go:171,190-192 | scanner 上限 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-1074 | 60 字符 | argPreview 只给 TUI 显示 ● tool(args) |
| agent 结果摘要 | agent.go:1089-1096 | 8 行 + ... | truncateLines 在 ToolEvent.Result 上——只影响 UI,不影响回灌给模型的 Content(这点容易看错) |
| compact 单条 | compact/const.go:7,9 | 50000 字节 / 聚合 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-45 | 20 行 / 2048 字节 | headPreview(layer1.go:23-34) |
| compact 恢复段 | compact/const.go:24-25 | 最近 5 个文件 × 5000 token | 摘要后重挂「最近读过的文件」+ 当前工具列表(recovery.go:19-45) |
为什么这么设计(可讲的逻辑链):
- 两道闸门分工:工具层截断是「单次观察的卫生」,compact 层截断是「历史累积的卫生」。工具层不为 token 总量负责(它没有全局视野),compact 层不知道单次语义(它只按字节切)。
- 行数优先、字节兜底:行数保证结构可读(模型靠行号定位),字节数防「一行 10MB」。两者的组合正好覆盖最常见的两种爆炸形态。
- 截断即告知:
[truncated]、[warning: ...]、[content offloaded]三处都显式声明,且 compact 的预览文案还带行动指令(如需查看请用文件读取工具读取该路径,不要凭头部预览猜测全文,layer1.go:43)——防止模型把「部分」当「全部」。 - 已知瑕疵:
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_skillvsLoadSkill)。替代方案:单一命名规范 + 显式 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 Code | MewCode 现状 | 差距结论 |
|---|---|---|
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[] 数组(同文件一次提交,原子写回) |
Write | write_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+ 仅挂载工作区可写);② macOSsandbox-execprofile(或直接容器);③ Docker/Dev Container(能力受限、无--privileged、只读根 + 卷挂载工作区);④seccomp白名单系统调用、AppArmor/SELinuxprofile;⑤ cgroup CPU/内存/PID 限额 +RLIMIT_NOFILE/RLIMIT_CORE;⑥ 网络默认断网(只允许白名单域名),因为「密钥不泄漏」若配上「可以外发」就等于没隔离——当前 env 白名单防的是「凭证被命令读取」,不防「命令把工作区源码发到外部」。 - 补齐点(本项目可落地的最小集):进程组 kill(
Setpgid+kill(-pgid))消除残留子进程 → 输出流式截断(io.LimitReaderon pipe)避免先缓冲 100MB 再截断 → 可选 docker/bwrap 执行后端(配置开关)→ 网络默认关闭 + 显式allow_network参数(进审批)。
5. 工具选择率优化(tool selection rate)
- 本项目已做的:①
Description带「何时不要用我」的负向指令(bash.go:31-34:读文件/找文件/搜内容优先用专用工具);②edit_fileDescription 内嵌前置动作(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也显式带上。