package tools import ( "bytes" "context" "fmt" "os" "path/filepath" "regexp" "strings" "time" "git.gopher.ink/mirrors2/AutoZero/execx" ) // readMaxLines 是 read 单次返回的行数/目录条目上限。 const readMaxLines = 2000 // pageParams 解析 offset/limit:offset 从 1 开始,limit 默认且封顶 readMaxLines。 func pageParams(m map[string]any) (offset, limit int) { offset, limit = 1, readMaxLines if v, ok := m["offset"].(float64); ok && int(v) > 0 { offset = int(v) } if v, ok := m["limit"].(float64); ok && int(v) > 0 { limit = int(v) } if limit > readMaxLines { limit = readMaxLines } return offset, limit } // bashTimeout 限制单条命令的最长执行时间。var 仅供测试缩短。 var bashTimeout = 120 * time.Second // bashMaxOutput 是回灌给模型的命令输出上限(字节)。var 仅供测试缩短。 var bashMaxOutput = 16000 // Builtin 返回 MVP 内置工具集:read / write / edit / bash / ls / grep。 // 语义标注遵循 fail-closed:只有明确的只读工具才标 ReadOnly+ConcurrencySafe。 // Builtin 返回默认工作目录(进程 cwd)下的内置工具,走默认围栏、不留档。 func Builtin() []*Tool { return BuiltinIn("", nil, nil) } // BuiltinIn 返回以内置工具为蓝本、绑定了工作目录与围栏的实例:workDir 非空时, // bash 在该目录执行,文件工具的相对路径以它为基准解析(工作区语义); // fence 是本会话的路径围栏(按工作区现场组装),nil 走包级默认围栏; // snap 非 nil 时 write/edit 把每次改动交给它留档,供会话级回退(rewind)使用。 // "" 与进程 cwd 等价,行为与历史版本一致。 func BuiltinIn(workDir string, fence *Fence, snap Snapshotter) []*Tool { return BuiltinInSession(workDir, fence, snap, "", nil) } // BuiltinInSession 在 BuiltinIn 基础上给后台作业绑上会话与完成回调: // bash_bg 结束时主动通知 sessionID(而不是靠模型轮询)。 func BuiltinInSession(workDir string, fence *Fence, snap Snapshotter, sessionID string, onBackgroundDone func(BackgroundDone)) []*Tool { return BuiltinWithHooks(workDir, fence, snap, BuiltinHooks{ SessionID: sessionID, OnBackgroundDone: onBackgroundDone, }) } // BuiltinWithHooks 是带完整宿主钩子的内置工具集。 func BuiltinWithHooks(workDir string, fence *Fence, snap Snapshotter, hooks BuiltinHooks) []*Tool { if fence == nil { fence = Default() } // resolve 以工作目录为基准解析工具入参路径:~ 与绝对路径原样, // 相对路径落到 workDir(未设置则维持相对,交给围栏 Check 的 Abs 按进程 cwd)。 resolve := func(p string) string { p = expandHome(p) if workDir != "" && !filepath.IsAbs(p) { return filepath.Join(workDir, p) } return p } builtins := []*Tool{ { Name: "read", Description: "Read a text file or list a directory. Files return up to 2000 lines; use offset " + "(1-based line) and limit to page through large files. Directory entries are one per line, " + "directories end with '/'. Binary files are rejected — use bash for those.", ReadOnly: true, ConcurrencySafe: true, Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{"type": "string", "description": "File or directory path"}, "offset": map[string]any{"type": "integer", "description": "1-based line or directory entry to start from (default 1)"}, "limit": map[string]any{"type": "integer", "description": "Maximum lines or entries to return (default and cap 2000)"}, }, "required": []string{"path"}, }, Run: func(_ context.Context, input string) (Result, error) { m, err := arg(input) if err != nil { return Result{}, err } p := resolve(str(m, "path")) if err := fence.Check(p); err != nil { return Result{}, err } info, err := os.Stat(p) if err != nil { return Result{}, err } offset, limit := pageParams(m) if info.IsDir() { discoverInstructions(p, true, workDir, hooks.OnInstructions) entries, err := os.ReadDir(p) // 已按文件名排序 if err != nil { return Result{}, err } names := make([]string, 0, len(entries)) for _, e := range entries { name := e.Name() if e.IsDir() { name += "/" } names = append(names, name) } total := len(names) if offset > total { return Result{Text: fmt.Sprintf("[offset %d is beyond %d entries in %s]", offset, total, p)}, nil } end := min(offset-1+limit, total) var b strings.Builder fmt.Fprintf(&b, "Directory %s, entries %d-%d of %d\n", p, offset, end, total) b.WriteString(strings.Join(names[offset-1:end], "\n")) if end < total { fmt.Fprintf(&b, "\n[truncated; continue with offset: %d]", end+1) } return Result{Text: b.String()}, nil } data, err := os.ReadFile(p) if err != nil { return Result{}, err } if bytes.IndexByte(data, 0) >= 0 { return Result{}, fmt.Errorf("%s looks binary; use bash (e.g. `head -c`, `xxd`) to inspect it", p) } // 读到某棵子树时顺带发现该子树自己的 AGENTS.md(根的那份已在 // 基础 system 提示里)。失败不影响读取。 discoverInstructions(p, false, workDir, hooks.OnInstructions) content := string(data) // 未分页的小文件保持历史行为:原样返回,不加头尾。 lines := strings.Split(content, "\n") if strings.HasSuffix(content, "\n") { lines = lines[:len(lines)-1] } total := len(lines) if offset == 1 && total <= limit { return Result{Text: content}, nil } if total == 0 || offset > total { return Result{Text: fmt.Sprintf("[offset %d is beyond EOF (%d lines) in %s]", offset, total, p)}, nil } end := min(offset-1+limit, total) var b strings.Builder fmt.Fprintf(&b, "Read file %s, lines %d-%d of %d\n", p, offset, end, total) b.WriteString(strings.Join(lines[offset-1:end], "\n")) if end < total { fmt.Fprintf(&b, "\n[truncated; continue with offset: %d]", end+1) } return Result{Text: b.String()}, nil }, }, { Name: "write", Description: "Write content to a file (overwrites). Creates parent directories.", SideEffect: true, Kind: KindWrite, // 「自动编辑」档可自动放行 Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{"type": "string", "description": "File path"}, "content": map[string]any{"type": "string", "description": "Full file content"}, }, "required": []string{"path", "content"}, }, Run: func(_ context.Context, input string) (Result, error) { m, err := arg(input) if err != nil { return Result{}, err } p := resolve(str(m, "path")) if err := fence.CheckWrite(p); err != nil { return Result{}, err } if dir := filepath.Dir(p); dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return Result{}, err } } content := []byte(str(m, "content")) // 留档必须先于写入。write 本来不读旧内容,为了回合级回退多付一次读; // 读失败绝不当成"文件不存在"——那会让一次 rewind 删掉本来存在的文件。 before, existed, why := preImage(p) if err := os.WriteFile(p, content, 0o644); err != nil { return Result{}, err } record(snap, Change{Path: p, Before: before, Existed: existed, After: content, Reason: why}) return Result{Text: "wrote " + p}, nil }, }, { Name: "edit", Description: "Replace exact text in a file. old_string must identify a UNIQUE location: multiple matches " + "fail unless replace_all is true, so add surrounding context to disambiguate (or set replace_all to " + "change every occurrence, e.g. renaming a variable). When there is no exact match, the edit retries " + "while ignoring typographic quote/dash/space variants, then trailing whitespace on each line. The " + "file's CRLF or LF line endings are matched and preserved.", SideEffect: true, Kind: KindWrite, // 「自动编辑」档可自动放行 Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{"type": "string"}, "old_string": map[string]any{"type": "string", "description": "Exact text to replace; must be unique unless replace_all"}, "new_string": map[string]any{"type": "string", "description": "Replacement text"}, "replace_all": map[string]any{"type": "boolean", "description": "Replace every occurrence (default false)"}, }, "required": []string{"path", "old_string", "new_string"}, }, Run: func(_ context.Context, input string) (Result, error) { m, err := arg(input) if err != nil { return Result{}, err } p := resolve(str(m, "path")) if err := fence.CheckWrite(p); err != nil { return Result{}, err } data, err := os.ReadFile(p) if err != nil { return Result{}, err } old, new := str(m, "old_string"), str(m, "new_string") if old == "" { return Result{}, fmt.Errorf("old_string must not be empty") } content := string(data) matches := findEditMatches(content, old) if len(matches) == 0 { return Result{}, fmt.Errorf("old_string not found in %s", p) } replaceAll, _ := m["replace_all"].(bool) if len(matches) > 1 && !replaceAll { return Result{}, fmt.Errorf( "old_string appears %d times in %s; add more surrounding context to make it unique, or set replace_all to true", len(matches), p) } updated := []byte(applyEditMatches(content, matches, new)) if err := os.WriteFile(p, updated, 0o644); err != nil { return Result{}, err } // edit 已经握着旧内容,留档不额外花钱。 record(snap, Change{Path: p, Before: data, Existed: true, After: updated}) return Result{Text: fmt.Sprintf("edited %s (%d replacement(s))", p, len(matches))}, nil }, }, { Name: "bash", Description: "Run a shell command and return combined stdout/stderr (timeout 120s). The whole process tree is killed at the deadline: to leave a daemon running, detach it with `setsid` (or schedule it).", SideEffect: true, // 命令可读写任意状态:既非 ReadOnly 也非 ConcurrencySafe Kind: KindExec, // 执行类:任何档位都要确认(「自动编辑」也不例外) Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "command": map[string]any{"type": "string"}, }, "required": []string{"command"}, }, Run: func(ctx context.Context, input string) (Result, error) { m, err := arg(input) if err != nil { return Result{}, err } cctx, cancel := context.WithTimeout(ctx, bashTimeout) defer cancel() // workDir 是空串时继承进程 cwd(与历史行为一致)。 out, err := execx.Run(cctx, execx.Options{Dir: workDir}, "sh", "-c", str(m, "command")) out, truncated := execx.Truncate(out, bashMaxOutput) res := Result{Text: string(out), Truncated: truncated} if res.Truncated { res.Text += "\n...[truncated]" } if err != nil { // 退出码/超时走 Err 而非塞进 Text:让 Agent 能把失败结构化地 // 回灌给模型([TOOL FAILED] 块),同时保留已有的 stdout。 // ExitCode() 被信号杀时返回 -1——那不是"没取到",照实记。 if ec, ok := err.(interface{ ExitCode() int }); ok { res.Exit = ec.ExitCode() } res.Err = err } return res, nil }, }, { Name: "ls", Description: "List directory entries (non-recursive).", ReadOnly: true, ConcurrencySafe: true, Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]any{"type": "string", "description": "Directory, default '.'"}, }, }, Run: func(_ context.Context, input string) (Result, error) { m, err := arg(input) if err != nil { return Result{}, err } dir := str(m, "path") if dir == "" { dir = "." } dir = resolve(dir) if err := fence.Check(dir); err != nil { return Result{}, err } entries, err := os.ReadDir(dir) if err != nil { return Result{}, err } var b strings.Builder for _, e := range entries { name := e.Name() if e.IsDir() { name += "/" } b.WriteString(name + "\n") } return Result{Text: b.String()}, nil }, }, { Name: "grep", Description: "Search a regex pattern in all files under a directory (recursive, skips large/binary files).", ReadOnly: true, ConcurrencySafe: true, Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "pattern": map[string]any{"type": "string", "description": "Go regexp pattern"}, "path": map[string]any{"type": "string", "description": "Directory, default '.'"}, "max_matches": map[string]any{"type": "integer", "description": "Max matches, default 50"}, }, "required": []string{"pattern"}, }, Run: func(_ context.Context, input string) (Result, error) { m, err := arg(input) if err != nil { return Result{}, err } re, err := regexp.Compile(str(m, "pattern")) if err != nil { return Result{}, err } dir := str(m, "path") if dir == "" { dir = "." } dir = resolve(dir) if err := fence.Check(dir); err != nil { return Result{}, err } max := 50 if v, ok := m["max_matches"].(float64); ok && int(v) > 0 { max = int(v) } var b strings.Builder count := 0 _ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { if err != nil || info.IsDir() || count >= max { return nil } if info.Size() > 1<<20 { return nil } data, err := os.ReadFile(p) if err != nil || bytes.IndexByte(data, 0) >= 0 { return nil } for i, line := range strings.Split(string(data), "\n") { if re.MatchString(line) { fmt.Fprintf(&b, "%s:%d: %s\n", p, i+1, line) if count++; count >= max { break } } } return nil }) return Result{Text: b.String()}, nil }, }, } // 后台执行通道与内置工具同表注册:它属于同一套「内置能力」,不是可选插件—— // 模型只会用你给它的那把扳手,长任务没有正门就会自己去 nohup + pkill。 return append(builtins, BackgroundToolsNotify(workDir, hooks.SessionID, hooks.OnBackgroundDone)...) }