Files
Sakurasan d5dac5a6fc feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、
builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、
编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。

产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt)
留在 AutoGo,宿主接入方式见 MIGRATION.md。
2026-10-26 00:54:46 +08:00

405 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)...)
}