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:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
@@ -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)...)
|
||||
}
|
||||
Reference in new issue
Block a user