// Package tools 实现工具注册表(借鉴 picoclaw 的注册表模式与 pigo 的 // agenttool 分层):每个工具自我描述 JSON schema,副作用工具标记 SideEffect // 以便 agent 层做信任门控。 package tools import ( "context" "encoding/json" "errors" "fmt" "os" "git.gopher.ink/mirrors2/AutoZero/llm" ) // Result 是工具的执行结果:文本回复 + 可选的图片附件(如截图), // 图片随 tool result 一起发给模型(vision)。 type Result struct { Text string Images []llm.Image // Err 非空表示本次调用失败,而 Text 仍可携带部分输出(典型如 bash: // 命令失败但 stdout 有价值)。它与 Run 的第二个返回值等价, // 由 Agent 统一归一后按结构化失败块回灌给模型(见 agent/toolfail.go)。 Err error // Exit 是命令类工具的退出码;0 表示没取到或不适用(Agent 落盘时会跳过)。 Exit int // Truncated 表示 Text 被本工具的长度上限削过——模型看到的不是全量。 // 由工具自己标:只有它知道上限是多少、有没有触发。 Truncated bool } // TextResult 是纯文本结果的便捷构造。 func TextResult(s string) Result { return Result{Text: s} } // Change 是一次文件改动的留档记录,由宿主的 Snapshotter 实现消费 // (AutoGo 里对应 snapshot.Change,回退/补偿机制在宿主侧)。 type Change struct { Path string // 解析后的真实路径(~ 与工作区相对都已展开) Before []byte // 改动前的内容 Existed bool // false = 改动前不存在,是本次新建的(恢复时该删,不是该还原) After []byte // 改动后的内容 // Reason 非空表示这次改动**没能**留档(例如旧内容读不出来)。据此记一条 // 断链记录,恢复时该文件会被拦下——绝不能把"读失败"当成"文件不存在", // 那会让一次 rewind 删掉一个本来一直存在的文件。 Reason string } // Snapshotter 是写类工具的留档钩子;nil 时不留档,行为与从前一致。 // // 为什么挂在工具层而不是 agent 层:只有工具自己知道解析后的真实路径,以及 // "改动前那一刻文件是什么"。write 本来不读旧内容,为回合级回退它得多读一次—— // 这笔开销写在工具里看得见,也可以随时整个关掉。 type Snapshotter interface { Record(Change) } // BuiltinHooks 是内置工具向宿主回传的副作用钩子。 type BuiltinHooks struct { // SessionID 用于后台作业完成推送(空 = 不推送)。 SessionID string // OnBackgroundDone 在 bash_bg 结束时调用(可空)。 OnBackgroundDone func(BackgroundDone) // OnInstructions 在 read 发现当前工作区内的嵌套 AGENTS.md 时调用(可空)。 // 路径与正文由工具层给出,宿主负责去重与注入。 OnInstructions func(path, text string) } // record 在钩子缺席时什么都不做。 func record(s Snapshotter, c Change) { if s != nil { s.Record(c) } } // preImage 读出改动前的内容。第三种结果必须与"不存在"区分开: // 存在但读不出(权限、被占用)时若按不存在处理,一次 rewind 就会删掉本来 // 存在的文件。这里返回原因码,留档层据此记一条断链记录。 func preImage(p string) (before []byte, existed bool, reason string) { old, err := os.ReadFile(p) switch { case err == nil: return old, true, "" case errors.Is(err, os.ErrNotExist): return nil, false, "" default: return nil, false, "unreadable" } } // ToolKind 是工具的作用类别,供审批策略按类别放行(如「自动编辑」放行写入、 // 但执行类仍需确认)。三个 bool 标注无法区分 write/edit 与 bash——它们同为 // SideEffect,故需要单独的类别维度。 type ToolKind string const ( KindRead ToolKind = "read" // 只读:读文件、搜索 KindWrite ToolKind = "write" // 改工作区文件 KindExec ToolKind = "exec" // 执行命令/控制本机 KindNetwork ToolKind = "network" // 访问外部网络 KindOther ToolKind = "other" // 未分类(含 MCP 动态工具),最保守 // KindAsk 是「向人提问」:它不改现实世界,也不该被任何审批门过问。 // 单列一个类别是因为非副作用工具在 ApprovalPolicy=always 下仍会被问一次 // 「要不要允许 ask_user」——为了问一个问题,先批准问这个问题。 KindAsk ToolKind = "ask" ) // Tool 是一个可被模型调用的工具。语义标注借鉴 claude-code-best 的 Tool 接口 // (src/Tool.ts:373):Go 的零值即 fail-closed——未标注的工具默认视为有副作用、 // 不可并发。 type Tool struct { Name string Description string Parameters map[string]any // JSON schema SideEffect bool // 为 true 时需要信任门控(写文件、执行命令等) // ReadOnly 表示工具不改变系统状态(读文件、搜索等)。 ReadOnly bool // ConcurrencySafe 表示可与其他工具并发执行;只有同时 ReadOnly 的连续 // 段才会被编排器并发执行。 ConcurrencySafe bool // Kind 是审批用的作用类别;零值按 EffectiveKind() 推断(fail-closed)。 Kind ToolKind // Run 执行工具;input 是模型给的原始 JSON 参数字符串。 Run func(ctx context.Context, input string) (Result, error) } // EffectiveKind 返回工具类别:显式标注优先;否则只读工具算 read, // 其余一律 other——**未知类别绝不被当作可自动放行的 write**(fail-closed)。 func (t *Tool) EffectiveKind() ToolKind { if t.Kind != "" { return t.Kind } if t.ReadOnly && !t.SideEffect { return KindRead } return KindOther } // Registry 按名管理工具集合。 type Registry struct { byName map[string]*Tool order []string } func NewRegistry() *Registry { return &Registry{byName: map[string]*Tool{}} } func (r *Registry) Register(t *Tool) error { if _, dup := r.byName[t.Name]; dup { return fmt.Errorf("tool %q already registered", t.Name) } r.byName[t.Name] = t r.order = append(r.order, t.Name) return nil } func (r *Registry) Get(name string) (*Tool, bool) { t, ok := r.byName[name] return t, ok } func (r *Registry) Names() []string { return r.order } // CloneWithout 返回一个去除了指定工具的注册表副本(用于子 agent 防递归)。 func (r *Registry) CloneWithout(names ...string) *Registry { drop := map[string]bool{} for _, n := range names { drop[n] = true } c := NewRegistry() for _, n := range r.order { if !drop[n] { _ = c.Register(r.byName[n]) } } return c } // Defs 导出给 LLM 的工具声明列表。 func (r *Registry) Defs() []llm.ToolDef { out := make([]llm.ToolDef, 0, len(r.order)) for _, n := range r.order { t := r.byName[n] out = append(out, llm.ToolDef{ Name: t.Name, Description: t.Description, Parameters: t.Parameters, }) } return out } // arg 解析输入 JSON 到 map。 // 兼容模型可能生成的 {"arguments": {...}} 包装格式。 func arg(input string) (map[string]any, error) { m := map[string]any{} if input == "" { return m, nil } if err := json.Unmarshal([]byte(input), &m); err != nil { return nil, fmt.Errorf("invalid tool arguments: %w", err) } // 兼容 {"arguments": {...}} 包装:提取内层对象 if args, ok := m["arguments"]; ok { if argsMap, ok := args.(map[string]any); ok { return argsMap, nil } } return m, nil } func str(m map[string]any, key string) string { if v, ok := m[key].(string); ok { return v } return "" } // expandHome 把 ~ 前缀替换为用户主目录。 func expandHome(p string) string { if p == "~" || len(p) > 1 && p[0] == '~' && p[1] == '/' { home, err := os.UserHomeDir() if err == nil { if p == "~" { return home } return home + p[1:] } } return p }