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。
This commit is contained in:
Sakurasan committed 2026-10-26 00:54:46 +08:00
commit d5dac5a6fc
140 files changed
+26497

No files matched your search

+404
View File
@@ -0,0 +1,404 @@
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)...)
}