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

+400
View File
@@ -0,0 +1,400 @@
package tools
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"git.gopher.ink/mirrors2/AutoZero/execx"
"git.gopher.ink/mirrors2/AutoZero/scratch"
)
// 后台执行通道:给「天生长过 bash 那 120 秒」的活儿一个正式去处。
//
// 没有它的时候模型会自己发明一套。一个 22 小时的真实下载会话里出现过:
// `nohup ./x & echo $!` 存 PID、`sleep 30; tail -3 /tmp/dl.log` 轮询、用外层
// `timeout 280` 跟内部上限谈判、最后 `pkill -f` 收尾——而 pkill -f 的模式串就
// 写在这条命令自己的命令行里,于是它先杀掉了正在执行它的 shell,后面半步都没跑。
// 这套手搓流程既不可观测(输出散在 /tmp),也不可控(停不掉、重启就找不着)。
//
// 两个关键取舍:
// - **作业输出写文件,不建管道**。bash 那一类挂死的根源是孙子进程握着 stdout
// 管道写端(见 internal/execx),这里从源头绕开;副产品是回合结束后进度仍留在
// 文件里,而且日志落在 scratch 根内——`read` 工具也读得到,不受围栏阻拦。
// - **停止走 kill(-pgid)**:作业自成进程组,`npm build`/`yt-dlp` 这类派生的
// 子进程一起带走,不留孤儿。
// bgJob 是一个后台作业。由 startJob 构造,生命周期到进程结束。
type bgJob struct {
ID string
Command string
Dir string
LogPath string
StartedAt time.Time
// sessionID 是启动该作业的会话;onDone 在作业结束时通知它(可空)。
// 回调跑在独立 goroutine 里:收尾路径不能因为宿主慢而被拖住。
sessionID string
onDone func(BackgroundDone)
mu sync.Mutex
cmd *exec.Cmd
log *os.File
finished bool
exit string // 结束描述:"exit 0" / "signal: killed" / "exit status 1"
endedAt time.Time // finish 的时刻(算总耗时用)
}
// BackgroundDone 是一次后台作业结束的推送。Tail 是日志尾部(不是全量)。
type BackgroundDone struct {
JobID string
SessionID string
Command string
Exit string
LogPath string
Tail string
}
// bgKeep 是内存里保留的作业数上限:超出时优先淘汰最久的**已完成**作业,
// 免得跑了几百个后台任务的会话把作业表变成小泄漏。
const bgKeep = 32
// bgDefaultTail / bgMaxTail:bg_status 输出尾部的字节数(默认/上限)。
const (
bgDefaultTail = 2000
bgMaxTail = 20000
)
// 作业表是**进程级**的:跨回合、跨会话可见(同一次 a2go 进程内)。
// 已知边界:a2go 重启后,之前起的子进程仍在跑(它们在别的进程组里,不受我们
// 管辖),但不再出现在这张表里——这点写进工具描述,别让模型以为能找回来。
var (
bgMu sync.Mutex
bgJobs = map[string]*bgJob{}
bgSeq []string // 插入顺序,保证列表稳定
)
// newJobID 生成短作业号。
func newJobID() string {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("bg-%d", time.Now().UnixNano()%1_000_000_000)
}
return "bg-" + hex.EncodeToString(b[:])
}
// startJob 起一个后台作业并立刻返回(不等待)。sessionID/onDone 用于完成推送。
func startJob(dir, command, sessionID string, onDone func(BackgroundDone)) (*bgJob, error) {
id := newJobID()
logPath := filepath.Join(scratch.Dir(), id+".log")
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return nil, fmt.Errorf("open job log: %w", err)
}
cmd := exec.Command("sh", "-c", command)
if dir != "" {
cmd.Dir = dir
}
execx.SeparateGroup(cmd)
cmd.Stdout, cmd.Stderr = f, f
if err := cmd.Start(); err != nil {
_ = f.Close()
return nil, err
}
j := &bgJob{ID: id, Command: command, Dir: dir, LogPath: logPath,
StartedAt: time.Now(), cmd: cmd, log: f, sessionID: sessionID, onDone: onDone}
go func() { j.finish(cmd.Wait()) }()
bgMu.Lock()
bgJobs[id] = j
bgSeq = append(bgSeq, id)
// 超预算:从最往前的开始淘汰已完成的;全在跑就留着(不牺牲活着的作业)。
for len(bgSeq) > bgKeep {
removed := false
if old := bgJobs[bgSeq[0]]; old != nil && old.isFinished() {
delete(bgJobs, bgSeq[0])
bgSeq = bgSeq[1:]
removed = true
}
if !removed {
break
}
}
bgMu.Unlock()
return j, nil
}
// finish 记录退出状态并关掉日志句柄(子进程在 exec 时已 dup 过 fd,关它不影响写)。
// 结束后向启动它的会话推送一条通知:长任务不该靠模型反复轮询才知道跑完了。
func (j *bgJob) finish(err error) {
if j.log != nil {
_ = j.log.Close()
}
j.mu.Lock()
if j.finished {
j.mu.Unlock()
return
}
j.finished = true
j.endedAt = time.Now()
if err == nil {
j.exit = "exit 0"
} else {
j.exit = err.Error()
}
onDone, sessionID, exit := j.onDone, j.sessionID, j.exit
j.mu.Unlock()
if onDone == nil {
return
}
ev := BackgroundDone{
JobID: j.ID, SessionID: sessionID, Command: j.Command,
Exit: exit, LogPath: j.LogPath, Tail: j.tail(bgDefaultTail),
}
go func() {
defer func() {
// 通知是副作用,不能让它把等待进程收尾的 goroutine 带崩。
_ = recover()
}()
onDone(ev)
}()
}
func (j *bgJob) isFinished() bool {
j.mu.Lock()
defer j.mu.Unlock()
return j.finished
}
// stopJob 杀掉整个作业进程组。已结束的作业返回 nil(幂等)。
func stopJob(id string) error {
bgMu.Lock()
j := bgJobs[id]
bgMu.Unlock()
if j == nil {
return fmt.Errorf("no such job %q (jobs are tracked per a2go process; a restart forgets them)", id)
}
j.mu.Lock()
cmd, finished := j.cmd, j.finished
j.mu.Unlock()
if finished {
return nil
}
return execx.KillTree(cmd)
}
// state 一行状态:跑着就说跑了多久,结束就说退出原因和总耗时。
func (j *bgJob) state() string {
j.mu.Lock()
exit, ended := j.exit, j.endedAt
j.mu.Unlock()
if exit == "" {
return "running for " + humanDur(time.Since(j.StartedAt))
}
return fmt.Sprintf("exited (%s) after %s", exit, humanDur(ended.Sub(j.StartedAt)))
}
// humanDur 秒以下的作业很常见("exited (exit 0) after 0s" 那种读起来像坏了)。
func humanDur(d time.Duration) string {
if d < time.Second {
return d.Round(time.Millisecond).String()
}
return d.Round(time.Second).String()
}
// tail 返回输出尾部。
//
// 只读文件末尾那一段:后台作业最常见的正是下载/构建,进度条能刷出几十 MB,
// 用 os.ReadFile 取尾部等于把整个日志吞进内存。
func (j *bgJob) tail(n int) string {
if n <= 0 {
n = bgDefaultTail
}
if n > bgMaxTail {
n = bgMaxTail
}
f, err := os.Open(j.LogPath)
if err != nil {
return "(no output yet)"
}
defer f.Close()
st, err := f.Stat()
if err != nil || st.Size() == 0 {
return "(empty)"
}
start := st.Size() - int64(n)
if start < 0 {
start = 0
}
buf := make([]byte, st.Size()-start)
if _, err := f.ReadAt(buf, start); err != nil && err != io.EOF {
return "(read failed: " + err.Error() + ")"
}
s := string(buf)
if start > 0 {
// 起点可能落在 UTF-8 字符中间:往后推到首字节,别把开头的汉字留半个。
for len(s) > 0 && s[0]&0xC0 == 0x80 {
s = s[1:]
}
s = "…" + s
}
return strings.TrimRight(s, "\n")
}
// brief 是列表里的一行。
func (j *bgJob) brief() string {
cmd := j.Command
if len(cmd) > 70 {
c, _ := execx.Truncate([]byte(cmd), 70)
cmd = string(c) + "…"
}
return fmt.Sprintf("%s [%s] %s", j.ID, j.state(), cmd)
}
// render 是单个作业的完整展示(状态 + 输出尾部)。
func (j *bgJob) render(n int) string {
var b strings.Builder
fmt.Fprintf(&b, "%s [%s]\n$ %s\nlog: %s\n", j.ID, j.state(), j.Command, j.LogPath)
if n > 0 {
fmt.Fprintf(&b, "--- output tail (%d bytes) ---\n%s\n", n, j.tail(n))
}
return strings.TrimRight(b.String(), "\n")
}
func bgJobByID(id string) *bgJob {
bgMu.Lock()
defer bgMu.Unlock()
return bgJobs[id]
}
func bgAllJobs() []*bgJob {
bgMu.Lock()
defer bgMu.Unlock()
out := make([]*bgJob, 0, len(bgSeq))
for _, id := range bgSeq {
if j := bgJobs[id]; j != nil {
out = append(out, j)
}
}
return out
}
// BackgroundTools 返回后台执行相关的两个工具:
// bash_bg(起/停,执行类,需要确认)与 bg_status(查状态与输出,只读可并发)。
// 拆成两个工具是为了**轮询不打扰用户**:作业跑一分钟,模型查十次输出,
// 若查状态也要审批,就等于把「后台」的代价又挪回到人身上。
func BackgroundTools(workDir string) []*Tool { return BackgroundToolsNotify(workDir, "", nil) }
// BackgroundToolsNotify 是带完成推送的版本:sessionID 与 onDone 用来在作业结束时
// 主动通知启动它的会话,而不是让模型靠 bg_status 轮询。
func BackgroundToolsNotify(workDir, sessionID string, onDone func(BackgroundDone)) []*Tool {
return []*Tool{
{
Name: "bash_bg",
Description: "Start or stop a background shell command — use it for anything that outlives ~2 minutes " +
"(downloads, builds, dev servers, watchers) instead of `cmd &` + pkill + tail gymnastics. " +
"action=start needs command, returns a job id immediately and keeps running; action=stop needs id " +
"(kills the whole process group, children included). Output is written to a log file you read with " +
"bg_status or the read tool. When the job finishes you will receive a system notification with the " +
"exit status and output tail — do NOT poll in a loop waiting for it. Jobs live in this a2go " +
"process: a restart leaves the child running but forgets the id.",
SideEffect: true,
Kind: KindExec,
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{"type": "string", "enum": []string{"start", "stop"}},
"command": map[string]any{"type": "string",
"description": "start only: the shell command to run in the background"},
"id": map[string]any{"type": "string",
"description": "stop only: job id returned by a previous start"},
},
"required": []string{"action"},
},
Run: func(_ context.Context, input string) (Result, error) {
m, err := arg(input)
if err != nil {
return Result{}, err
}
switch a := str(m, "action"); a {
case "start":
cmd := str(m, "command")
if strings.TrimSpace(cmd) == "" {
return TextResult("start needs a non-empty command"), nil
}
j, err := startJob(workDir, cmd, sessionID, onDone)
if err != nil {
return TextResult("start failed: " + err.Error()), nil
}
return TextResult(fmt.Sprintf(
"started %s\n$ %s\nlog: %s\ncheck it with bg_status {\"id\":%q}",
j.ID, cmd, j.LogPath, j.ID)), nil
case "stop":
id := str(m, "id")
if id == "" {
return TextResult("stop needs: id"), nil
}
if err := stopJob(id); err != nil {
return TextResult("stop failed: " + err.Error()), nil
}
return TextResult("stopped " + id + " (process group killed)"), nil
default:
return TextResult("unknown action " + a + " (want start|stop)"), nil
}
},
},
{
Name: "bg_status",
Description: "Show background jobs started with bash_bg: pass id for one job's state plus its output tail, or omit id for a one-line-per-job list. Read-only, safe to poll.",
ReadOnly: true,
ConcurrencySafe: true,
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string", "description": "job id (omit to list all)"},
"tail": map[string]any{"type": "integer", "description": fmt.Sprintf("output bytes to show, default %d", bgDefaultTail)},
},
},
Run: func(_ context.Context, input string) (Result, error) {
var m map[string]any
if input != "" {
if err := json.Unmarshal([]byte(input), &m); err != nil {
return Result{}, fmt.Errorf("invalid tool arguments: %w", err)
}
}
id := str(m, "id")
n := bgDefaultTail
if v, ok := m["tail"].(float64); ok && v > 0 {
n = int(v)
}
if id == "" {
jobs := bgAllJobs()
if len(jobs) == 0 {
return TextResult("no background jobs (start one with bash_bg {\"action\":\"start\",\"command\":…})"), nil
}
var b strings.Builder
for _, j := range jobs {
b.WriteString(j.brief() + "\n")
}
b.WriteString(fmt.Sprintf("\n%d job(s); pass id to see output", len(jobs)))
return TextResult(strings.TrimRight(b.String(), "\n")), nil
}
j := bgJobByID(id)
if j == nil {
return TextResult(fmt.Sprintf("no such job %q (jobs are tracked per a2go process)", id)), nil
}
return TextResult(j.render(n)), nil
},
},
}
}
+339
View File
@@ -0,0 +1,339 @@
// 后台任务的 kill/等待用 syscall.Kill 驱动,Windows 上没有这个调用。
//go:build !windows
package tools
import (
"context"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"syscall"
"testing"
"time"
"unicode/utf8"
"git.gopher.ink/mirrors2/AutoZero/scratch"
)
var jobIDRe = regexp.MustCompile(`\b(bg-[0-9a-f]{8}|bg-\d+)\b`)
// resetBG 清空进程级作业表。作业表故意跨回合共享(同一次 a2go 进程里都看得见),
// 所以测试必须自己隔开,否则前一个用例的作业会出现在后一个用例的列表里。
func resetBG(t *testing.T) {
t.Helper()
bgMu.Lock()
bgJobs = map[string]*bgJob{}
bgSeq = nil
bgMu.Unlock()
}
func bgStart(t *testing.T, command string) (id, logPath string) {
t.Helper()
tl := lookupTool(t, "bash_bg")
res, err := tl.Run(context.Background(), `{"action":"start","command":`+jsonQuote(command)+`}`)
if err != nil {
t.Fatalf("start: %v", err)
}
id = jobIDRe.FindString(res.Text)
if id == "" {
t.Fatalf("没拿到作业号:%q", res.Text)
}
line := regexp.MustCompile(`log: (\S+)`).FindStringSubmatch(res.Text)
if line == nil {
t.Fatalf("没给出日志路径:%q", res.Text)
}
return id, line[1]
}
func jsonQuote(s string) string {
// 只在测试里用最省事的方式造 JSON 字符串字面量。
return `"` + strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), `"`, `\"`) + `"`
}
func bgStatus(t *testing.T, args string) string {
t.Helper()
tl := lookupTool(t, "bg_status")
res, err := tl.Run(context.Background(), args)
if err != nil {
t.Fatalf("bg_status: %v", err)
}
return res.Text
}
// TestBackgroundJobPushesCompletion:作业结束要主动通知启动它的会话,
// 带上作业号、退出状态、日志路径与输出尾部;模型不必再轮询。
func TestBackgroundJobPushesCompletion(t *testing.T) {
resetBG(t)
t.Setenv("HOME", t.TempDir())
done := make(chan BackgroundDone, 1)
j, err := startJob(t.TempDir(), "printf 'hello-bg'", "sess-42", func(ev BackgroundDone) {
done <- ev
})
if err != nil {
t.Fatal(err)
}
select {
case ev := <-done:
if ev.JobID != j.ID || ev.SessionID != "sess-42" {
t.Fatalf("notification identity: %+v (job %s)", ev, j.ID)
}
if ev.Exit != "exit 0" {
t.Fatalf("exit = %q", ev.Exit)
}
if !strings.Contains(ev.Tail, "hello-bg") {
t.Fatalf("tail missing output: %q", ev.Tail)
}
if ev.LogPath == "" {
t.Fatal("log path missing")
}
case <-time.After(5 * time.Second):
t.Fatal("no completion notification")
}
}
// TestBackgroundJobNotifiesOnFailure:非零退出同样推送,状态要如实。
func TestBackgroundJobNotifiesOnFailure(t *testing.T) {
resetBG(t)
t.Setenv("HOME", t.TempDir())
done := make(chan BackgroundDone, 1)
if _, err := startJob(t.TempDir(), "echo boom >&2; exit 3", "s1", func(ev BackgroundDone) {
done <- ev
}); err != nil {
t.Fatal(err)
}
select {
case ev := <-done:
if !strings.Contains(ev.Exit, "3") {
t.Fatalf("exit = %q", ev.Exit)
}
if !strings.Contains(ev.Tail, "boom") {
t.Fatalf("tail missing stderr: %q", ev.Tail)
}
case <-time.After(5 * time.Second):
t.Fatal("no completion notification")
}
}
// TestBashBgStartReturnsImmediately:start 不等命令跑完。
// 这是这条通道存在的理由——长任务不该占着 120 秒的同步窗口。
func TestBashBgStartReturnsImmediately(t *testing.T) {
resetBG(t)
t.Setenv("HOME", t.TempDir())
start := time.Now()
id, logPath := bgStart(t, "sleep 30")
if el := time.Since(start); el > 2*time.Second {
t.Errorf("start 阻塞了 %v,后台通道等于没有", el)
}
defer func() { _, _ = logPath, id }()
// 日志必须落在 scratch 根里:那才是 read 工具够得着的地方(围栏内)。
if !strings.HasPrefix(logPath, filepath.Clean(scratch.Dir())) {
t.Errorf("日志不在 scratch 根内:%s (scratch=%s)", logPath, scratch.Dir())
}
out := bgStatus(t, `{"id":`+jsonQuote(id)+`}`)
if !strings.Contains(out, "running") {
t.Errorf("状态应是 running:%q", out)
}
// 收尾:停掉,别留 sleep 30 给测试进程。
tl := lookupTool(t, "bash_bg")
if _, err := tl.Run(context.Background(), `{"action":"stop","id":`+jsonQuote(id)+`}`); err != nil {
t.Fatal(err)
}
}
// TestBashBgStopKillsWholeTree:stop 必须带走作业派生的子进程。
// 只杀 `sh -c` 本体的话,`sleep 60 &` 会作为孤儿继续跑(bash 工具那类挂死的
// 同一根源),模型看到的"已停止"和实际的 CPU/网络占用就对不上。
func TestBashBgStopKillsWholeTree(t *testing.T) {
resetBG(t)
if runtime.GOOS == "windows" {
t.Skip("依赖 POSIX 进程组语义")
}
t.Setenv("HOME", t.TempDir())
// 后台孙子 + 前台本体;把孙子的 pid 写进日志,供事后验尸。
id, logPath := bgStart(t, "sleep 60 & echo $! ; sleep 60")
// 先等孙子真的起来(日志里出现 pid)。先 stop 再找 pid 的话,stop 可能早于
// echo 执行,日志永远是空的,测试就什么都没验证。
deadline := time.Now().Add(5 * time.Second)
pid := 0
for time.Now().Before(deadline) && pid == 0 {
if data, err := os.ReadFile(logPath); err == nil {
if n, ok := firstPID(string(data)); ok {
pid = n
}
}
time.Sleep(20 * time.Millisecond)
}
if pid == 0 {
t.Fatal("日志里读不到孙子 pid,无法验证进程树")
}
if err := syscall.Kill(pid, 0); err != nil {
t.Fatalf("孙子进程 %d 本该在跑:%v", pid, err)
}
tl := lookupTool(t, "bash_bg")
if _, err := tl.Run(context.Background(), `{"action":"stop","id":`+jsonQuote(id)+`}`); err != nil {
t.Fatal(err)
}
// stop 之后连孙子一起没:只杀 sh 本体的话它就变成孤儿。
deadline = time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if syscall.Kill(pid, 0) != nil {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Errorf("孙子进程 %d 在 stop 之后仍在运行:没带走进程组", pid)
}
// TestBashBgCapturesOutputAndReportsExit:作业结束要报退出原因,
// 输出要能取回(含中文,验证尾部截断不切字符)。
func TestBashBgCapturesOutputAndReportsExit(t *testing.T) {
resetBG(t)
t.Setenv("HOME", t.TempDir())
id, _ := bgStart(t, "printf '第一段\\n'; sleep 0.2; printf '中文输出行\\n'")
deadline := time.Now().Add(15 * time.Second)
var out string
for time.Now().Before(deadline) {
out = bgStatus(t, `{"id":`+jsonQuote(id)+`,"tail":4000}`)
if strings.Contains(out, "exited") {
break
}
time.Sleep(50 * time.Millisecond)
}
if !strings.Contains(out, "exited (exit 0)") {
t.Errorf("应报退出:\n%s", out)
}
for _, want := range []string{"第一段", "中文输出行"} {
if !strings.Contains(out, want) {
t.Errorf("输出尾部缺 %q:\n%s", want, out)
}
}
}
// TestBgTailSnapsToRuneBoundary:尾部起点落在汉字中间时必须往后推到首字节,
// 否则送给模型的是半个字(进 JSON 就是替换字符)。
func TestBgTailSnapsToRuneBoundary(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "job.log")
body := strings.Repeat("中文测试行", 2000) // 每个汉字 3 字节
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
j := &bgJob{LogPath: path}
// 逐个 tail 长度扫一遍:任何一种切法都必须产出合法 UTF-8。
for n := 1; n < 40; n++ {
got := j.tail(n)
if !utf8.ValidString(got) {
t.Fatalf("tail=%d 切出非法 UTF-8:%q", n, got)
}
}
if !strings.HasPrefix(j.tail(10), "…") {
t.Error("被截断的尾部应带省略号,提示模型前面还有内容")
}
}
// TestBgTailHandlesBigLog:后台作业最常见的正是下载/构建,进度条能刷出几十 MB。
// 取尾部只许读末尾那一段(实现走 ReadAt,见 bashbg.go)。
func TestBgTailHandlesBigLog(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "big.log")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
line := strings.Repeat("x", 1024) + "\n"
for i := 0; i < 20000; i++ { // ~20MB
if _, err := f.WriteString(line); err != nil {
t.Fatal(err)
}
}
if _, err := f.WriteString("最后一行\n"); err != nil {
t.Fatal(err)
}
_ = f.Close()
j := &bgJob{LogPath: path}
start := time.Now()
got := j.tail(bgDefaultTail)
if el := time.Since(start); el > time.Second {
t.Errorf("取尾部花了 %v,疑似把整个日志读进内存", el)
}
if !strings.Contains(got, "最后一行") {
t.Errorf("尾部没带上最后一行:%q", got)
}
}
// TestBashBgListAndUnknownID:不传 id 是列表;查不存在的 id 要自解释。
func TestBashBgListAndUnknownID(t *testing.T) {
resetBG(t)
t.Setenv("HOME", t.TempDir())
out := bgStatus(t, `{}`)
if !strings.Contains(out, "no background jobs") {
t.Errorf("空表提示:%q", out)
}
t.Cleanup(func() { resetBG(t) })
id, _ := bgStart(t, "sleep 5")
out = bgStatus(t, `{}`)
if !strings.Contains(out, id) || !strings.Contains(out, "job(s)") {
t.Errorf("列表里应有该作业:%q", out)
}
out = bgStatus(t, `{"id":"bg-deadbeef"}`)
if !strings.Contains(out, "no such job") {
t.Errorf("未知 id 应说明:\n%s", out)
}
// 收尾
tl := lookupTool(t, "bash_bg")
_, _ = tl.Run(context.Background(), `{"action":"stop","id":`+jsonQuote(id)+`}`)
}
// TestBackgroundToolsRegistered:工具必须在内置表里,且语义标注正确——
// 标注错了,轮询输出就会每次都弹确认(后台的意义当场归零)。
func TestBackgroundToolsRegistered(t *testing.T) {
reg := NewRegistry()
for _, tl := range Builtin() {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
start, ok := reg.Get("bash_bg")
if !ok {
t.Fatal("bash_bg 没注册")
}
if !start.SideEffect || start.Kind != KindExec {
t.Errorf("bash_bg 必须是副作用 + 执行类:%+v", start)
}
status, ok := reg.Get("bg_status")
if !ok {
t.Fatal("bg_status 没注册")
}
if !status.ReadOnly || !status.ConcurrencySafe || status.SideEffect {
t.Errorf("bg_status 必须是只读可并发(轮询不该要审批):%+v", status)
}
}
// firstPID 从日志里取第一个数字串当 pid。
func firstPID(s string) (int, bool) {
for _, f := range strings.Fields(s) {
n := 0
for _, r := range f {
if r < '0' || r > '9' {
goto next
}
n = n*10 + int(r-'0')
}
if n > 0 {
return n, true
}
next:
}
return 0, false
}
+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)...)
}
+213
View File
@@ -0,0 +1,213 @@
package tools
import (
"context"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"unicode/utf8"
)
func lookupTool(t *testing.T, name string) *Tool {
t.Helper()
reg := NewRegistry()
for _, tl := range Builtin() {
if err := reg.Register(tl); err != nil {
t.Fatalf("register %s: %v", tl.Name, err)
}
}
tl, ok := reg.Get(name)
if !ok {
t.Fatalf("tool %q not registered", name)
}
return tl
}
// TestBashTimeoutIsEnforced:bash 的超时必须真的终止命令。
//
// 命令里 `sleep 60 &` 会留下一个继承 stdout 管道写端的子进程。修复前
// exec.CommandContext 只杀直接子进程,cmd.Run() 要等管道写端全部关闭才返回
// ——于是"120 秒超时"实测挂了 23 分钟(session 20261008-214717),整个回合静止。
func TestBashTimeoutIsEnforced(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("依赖 POSIX 进程组语义")
}
old := bashTimeout
bashTimeout = 400 * time.Millisecond
defer func() { bashTimeout = old }()
tl := lookupTool(t, "bash")
start := time.Now()
res, err := tl.Run(context.Background(), `{"command":"sleep 60 & sleep 60"}`)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("工具不该返回 error(失败走 Result.Err):%v", err)
}
if res.Err == nil {
t.Errorf("超时必须成为显式失败信号,text=%q", res.Text)
}
// WaitGrace 给 2 秒余量;再宽就是抓"完全没生效"的量级了。
if elapsed > 10*time.Second {
t.Errorf("超时没有生效:预算 %v,实际 %v", old, elapsed)
}
}
// TestBashCancelStopsTool:调用方取消(用户按停止)后不该再留下长时间阻塞。
func TestBashCancelStopsTool(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("依赖 POSIX 进程组语义")
}
tl := lookupTool(t, "bash")
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(300 * time.Millisecond)
cancel()
}()
start := time.Now()
res, _ := tl.Run(ctx, `{"command":"sleep 60 & sleep 60"}`)
if elapsed := time.Since(start); elapsed > 10*time.Second {
t.Errorf("取消后仍阻塞 %v(res.text=%q)", elapsed, res.Text)
}
}
// TestBashOutputTruncationKeepsUTF8:截断不能把一个汉字劈开。
// 中文一字 3 字节,16000 字节的硬切几乎必然落在字符中间,尾部非法 UTF-8
// 进 JSON 会变替换字符,也可能被模型网关直接拒掉。
func TestBashOutputTruncationKeepsUTF8(t *testing.T) {
old := bashMaxOutput
bashMaxOutput = 100
defer func() { bashMaxOutput = old }()
tl := lookupTool(t, "bash")
res, err := tl.Run(context.Background(),
`{"command":"awk 'BEGIN{for(i=0;i<2000;i++)printf \"中文测试\"}'"}`)
if err != nil {
t.Fatalf("run: %v", err)
}
if res.Err != nil {
t.Fatalf("命令失败:%s %v", res.Text, res.Err)
}
const marker = "\n...[truncated]"
body := strings.TrimSuffix(res.Text, marker)
if body == res.Text {
t.Fatalf("没有截断标记,len=%d text=%q", len(res.Text), res.Text)
}
if !utf8.ValidString(body) {
tail := len(body) - 9
if tail < 0 {
tail = 0
}
t.Errorf("截断处切坏了 UTF-8,尾部=%q", body[tail:])
}
if len(body) > bashMaxOutput {
t.Errorf("截断没生效:%d > %d", len(body), bashMaxOutput)
}
}
// TestBashRunsWithinWorkspace:workDir 语义不能被换掉时丢掉。
func TestBashRunsWithinWorkspace(t *testing.T) {
dir := t.TempDir()
reg := NewRegistry()
for _, tl := range BuiltinIn(dir, nil, nil) {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
tl, ok := reg.Get("bash")
if !ok {
t.Fatal("bash not registered")
}
res, err := tl.Run(context.Background(), `{"command":"pwd"}`)
if err != nil || res.Err != nil {
t.Fatalf("run: err=%v res=%+v", err, res)
}
// macOS 的临时目录带 /private 前缀,两侧都解引用再比。
got, want := eval(res.Text), dir
if want2 := eval(dir); want2 != "" {
want = want2
}
if got != want {
t.Errorf("没有在工作区里执行:got=%q want=%q", got, want)
}
}
func eval(s string) string {
p, err := filepath.EvalSymlinks(strings.TrimSpace(s))
if err != nil {
return ""
}
return p
}
// recordingSnap 收集留档钩子收到的改动。
type recordingSnap struct{ got []Change }
func (r *recordingSnap) Record(c Change) { r.got = append(r.got, c) }
// TestWriteEditFeedTheSnapshotHook:write/edit 必须在**落盘之后**把
// 路径/改前内容/是否新建/改后内容交给留档层。少一份或报错都算回归。
func TestWriteEditFeedTheSnapshotHook(t *testing.T) {
dir := t.TempDir()
snap := &recordingSnap{}
reg := NewRegistry()
for _, tl := range BuiltinIn(dir, NewFence(), snap) {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
w, _ := reg.Get("write")
e, _ := reg.Get("edit")
p := filepath.Join(dir, "s.txt")
// 新建:Existed=false,Before 为空
if _, err := w.Run(context.Background(),
`{"path":"`+p+`","content":"第一版"}`); err != nil {
t.Fatal(err)
}
if len(snap.got) != 1 || snap.got[0].Existed || string(snap.got[0].After) != "第一版" {
t.Fatalf("新建的留档不对:%+v", snap.got)
}
// 改动:Existed=true,Before 是旧内容
if _, err := w.Run(context.Background(),
`{"path":"`+p+`","content":"第二版"}`); err != nil {
t.Fatal(err)
}
g := snap.got[1]
if !g.Existed || string(g.Before) != "第一版" || string(g.After) != "第二版" {
t.Errorf("覆盖写的留档不对:%+v", g)
}
// edit:同样要交一份,且 Before 是编辑前全文
if _, err := e.Run(context.Background(),
`{"path":"`+p+`","old_string":"第二版","new_string":"第三版"}`); err != nil {
t.Fatal(err)
}
g = snap.got[2]
if !g.Existed || string(g.Before) != "第二版" || string(g.After) != "第三版" {
t.Errorf("edit 的留档不对:%+v", g)
}
// 失败的写不该留档(否则索引里会出现现实中没发生的改动)
if _, err := e.Run(context.Background(),
`{"path":"`+p+`","old_string":"不存在","new_string":"x"}`); err == nil {
t.Fatal("edit 应报错")
}
if len(snap.got) != 3 {
t.Errorf("失败的改动也留档了:%d 条", len(snap.got))
}
}
// TestSnapshotHookDisabledIsNoop:不传留档层时行为与从前完全一致。
func TestSnapshotHookDisabledIsNoop(t *testing.T) {
reg := NewRegistry()
for _, tl := range BuiltinIn(t.TempDir(), NewFence(), nil) {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
w, _ := reg.Get("write")
res, err := w.Run(context.Background(), `{"path":"x.txt","content":"hi"}`)
if err != nil || res.Err != nil {
t.Fatalf("write: %v %+v", err, res)
}
}
+331
View File
@@ -0,0 +1,331 @@
package tools
import (
"context"
"fmt"
"strings"
"git.gopher.ink/mirrors2/AutoZero/llm"
)
// computer 工具:本地桌面控制(借鉴 Claude Code / ccb 的 Computer Use)。
// 平台实现由 guiBackend 抽象、编译期选择,各自独立成文件:
// - computer_darwin.go macOS:osascript(System Events) + screencapture,
// 检测到 cliclick 时点击更可靠(保持独立的实现);
// - computer_linux.go Linux:xdotool + scrot/import/gnome-screenshot(X11,Wayland 不支持);
// - computer_windows.go Windows:PowerShell(SendKeys 键盘 + user32 P/Invoke 鼠标/滚轮)。
//
// 分发与参数校验在本文件(共享);校验失败必须发生在任何真实 GUI 调用之前。
// guiBackend 是单平台的桌面控制实现。
type guiBackend interface {
Available() error // 依赖检查(缺工具时给出可操作的报错)
Screenshot(ctx context.Context, region string) (string, error) // region 为 "x,y,w,h" 或 ""
Click(ctx context.Context, x, y int, kind string) error // kind: left / right / double
TypeText(ctx context.Context, text string) error
KeyChord(ctx context.Context, chordStr string) error
Scroll(ctx context.Context, direction string, amount int) error
PreflightPermissions() []string // 首次运行引导授予桌面权限;返回缺失项(空=齐全)
}
// PreflightDesktopPermissions 探测并(首次运行)引导授予桌面控制所需的系统权限
// (macOS 的辅助功能 / 屏幕录制)。返回缺失权限名(空切片=齐全)。非 macOS 由 backend
// 直接返回 nil。在程序启动时调用一次,缺失时自动打开系统设置面板并打印指引。
func PreflightDesktopPermissions() []string {
return currentGuiBackend.PreflightPermissions()
}
// currentGuiBackend 由各平台的 newGuiBackend() 在编译期决定。
var currentGuiBackend = newGuiBackend()
// Computer 返回 computer 工具(三平台通用入口)。
func Computer() *Tool {
return &Tool{
Name: "computer",
Description: "Control the local desktop: screenshot (returned as an image you can see), click, double-click, right-click, type text, press key chords, scroll. " +
"macOS: needs Accessibility & Screen Recording permission. If a click fails with an accessibility/osascript error (-25211, -1728), STOP retrying clicks and switch to `osascript` (AppleScript/System Events) or the `browser` tool instead. " +
"Screenshots are expensive (vision inference ~10-70s each): take one only when you must SEE pixels (e.g. final confirmation for the user); for verification prefer programmatic state reads (browser `js`, `osascript` reading menu/window state). " +
"Click coordinates are in real logical screen resolution — do not trust coordinates derived from a screenshot's displayed size. " +
"Linux: needs xdotool + scrot/import under X11 (Wayland unsupported). " +
"Windows: uses PowerShell (Win key unsupported by SendKeys).",
SideEffect: true,
Kind: KindExec, // 控制本机桌面:执行类
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{"type": "string", "enum": []string{"screenshot", "left_click", "right_click", "double_click", "type", "key", "scroll"},
"description": "GUI action to perform"},
"x": map[string]any{"type": "integer", "description": "Screen x coordinate (click actions)"},
"y": map[string]any{"type": "integer", "description": "Screen y coordinate (click actions)"},
"text": map[string]any{"type": "string", "description": "Text to type (action=type)"},
"key": map[string]any{"type": "string", "description": "Key or chord, e.g. enter, cmd+c / ctrl+c, cmd+shift+t (action=key)"},
"direction": map[string]any{"type": "string", "enum": []string{"up", "down"},
"description": "Scroll direction (action=scroll, default down)"},
"amount": map[string]any{"type": "integer", "description": "Scroll repetitions 1-10 (action=scroll, default 3)"},
},
"required": []string{"action"},
},
Run: func(ctx context.Context, input string) (Result, error) {
m, err := arg(input)
if err != nil {
return Result{}, err
}
b := currentGuiBackend
if err := b.Available(); err != nil {
return Result{}, err
}
vision := func(path string, err error) (string, error) {
if err != nil {
return "", err
}
p, serr := ShrinkForVision(path)
if serr != nil {
return path, nil
}
return p, nil
}
action := str(m, "action")
switch action {
case "screenshot":
path, err := vision(b.Screenshot(ctx, ""))
if err != nil {
return Result{}, err
}
return Result{Text: "screenshot saved", Images: []llm.Image{{Path: path}}}, nil
case "left_click", "right_click", "double_click":
x, y := intArg(m, "x"), intArg(m, "y")
if x < 0 || y < 0 {
return Result{}, fmt.Errorf("action %q requires valid x,y coordinates", action)
}
kind := strings.TrimSuffix(action, "_click")
if err := b.Click(ctx, x, y, kind); err != nil {
return Result{}, err
}
// 操作后截图验证;截图失败降级为纯文本,不让点击白做。
path, serr := vision(b.Screenshot(ctx, ""))
if serr != nil {
return TextResult(action + " at " + itoa(x) + "," + itoa(y) + " (post-action screenshot failed: " + serr.Error() + ")"), nil
}
return Result{Text: action + " at " + itoa(x) + "," + itoa(y), Images: []llm.Image{{Path: path}}}, nil
case "type":
text := str(m, "text")
if text == "" {
return Result{}, fmt.Errorf("action type requires non-empty text")
}
if err := b.TypeText(ctx, text); err != nil {
return Result{}, err
}
return TextResult("typed " + itoa(len([]rune(text))) + " chars"), nil
case "key":
chordStr := str(m, "key")
if _, _, err := parseChord(chordStr); err != nil {
return Result{}, err
}
if err := b.KeyChord(ctx, chordStr); err != nil {
return Result{}, err
}
return TextResult("pressed " + chordStr), nil
case "scroll":
dir := str(m, "direction")
if dir == "" {
dir = "down"
}
n := intArg(m, "amount")
if n <= 0 {
n = 3
}
if n > 10 {
n = 10
}
if err := b.Scroll(ctx, dir, n); err != nil {
return Result{}, err
}
path, serr := vision(b.Screenshot(ctx, ""))
if serr != nil {
return TextResult("scrolled " + dir + " (post-action screenshot failed: " + serr.Error() + ")"), nil
}
return Result{Text: "scrolled " + dir, Images: []llm.Image{{Path: path}}}, nil
default:
return Result{}, fmt.Errorf("unknown action %q", action)
}
},
}
}
// ---- 共享键序模型:统一词表,各平台后端再翻译为自己的语法 ----
var chordMods = map[string]bool{
"cmd": true, "command": true, "ctrl": true, "control": true, "alt": true,
"option": true, "shift": true, "fn": true, "win": true, "super": true, "meta": true,
}
var namedKeys = map[string]bool{
"enter": true, "return": true, "tab": true, "escape": true, "esc": true,
"delete": true, "backspace": true, "forwarddelete": true,
"up": true, "down": true, "left": true, "right": true,
"space": true, "home": true, "end": true, "pageup": true, "pagedown": true,
"f1": true, "f2": true, "f3": true, "f4": true, "f5": true, "f6": true,
"f7": true, "f8": true, "f9": true, "f10": true, "f11": true, "f12": true,
}
// fnKeyNum 返回功能键的编号(f1..f12),非功能键返回 0。
func fnKeyNum(main string) int {
if len(main) >= 2 && main[0] == 'f' {
n := 0
for _, r := range main[1:] {
if r < '0' || r > '9' {
return 0
}
n = n*10 + int(r-'0')
}
if n >= 1 && n <= 12 {
return n
}
}
return 0
}
// parseChord 把 "cmd+shift+t" 解析为修饰键 + 主键(命名键或单字符)。
// 全平台共用词表;平台不支持的键由后端在运行时报错。
func parseChord(s string) (mods []string, main string, err error) {
if strings.TrimSpace(s) == "" {
return nil, "", fmt.Errorf("empty key chord")
}
for _, part := range strings.Split(strings.ToLower(strings.TrimSpace(s)), "+") {
part = strings.TrimSpace(part)
if part == "" {
return nil, "", fmt.Errorf("empty key in chord %q", s)
}
if chordMods[part] && main == "" {
mods = append(mods, part)
continue
}
if main != "" {
return nil, "", fmt.Errorf("multiple main keys in chord %q", s)
}
if namedKeys[part] || len([]rune(part)) == 1 {
main = part
continue
}
return nil, "", fmt.Errorf("unknown key %q", part)
}
if main == "" {
return nil, "", fmt.Errorf("chord %q has no main key", s)
}
return mods, main, nil
}
// chordToXdotool 翻译为 xdotool 的 "mod+keysym" 语法(如 "ctrl+c"、"super+q")。
func chordToXdotool(mods []string, main, orig string) (string, error) {
modMap := map[string]string{
"ctrl": "ctrl", "control": "ctrl", "alt": "alt", "option": "alt",
"shift": "shift", "cmd": "super", "command": "super",
"win": "super", "super": "super", "meta": "super",
}
mainMap := map[string]string{
"enter": "Return", "return": "Return", "escape": "Escape", "esc": "Escape",
"tab": "Tab", "delete": "BackSpace", "backspace": "BackSpace", "forwarddelete": "Delete",
"up": "Up", "down": "Down", "left": "Left", "right": "Right",
"space": "space", "home": "Home", "end": "End",
"pageup": "Page_Up", "pagedown": "Page_Down",
}
parts := make([]string, 0, len(mods)+1)
for _, m := range mods {
if m == "fn" {
return "", fmt.Errorf("fn is not supported on Linux (chord %q)", orig)
}
parts = append(parts, modMap[m])
}
if sym, ok := mainMap[main]; ok {
parts = append(parts, sym)
} else if n := fnKeyNum(main); n > 0 {
parts = append(parts, "F"+itoa(n))
} else {
parts = append(parts, main) // 单字符即 keysym
}
return strings.Join(parts, "+"), nil
}
// chordToSendKeys 翻译为 Windows SendKeys 语法(^=ctrl %=alt +=shift)。
func chordToSendKeys(mods []string, main, orig string) (string, error) {
modMap := map[string]string{"ctrl": "^", "control": "^", "alt": "%", "option": "%", "shift": "+"}
prefix := ""
for _, m := range mods {
if sym, ok := modMap[m]; ok {
prefix += sym
continue
}
return "", fmt.Errorf("modifier %q is not supported by Windows SendKeys (chord %q)", m, orig)
}
mainMap := map[string]string{
"enter": "{ENTER}", "return": "{ENTER}", "tab": "{TAB}", "escape": "{ESC}", "esc": "{ESC}",
"delete": "{BS}", "backspace": "{BS}", "forwarddelete": "{DEL}",
"up": "{UP}", "down": "{DOWN}", "left": "{LEFT}", "right": "{RIGHT}",
"space": " ", "home": "{HOME}", "end": "{END}",
"pageup": "{PGUP}", "pagedown": "{PGDN}",
}
if sym, ok := mainMap[main]; ok {
return prefix + sym, nil
}
if n := fnKeyNum(main); n > 0 {
return prefix + "{F" + itoa(n) + "}", nil
}
c := []rune(main)[0]
if strings.ContainsRune("+^%~(){}[]", c) {
return prefix + "{" + main + "}", nil
}
return prefix + main, nil
}
// sendKeysEscape 把任意文本转义为 SendKeys 字面量(特殊字符包 {},
// 换行/制表转 Enter/Tab)。
func sendKeysEscape(text string) string {
var b strings.Builder
for _, r := range text {
switch r {
case '\n':
b.WriteString("{ENTER}")
case '\t':
b.WriteString("{TAB}")
case '+', '^', '%', '~', '(', ')', '{', '}', '[', ']':
b.WriteString("{" + string(r) + "}")
default:
b.WriteRune(r)
}
}
return b.String()
}
// parseRegion 解析 "x,y,w,h"。
func parseRegion(region string) (x, y, w, h int, err error) {
if _, err := fmt.Sscanf(strings.ReplaceAll(region, " ", ""), "%d,%d,%d,%d", &x, &y, &w, &h); err != nil {
return 0, 0, 0, 0, fmt.Errorf("invalid region %q (want x,y,w,h)", region)
}
return x, y, w, h, nil
}
func intArg(m map[string]any, key string) int {
if v, ok := m[key].(float64); ok {
return int(v)
}
return -1
}
func itoa(n int) string { return fmt.Sprint(n) }
// oneLine 把多行命令输出压成单行(用于错误消息)。
func oneLine(b []byte) string {
s := string(b)
for i, r := range s {
if r == '\n' || r == '\r' {
s = s[:i]
break
}
}
return s
}
+251
View File
@@ -0,0 +1,251 @@
//go:build darwin
package tools
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"git.gopher.ink/mirrors2/AutoZero/diag"
"git.gopher.ink/mirrors2/AutoZero/execx"
)
// darwinBackend:macOS 桌面控制,走系统自带能力(零依赖):
// 截图用 screencapture,鼠标键盘用 System Events 的 osascript;
// 若安装了 cliclick 则用它增强(更可靠的 click/right_click/double_click)。
// 需要终端的 辅助功能(Accessibility)与 屏幕录制 权限。
type darwinBackend struct{}
func newGuiBackend() guiBackend { return darwinBackend{} }
func (darwinBackend) Available() error { return nil } // osascript/screencapture 为系统自带
// PreflightPermissions 探测桌面控制所需的 macOS TCC 权限(辅助功能 / 屏幕录制),
// 缺失时打开系统设置对应面板引导用户授权,并尝试触发一次受保护调用以唤起原生弹窗。
// 返回缺失权限名(空切片=齐全)。仅在首次运行(无标记文件)时自动打开面板,避免每次启动打扰。
func (darwinBackend) PreflightPermissions() []string {
st := checkDesktopPermissions()
var missing []string
if !st.Accessibility {
missing = append(missing, "Accessibility")
}
if !st.ScreenCapture {
missing = append(missing, "Screen Recording")
}
if len(missing) == 0 {
return nil
}
// 首次运行自动打开系统设置面板引导;写标记后不再自动弹。
home, _ := os.UserHomeDir()
marker := filepath.Join(home, ".autogo", ".perms-prompted")
if _, err := os.Stat(marker); err != nil {
openSettingsPane("Privacy_Accessibility")
openSettingsPane("Privacy_ScreenCapture")
_ = os.MkdirAll(filepath.Dir(marker), 0o755)
_ = os.WriteFile(marker, []byte(time.Now().UTC().Format(time.RFC3339)), 0o644)
}
// 触发原生授权弹窗:未授权时调用一次受保护 API 即可让 macOS 弹窗引导。
_, _ = runOSA(context.Background(), `tell application "System Events" to return name of first process`)
// 指引走 diag:TUI 下它被换到服务日志,直写 os.Stderr 会打穿 alt-screen。
diag.Printf("[setup] macOS 桌面权限缺失:%s。请在「系统设置 → 隐私与安全性 → %s」中开启,并完全重启本程序(终端/App)使授权生效。",
strings.Join(missing, "、"), strings.Join(missing, " / "))
return missing
}
// checkDesktopPermissions 探测两项 TCC 权限是否已授予。
func checkDesktopPermissions() permState {
s := permState{}
// 辅助功能:未授权时 System Events 事件请求被拒。
if _, err := runOSA(context.Background(), `tell application "System Events" to return name of first process`); err == nil {
s.Accessibility = true
}
// 屏幕录制:未授权时 screencapture 不产生文件。
if p, err := captureScreen(context.Background(), ""); err == nil {
s.ScreenCapture = true
_ = os.Remove(p)
}
return s
}
type permState struct {
Accessibility bool
ScreenCapture bool
}
// openSettingsPane 打开系统设置的某个隐私子面板。
func openSettingsPane(pane string) {
_ = exec.Command("open", "x-apple.systempreferences:com.apple.preference.security?"+pane).Start()
}
func (darwinBackend) Screenshot(ctx context.Context, region string) (string, error) {
return captureScreen(ctx, region)
}
func (darwinBackend) Click(_ context.Context, x, y int, kind string) error {
if hasCliclick() {
_, err := runShell(context.Background(), clickScript(x, y, true, kind))
return err
}
if _, err := runOSA(context.Background(), clickScript(x, y, false, kind)); err != nil {
return fmt.Errorf("%w (hint: install cliclick for more reliable clicks, or grant Accessibility permission)", err)
}
return nil
}
func (darwinBackend) TypeText(_ context.Context, text string) error {
_, err := runOSA(context.Background(), fmt.Sprintf(`tell application "System Events" to keystroke %s`, osaQuote(text)))
return err
}
func (darwinBackend) KeyChord(_ context.Context, chordStr string) error {
stmt, err := osaKeyChord(chordStr)
if err != nil {
return err
}
_, err = runOSA(context.Background(), `tell application "System Events" to `+stmt)
return err
}
func (darwinBackend) Scroll(_ context.Context, direction string, amount int) error {
code := "125" // down arrow
if direction == "up" {
code = "126"
}
var b strings.Builder
b.WriteString(`tell application "System Events"`)
for i := 0; i < amount; i++ {
fmt.Fprintf(&b, " delay 0.05 key code %s", code)
}
b.WriteString(` end tell`)
_, err := runOSA(context.Background(), b.String())
return err
}
// ---- macOS 底层工具函数(browser.go 也复用 osaQuote/runOSA)----
// osaQuote 把字符串安全嵌入 AppleScript 双引号字符串。
func osaQuote(s string) string {
return "\"" + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + "\""
}
// osaKeyChord 把 "cmd+shift+t" 这类键组合编译为 AppleScript 语句。
func osaKeyChord(chord string) (string, error) {
mods := map[string]string{
"cmd": "command down", "command": "command down",
"ctrl": "control down", "control": "control down",
"alt": "option down", "option": "option down",
"shift": "shift down", "fn": "fn down",
}
named := map[string]string{
"enter": "36", "return": "36", "tab": "48", "escape": "53", "esc": "53",
"delete": "51", "backspace": "51", "forwarddelete": "117",
"up": "126", "down": "125", "left": "123", "right": "124",
"space": "49", "home": "115", "end": "119", "pageup": "116", "pagedown": "121",
"f1": "122", "f2": "120", "f3": "99", "f4": "118", "f5": "96", "f6": "97",
"f7": "98", "f8": "100", "f9": "101", "f10": "109", "f11": "103", "f12": "111",
}
var using []string
var main string
for _, part := range strings.Split(strings.ToLower(strings.TrimSpace(chord)), "+") {
part = strings.TrimSpace(part)
if part == "" {
return "", fmt.Errorf("empty key in chord %q", chord)
}
if m, ok := mods[part]; ok && main == "" {
using = append(using, m)
continue
}
if main != "" {
return "", fmt.Errorf("multiple non-modifier keys in chord %q", chord)
}
if code, ok := named[part]; ok {
main = "key code " + code
continue
}
if len([]rune(part)) == 1 {
main = "keystroke " + osaQuote(part)
continue
}
return "", fmt.Errorf("unknown key %q", part)
}
if main == "" {
return "", fmt.Errorf("chord %q has no main key", chord)
}
if len(using) > 0 {
return main + " using {" + strings.Join(using, ", ") + "}", nil
}
return main, nil
}
func runOSA(ctx context.Context, script string) (string, error) {
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
out, err := execx.Run(cctx, execx.Options{}, "osascript", "-e", script)
if err != nil {
return strings.TrimSpace(string(out)), fmt.Errorf("osascript: %w: %s", err, strings.TrimSpace(string(out)))
}
return strings.TrimSpace(string(out)), nil
}
func hasCliclick() bool {
_, err := exec.LookPath("cliclick")
return err == nil
}
// captureScreen 截屏到临时文件(region 非空时截取 "x,y,w,h")。
// screencapture 在等屏幕录制授权弹窗时会一直不返回,所以必须有超时。
func captureScreen(ctx context.Context, region string) (string, error) {
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
path := fmt.Sprintf("/tmp/autogo-shot-%d.png", time.Now().UnixNano())
args := []string{"-x"}
if region != "" {
args = append(args, "-R", region)
}
args = append(args, path)
out, err := execx.Run(cctx, execx.Options{}, "screencapture", args...)
if err != nil {
_ = os.Remove(path)
return "", fmt.Errorf("screencapture: %w: %s", err, strings.TrimSpace(string(out)))
}
if _, err := os.Stat(path); err != nil {
return "", fmt.Errorf("screenshot was not created (check Screen Recording permission)")
}
return path, nil
}
func clickScript(x, y int, cliclick bool, kind string) string {
if cliclick {
switch kind {
case "right":
return fmt.Sprintf("cliclick rc:%d,%d", x, y)
case "double":
return fmt.Sprintf("cliclick dc:%d,%d", x, y)
default:
return fmt.Sprintf("cliclick c:%d,%d", x, y)
}
}
switch kind {
case "double":
return fmt.Sprintf(`tell application "System Events" to click at {%d, %d} delay 0.05 click at {%d, %d}`, x, y, x, y)
default:
return fmt.Sprintf(`tell application "System Events" to click at {%d, %d}`, x, y)
}
}
func runShell(ctx context.Context, cmdline string) (string, error) {
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
out, err := execx.Shell(cctx, execx.Options{}, cmdline)
if err != nil {
return strings.TrimSpace(string(out)), fmt.Errorf("%s: %w: %s", strings.Fields(cmdline)[0], err, strings.TrimSpace(string(out)))
}
return strings.TrimSpace(string(out)), nil
}
+38
View File
@@ -0,0 +1,38 @@
//go:build darwin
package tools
import (
"testing"
)
func TestOSAQuote(t *testing.T) {
if got := osaQuote(`he said "hi" \ ok`); got != `"he said \"hi\" \\ ok"` {
t.Fatalf("osaQuote: %q", got)
}
}
func TestOSAKeyChord(t *testing.T) {
cases := map[string]string{
"enter": "key code 36",
"cmd+c": `keystroke "c" using {command down}`,
"cmd+shift+t": "keystroke \"t\" using {command down, shift down}",
"pageup": "key code 116",
"ctrl+alt+delete": "key code 51 using {control down, option down}",
"fn+left": "key code 123 using {fn down}",
}
for in, want := range cases {
got, err := osaKeyChord(in)
if err != nil {
t.Fatalf("%s: %v", in, err)
}
if got != want {
t.Fatalf("%s: got %q want %q", in, got, want)
}
}
for _, bad := range []string{"", "cmd", "cmd+a+b", "cmd+notaoption+q", "foo"} {
if _, err := osaKeyChord(bad); err == nil {
t.Fatalf("%q should be rejected", bad)
}
}
}
+291
View File
@@ -0,0 +1,291 @@
//go:build linux
package tools
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"git.gopher.ink/mirrors2/AutoZero/diag"
"git.gopher.ink/mirrors2/AutoZero/execx"
)
// screenshotTimeout 兜住截图工具不返回的情况(授权失败、X server 卡死)。
const screenshotTimeout = 20 * time.Second
// linuxBackend:Linux 桌面控制,自动检测 X11/Wayland 会话。
// X11: 输入用 xdotool,截图用 scrot → import → gnome-screenshot。
// Wayland: 输入用 ydotool,截图用 grim → gnome-screenshot → import。
type linuxBackend struct {
inputFn func(args ...string) (string, error) // xdotool 或 ydotool
isWay bool
}
func newGuiBackend() guiBackend {
b := &linuxBackend{}
if isWayland() {
b.isWay = true
b.inputFn = ydotool
} else {
b.inputFn = xdotool
}
return b
}
// isWayland 检测当前会话是否为 Wayland。
func isWayland() bool {
if v := os.Getenv("XDG_SESSION_TYPE"); strings.EqualFold(v, "wayland") {
return true
}
if os.Getenv("WAYLAND_DISPLAY") != "" {
return true
}
return false
}
func hasBin(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
func (b *linuxBackend) Available() error {
if b.isWay {
if _, err := exec.LookPath("ydotool"); err != nil {
return fmt.Errorf("ydotool is required for computer use on Wayland (e.g. apt install ydotool); also ensure /dev/uinput is accessible (user may need input group)")
}
if !hasBin("grim") && !hasBin("gnome-screenshot") && !hasBin("import") {
return fmt.Errorf("a screenshot tool is required on Wayland (install grim, gnome-screenshot, or imagemagick)")
}
return nil
}
// X11
if _, err := exec.LookPath("xdotool"); err != nil {
return fmt.Errorf("xdotool is required for computer use on X11 (e.g. apt install xdotool)")
}
for _, t := range []string{"scrot", "import", "gnome-screenshot"} {
if hasBin(t) {
return nil
}
}
return fmt.Errorf("a screenshot tool is required (install scrot, imagemagick, or gnome-screenshot)")
}
// PreflightPermissions Linux 暂无统一的“首次引导授权”机制,依赖 Available() 的依赖检查。
func (b *linuxBackend) PreflightPermissions() []string { return nil }
func (b *linuxBackend) Screenshot(ctx context.Context, region string) (string, error) {
// 截图工具在 Wayland 授权失败/X server 卡住时会不返回;ctx 由调用方给,
// 这里再加一层兜底上限,保证 computer 工具不会把回合钉死。
cctx, cancel := context.WithTimeout(ctx, screenshotTimeout)
defer cancel()
path := filepath.Join(os.TempDir(), fmt.Sprintf("autogo-shot-%d.png", time.Now().UnixNano()))
var cmd *exec.Cmd
if b.isWay {
switch {
case hasBin("grim") && region != "":
// grim 支持区域: grim -g x,y,w,h
cmd = execx.Command(cctx, "grim", "-g", region, path)
case hasBin("grim"):
cmd = execx.Command(cctx, "grim", path)
case hasBin("gnome-screenshot"):
cmd = execx.Command(cctx, "gnome-screenshot", "-f", path)
case hasBin("import"):
cmd = execx.Command(cctx, "import", "-window", "root", path)
default:
return "", fmt.Errorf("no screenshot tool available on Wayland (install grim)")
}
} else {
// X11
switch {
case region != "" && hasBin("scrot"):
cmd = execx.Command(cctx, "scrot", "-o", "-a", region, path)
case hasBin("scrot"):
if region != "" {
// import/gnome-screenshot 不支持区域,静默截全屏并打印提示
diag.Printf("[computer] warning: region screenshot not supported with current tool; capturing full screen\n")
}
cmd = execx.Command(cctx, "scrot", "-o", path)
case hasBin("import"):
if region != "" {
diag.Printf("[computer] warning: region screenshot not supported with import; capturing full screen\n")
}
cmd = execx.Command(cctx, "import", "-window", "root", path)
case hasBin("gnome-screenshot"):
if region != "" {
diag.Printf("[computer] warning: region screenshot not supported with gnome-screenshot; capturing full screen\n")
}
cmd = execx.Command(cctx, "gnome-screenshot", "-f", path)
default:
return "", fmt.Errorf("no screenshot tool available (install scrot or imagemagick)")
}
}
if out, err := cmd.CombinedOutput(); err != nil {
_ = os.Remove(path)
return "", fmt.Errorf("screenshot failed: %w: %s", err, oneLine(out))
}
if _, err := os.Stat(path); err != nil {
return "", fmt.Errorf("screenshot was not created (is DISPLAY/WAYLAND_DISPLAY set?)")
}
return path, nil
}
func (b *linuxBackend) Click(_ context.Context, x, y int, kind string) error {
if b.isWay {
return ydotoolClick(x, y, kind)
}
args := []string{"mousemove", itoa(x), itoa(y)}
switch kind {
case "right":
args = append(args, "click", "3")
case "double":
args = append(args, "click", "--repeat", "2", "--delay", "80", "1")
default:
args = append(args, "click", "1")
}
_, err := b.inputFn(args...)
return err
}
func (b *linuxBackend) TypeText(_ context.Context, text string) error {
if b.isWay {
// ydotool type: 逐字符输入
_, err := ydotool("type", "--delay", "15", "--", text)
return err
}
_, err := xdotool("type", "--delay", "15", "--", text)
return err
}
func (b *linuxBackend) KeyChord(_ context.Context, chordStr string) error {
mods, main, err := parseChord(chordStr)
if err != nil {
return err
}
if b.isWay {
combo, err := chordToYdotool(mods, main, chordStr)
if err != nil {
return err
}
_, err = ydotool("key", combo)
return err
}
combo, err := chordToXdotool(mods, main, chordStr)
if err != nil {
return err
}
_, err = xdotool("key", "--clearmodifiers", combo)
return err
}
func (b *linuxBackend) Scroll(_ context.Context, direction string, amount int) error {
if b.isWay {
return ydotoolScroll(direction, amount)
}
button := "5" // 滚轮下
if direction == "up" {
button = "4"
}
_, err := xdotool("click", "--repeat", itoa(amount), "--delay", "90", button)
return err
}
// ---- xdotool (X11) ----
func xdotool(args ...string) (string, error) {
cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
out, err := execx.Run(cctx, execx.Options{}, "xdotool", args...)
if err != nil {
return string(out), fmt.Errorf("xdotool %s: %w: %s", joinArgs(args), err, oneLine(out))
}
return string(out), nil
}
// ---- ydotool (Wayland) ----
func ydotool(args ...string) (string, error) {
cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
out, err := execx.Run(cctx, execx.Options{}, "ydotool", args...)
if err != nil {
return string(out), fmt.Errorf("ydotool %s: %w: %s", joinArgs(args), err, oneLine(out))
}
return string(out), nil
}
func ydotoolClick(x, y int, kind string) error {
// ydotool:mousemove absolute x y; click:1=左 3=右
args := []string{"mousemove", "--absolute", itoa(x), itoa(y)}
switch kind {
case "right":
args = append(args, "click", "3")
case "double":
args = append(args, "click", "1", "--next-delay", "80", "click", "1")
default:
args = append(args, "click", "1")
}
_, err := ydotool(args...)
return err
}
func ydotoolScroll(direction string, amount int) error {
// ydotool: wheel: -120=上 120=下
delta := "120"
if direction == "up" {
delta = "-120"
}
for i := 0; i < amount; i++ {
if _, err := ydotool("wheel", "--delta", delta); err != nil {
return err
}
time.Sleep(90 * time.Millisecond)
}
return nil
}
// chordToYdotool 翻译为 ydotool 按键语法。
// ydotool 使用 Linux 内核 keycode 名称(与 xdotool 类似但不完全相同)。
func chordToYdotool(mods []string, main, orig string) (string, error) {
modMap := map[string]string{
"ctrl": "ctrl", "control": "ctrl", "alt": "alt", "option": "alt",
"shift": "shift", "cmd": "meta", "command": "meta",
"win": "meta", "super": "meta", "meta": "meta",
}
mainMap := map[string]string{
"enter": "enter", "return": "enter", "escape": "esc", "esc": "esc",
"tab": "tab", "delete": "delete", "backspace": "backspace", "forwarddelete": "delete",
"up": "up", "down": "down", "left": "left", "right": "right",
"space": "space", "home": "home", "end": "end",
"pageup": "pageup", "pagedown": "pagedown",
}
parts := make([]string, 0, len(mods)+1)
for _, m := range mods {
parts = append(parts, modMap[m])
}
if sym, ok := mainMap[main]; ok {
parts = append(parts, sym)
} else if n := fnKeyNum(main); n > 0 {
parts = append(parts, fmt.Sprintf("f%d", n))
} else {
parts = append(parts, main)
}
return strings.Join(parts, "+"), nil
}
func joinArgs(args []string) string {
s := ""
for i, a := range args {
if i > 0 {
s += " "
}
s += a
}
return s
}
+82
View File
@@ -0,0 +1,82 @@
//go:build linux
package tools
import (
"os"
"testing"
)
func TestIsWayland(t *testing.T) {
// 保存原始环境变量
origSession := os.Getenv("XDG_SESSION_TYPE")
origWayland := os.Getenv("WAYLAND_DISPLAY")
defer func() {
os.Setenv("XDG_SESSION_TYPE", origSession)
os.Setenv("WAYLAND_DISPLAY", origWayland)
}()
// 测试 X11
os.Setenv("XDG_SESSION_TYPE", "x11")
os.Unsetenv("WAYLAND_DISPLAY")
if isWayland() {
t.Fatal("should not detect wayland on x11 session")
}
// 测试 Wayland (via XDG_SESSION_TYPE)
os.Setenv("XDG_SESSION_TYPE", "wayland")
os.Unsetenv("WAYLAND_DISPLAY")
if !isWayland() {
t.Fatal("should detect wayland via XDG_SESSION_TYPE")
}
// 测试 Wayland (via WAYLAND_DISPLAY)
os.Setenv("XDG_SESSION_TYPE", "")
os.Setenv("WAYLAND_DISPLAY", "wayland-0")
if !isWayland() {
t.Fatal("should detect wayland via WAYLAND_DISPLAY")
}
// 清除两个变量
os.Unsetenv("XDG_SESSION_TYPE")
os.Unsetenv("WAYLAND_DISPLAY")
if isWayland() {
t.Fatal("should not detect wayland without env vars")
}
}
func TestChordToYdotool(t *testing.T) {
cases := []struct{ in, want string }{
{"ctrl+c", "ctrl+c"},
{"cmd+c", "meta+c"},
{"ctrl+alt+delete", "ctrl+alt+delete"},
{"pageup", "pageup"},
{"cmd+shift+t", "meta+shift+t"},
{"enter", "enter"},
{"space", "space"},
{"a", "a"},
{"f5", "f5"},
{"escape", "esc"},
}
for _, c := range cases {
mods, main, err := parseChord(c.in)
if err != nil {
t.Fatalf("%s: %v", c.in, err)
}
got, err := chordToYdotool(mods, main, c.in)
if err != nil || got != c.want {
t.Fatalf("%s: got %q (%v), want %q", c.in, got, err, c.want)
}
}
}
func TestHasBin(t *testing.T) {
// ls 应该存在
if !hasBin("ls") {
t.Fatal("ls should be found")
}
// 不存在的命令
if hasBin("nonexistent_binary_xyz_12345") {
t.Fatal("nonexistent binary should not be found")
}
}
+105
View File
@@ -0,0 +1,105 @@
package tools
import (
"context"
"testing"
)
// TestComputerValidation:参数校验失败必须发生在任何真实 GUI 调用之前
// (三平台通用:错误先行,即使依赖缺失也应报校验错——实际上 Linux/Windows
// 上 Available() 先失败也是合法错误,本测试在任一平台都应通过)。
func TestComputerValidation(t *testing.T) {
c := Computer()
cases := []string{
`{"action":"bogus"}`,
`{"action":"left_click"}`, // 缺坐标
`{"action":"type"}`, // 缺文本
`{"action":"key","key":"cmd"}`, // 无主键
`{`,
}
for _, in := range cases {
if _, err := c.Run(context.Background(), in); err == nil {
t.Fatalf("input %q should fail", in)
}
}
}
func TestParseChord(t *testing.T) {
mods, main, err := parseChord("cmd+shift+t")
if err != nil || main != "t" || len(mods) != 2 || mods[0] != "cmd" || mods[1] != "shift" {
t.Fatalf("cmd+shift+t: mods=%v main=%q err=%v", mods, main, err)
}
if _, main, _ := parseChord("ENTER"); main != "enter" {
t.Fatalf("case normalization failed: %q", main)
}
for _, bad := range []string{"", "cmd", "cmd+a+b", "cmd++q", "foo", "ctrl+enter+esc"} {
if _, _, err := parseChord(bad); err == nil {
t.Fatalf("%q should be rejected", bad)
}
}
}
func TestChordToXdotool(t *testing.T) {
cases := []struct{ in, want string }{
{"ctrl+c", "ctrl+c"},
{"cmd+c", "super+c"},
{"ctrl+alt+delete", "ctrl+alt+BackSpace"},
{"pageup", "Page_Up"},
{"cmd+shift+t", "super+shift+t"},
{"enter", "Return"},
{"space", "space"},
{"a", "a"},
}
for _, c := range cases {
mods, main, err := parseChord(c.in)
if err != nil {
t.Fatalf("%s: %v", c.in, err)
}
got, err := chordToXdotool(mods, main, c.in)
if err != nil || got != c.want {
t.Fatalf("%s: got %q (%v), want %q", c.in, got, err, c.want)
}
}
if _, err := chordToXdotool([]string{"fn"}, "a", "fn+a"); err == nil {
t.Fatal("fn should be rejected on Linux")
}
}
func TestChordToSendKeys(t *testing.T) {
cases := []struct{ in, want string }{
{"ctrl+c", "^c"},
{"alt+f4", "%{F4}"},
{"ctrl+shift+esc", "^+{ESC}"},
{"enter", "{ENTER}"},
{"pageup", "{PGUP}"},
{"ctrl+{", "^{{}"},
{"a", "a"},
}
for _, c := range cases {
mods, main, err := parseChord(c.in)
if err != nil {
t.Fatalf("%s: %v", c.in, err)
}
got, err := chordToSendKeys(mods, main, c.in)
if err != nil || got != c.want {
t.Fatalf("%s: got %q (%v), want %q", c.in, got, err, c.want)
}
}
for _, bad := range []string{"cmd+c", "win+r", "fn+left"} {
mods, main, err := parseChord(bad)
if err != nil {
t.Fatalf("%s: %v", bad, err)
}
if _, err := chordToSendKeys(mods, main, bad); err == nil {
t.Fatalf("%s should be rejected on Windows", bad)
}
}
}
func TestSendKeysEscape(t *testing.T) {
got := sendKeysEscape("a+b\nc{d}\t]")
want := "a{+}b{ENTER}c{{}d{}}{TAB}{]}"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
+211
View File
@@ -0,0 +1,211 @@
//go:build windows
package tools
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"git.gopher.ink/mirrors2/AutoZero/diag"
)
// windowsBackend:Windows 桌面控制,全部走 PowerShell(系统自带,无 CGO):
// 键盘用 System.Windows.Forms.SendKeys(Win 键不支持),鼠标与滚轮用
// user32.dll P/Invoke(SetCursorPos + mouse_event),截图用 System.Drawing。
// 使用持久化进程避免每次操作的 200-500ms 启动开销。
type windowsBackend struct {
mu sync.Mutex
cmd *exec.Cmd
stdin *bufio.Writer
stdout *bufio.Scanner
}
func newGuiBackend() guiBackend { return &windowsBackend{} }
func (b *windowsBackend) Available() error {
if _, err := exec.LookPath("powershell"); err != nil {
return fmt.Errorf("powershell is required for computer use on Windows")
}
return nil
}
// PreflightPermissions Windows 暂无“首次引导授权”机制,依赖 Available() 的依赖检查。
func (b *windowsBackend) PreflightPermissions() []string { return nil }
// ensureStarted 确保持久化 PowerShell 进程已启动。
func (b *windowsBackend) ensureStarted() error {
b.mu.Lock()
defer b.mu.Unlock()
if b.cmd != nil && b.cmd.Process != nil {
return nil
}
cmd := exec.Command("powershell", "-NoProfile", "-NoLogo", "-NonInteractive")
cmd.SysProcAttr = nil // 使用默认
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
cmd.Stderr = diag.Indirect() // 不直连终端:TUI 在 alt-screen 下会被打穿
if err := cmd.Start(); err != nil {
return err
}
b.cmd = cmd
b.stdin = bufio.NewWriter(stdin)
b.stdout = bufio.NewScanner(stdout)
// 注入 user32 类定义(只需一次)
if _, err := b.exec(psUser32); err != nil {
_ = cmd.Process.Kill()
b.cmd = nil
return fmt.Errorf("failed to inject user32: %w", err)
}
return nil
}
// exec 向持久化 PS 进程发送命令并读取输出。
// 使用唯一分隔符区分输出边界。
const psDelim = "__AG_END_"
func (b *windowsBackend) exec(script string) (string, error) {
delim := fmt.Sprintf("Write-Output '%s'", psDelim)
full := script + "; " + delim
if _, err := b.stdin.WriteString(full + "\n"); err != nil {
return "", err
}
if err := b.stdin.Flush(); err != nil {
return "", err
}
var lines []string
for b.stdout.Scan() {
line := b.stdout.Text()
if strings.Contains(line, psDelim) {
break
}
lines = append(lines, line)
}
return strings.Join(lines, "\n"), nil
}
// restart 重启 PowerShell 进程(进程崩溃时恢复)。
func (b *windowsBackend) restart() {
if b.cmd != nil && b.cmd.Process != nil {
_ = b.cmd.Process.Kill()
_, _ = b.cmd.Process.Wait()
}
b.cmd = nil
_ = b.ensureStarted()
}
// runScript 在持久化进程失败时自动重启一次。
func (b *windowsBackend) runScript(script string) (string, error) {
if err := b.ensureStarted(); err != nil {
return "", err
}
out, err := b.exec(script)
if err != nil {
// 进程可能已死,重启后重试一次
b.restart()
if err2 := b.ensureStarted(); err2 != nil {
return "", err
}
out, err = b.exec(script)
}
return out, err
}
// psUser32 是注入 user32 P/Invoke 的 PowerShell 片段(在 ensureStarted 时注入一次)。
const psUser32 = "Add-Type -TypeDefinition 'using System;using System.Runtime.InteropServices;public class AGUser32{[DllImport(\"user32.dll\")]public static extern bool SetCursorPos(int x,int y);[DllImport(\"user32.dll\")]public static extern void mouse_event(uint f,uint dx,uint dy,uint d,UIntPtr e);}' -ErrorAction SilentlyContinue"
// mouse_event flags:2=LEFTDOWN 4=LEFTUP 8=RIGHTDOWN 16=RIGHTUP 2048=WHEEL
const (
meLeftDown = "2"
meLeftUp = "4"
meRightDown = "8"
meRightUp = "16"
meWheel = "2048"
wheelNotch = "120"
)
func (b *windowsBackend) Click(_ context.Context, x, y int, kind string) error {
var bld strings.Builder
fmt.Fprintf(&bld, "[AGUser32]::SetCursorPos(%d,%d); Start-Sleep -Milliseconds 80; ", x, y)
switch kind {
case "right":
fmt.Fprintf(&bld, "[AGUser32]::mouse_event(%s,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 40; [AGUser32]::mouse_event(%s,0,0,0,[UIntPtr]::Zero);", meRightDown, meRightUp)
case "double":
fmt.Fprintf(&bld, "[AGUser32]::mouse_event(%s,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 40; [AGUser32]::mouse_event(%s,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 120; [AGUser32]::mouse_event(%s,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 40; [AGUser32]::mouse_event(%s,0,0,0,[UIntPtr]::Zero);", meLeftDown, meLeftUp, meLeftDown, meLeftUp)
default:
fmt.Fprintf(&bld, "[AGUser32]::mouse_event(%s,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 40; [AGUser32]::mouse_event(%s,0,0,0,[UIntPtr]::Zero);", meLeftDown, meLeftUp)
}
_, err := b.runScript(bld.String())
return err
}
func (b *windowsBackend) TypeText(_ context.Context, text string) error {
seq := strings.ReplaceAll(sendKeysEscape(text), "'", "''")
script := "Add-Type -AssemblyName System.Windows.Forms; " +
"[System.Windows.Forms.SendKeys]::SendWait('" + seq + "');"
_, err := b.runScript(script)
return err
}
func (b *windowsBackend) KeyChord(_ context.Context, chordStr string) error {
mods, main, err := parseChord(chordStr)
if err != nil {
return err
}
seq, err := chordToSendKeys(mods, main, chordStr)
if err != nil {
return err
}
seq = strings.ReplaceAll(seq, "'", "''")
script := "Add-Type -AssemblyName System.Windows.Forms; " +
"[System.Windows.Forms.SendKeys]::SendWait('" + seq + "');"
_, err = b.runScript(script)
return err
}
func (b *windowsBackend) Scroll(_ context.Context, direction string, amount int) error {
delta := wheelNotch // 上滚为正
if direction == "down" {
delta = "-" + wheelNotch
}
var bld strings.Builder
for i := 0; i < amount; i++ {
fmt.Fprintf(&bld, "Start-Sleep -Milliseconds 90; [AGUser32]::mouse_event(%s,0,0,%s,[UIntPtr]::Zero); ", meWheel, delta)
}
_, err := b.runScript(bld.String())
return err
}
func (b *windowsBackend) Screenshot(_ context.Context, region string) (string, error) {
path := filepath.Join(os.TempDir(), fmt.Sprintf("autogo-shot-%d.png", time.Now().UnixNano()))
psPath := strings.ReplaceAll(path, "'", "''")
var bld strings.Builder
bld.WriteString("Add-Type -AssemblyName System.Windows.Forms,System.Drawing; ")
if region != "" {
x, y, w, h, err := parseRegion(region)
if err != nil {
return "", err
}
fmt.Fprintf(&bld, "$bmp=New-Object System.Drawing.Bitmap(%d,%d); $g=[System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen(%d,%d,0,0,$bmp.Size); ", w, h, x, y)
} else {
bld.WriteString("$b=[System.Windows.Forms.SystemInformation]::VirtualScreen; $bmp=New-Object System.Drawing.Bitmap($b.Width,$b.Height); $g=[System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.X,$b.Y,0,0,$bmp.Size); ")
}
bld.WriteString("$g.Dispose(); $bmp.Save('" + psPath + "',[System.Drawing.Imaging.ImageFormat]::Png); $bmp.Dispose();")
if out, err := b.runScript(bld.String()); err != nil {
_ = os.Remove(path)
return "", fmt.Errorf("screenshot failed: %w: %s", err, out)
}
return path, nil
}
+71
View File
@@ -0,0 +1,71 @@
//go:build windows
package tools
import (
"context"
"testing"
)
// 这份用例只在 Windows 上编译,且**只有真的起得来 PowerShell 时才执行**:
// 没有 PowerShell 的机器(Server Core、被裁掉的镜像)应当跳过,而不是把
// "跑不起来" 报成 "后端坏了"。可用性问题走 Available(),不在这里重述。
func TestRunScript(t *testing.T) {
b := newGuiBackend()
if err := b.Available(); err != nil {
t.Skipf("PowerShell 不可用:%v", err)
}
w := b.(*windowsBackend)
out, err := w.runScript("Write-Output 'hello'")
if err != nil {
t.Fatalf("runScript 失败:%v", err)
}
if out != "hello" {
t.Fatalf("got %q, want hello", out)
}
}
func TestRunScriptError(t *testing.T) {
b := newGuiBackend()
if err := b.Available(); err != nil {
t.Skipf("PowerShell 不可用:%v", err)
}
if _, err := b.(*windowsBackend).runScript("throw 'test error'"); err == nil {
t.Fatal("失败的脚本该返回错误")
}
}
// 截图返回的是文件路径:这一条把"注入 user32 → 落到一个真实存在的 png"整条链
// 走完,是这套后端能用的最短证明。
func TestScreenshotWritesFile(t *testing.T) {
b := newGuiBackend()
if err := b.Available(); err != nil {
t.Skipf("PowerShell 不可用:%v", err)
}
path, err := b.Screenshot(context.Background(), "")
if err != nil {
t.Fatalf("Screenshot 失败:%v", err)
}
if path == "" {
t.Fatal("没有返回文件路径")
}
}
func TestPSUser32Constant(t *testing.T) {
if psUser32 == "" {
t.Fatal("psUser32 should not be empty")
}
}
func TestMouseConstants(t *testing.T) {
if meLeftDown != "2" || meLeftUp != "4" {
t.Fatal("left mouse constants incorrect")
}
if meRightDown != "8" || meRightUp != "16" {
t.Fatal("right mouse constants incorrect")
}
if meWheel != "2048" || wheelNotch != "120" {
t.Fatal("wheel constants incorrect")
}
}
+199
View File
@@ -0,0 +1,199 @@
package tools
import "strings"
// editMatch 是 old_string 在文件内容里的一个字节区间 [start, end)。
type editMatch struct {
start int
end int
}
// findEditMatches 按三级降级查找 old_string 的出现位置:
//
// 1. 精确匹配:字节完全一致,命中后不再降级;
// 2. 字形归一:把弯引号、破折号、不间断空格等"看起来一样"的字符归一后匹配
// (模型从网页/文档复制的文本经常带这些字符,精确匹配会失败);
// 3. 逐行降级:忽略每行行尾空白后再匹配,用来兜住"多了几个空格/少了几个空格"。
//
// 返回的区间始终指向**原文**,调用方据此替换,不会把归一化后的字符写回文件。
func findEditMatches(content, old string) []editMatch {
if old == "" {
return nil
}
if matches := findExactMatches(content, old); len(matches) > 0 {
return matches
}
if matches := findNormalizedMatches(content, old); len(matches) > 0 {
return matches
}
return findLineMatches(content, old)
}
// findExactMatches 返回不重叠的精确出现位置(已是字节区间)。
func findExactMatches(content, old string) []editMatch {
var out []editMatch
offset := 0
for {
i := strings.Index(content[offset:], old)
if i < 0 {
return out
}
start := offset + i
out = append(out, editMatch{start: start, end: start + len(old)})
offset = start + len(old)
}
}
// findNormalizedMatches 在字形归一后的 rune 序列上匹配,再把 rune 下标映射回
// 原始字节区间。归一化是逐 rune 的一对一替换,所以下标可以直接对应。
func findNormalizedMatches(content, old string) []editMatch {
contentRunes := []rune(content)
oldRunes := []rune(old)
if len(oldRunes) == 0 || len(oldRunes) > len(contentRunes) {
return nil
}
prefix := runeByteOffsets(contentRunes)
var out []editMatch
for i := 0; i+len(oldRunes) <= len(contentRunes); i++ {
match := true
for j := range oldRunes {
if normalizeEditRune(contentRunes[i+j]) != normalizeEditRune(oldRunes[j]) {
match = false
break
}
}
if !match {
continue
}
out = append(out, editMatch{start: prefix[i], end: prefix[i+len(oldRunes)]})
i += len(oldRunes) - 1
}
return out
}
// findLineMatches 逐行比较(忽略行尾空白与字形差异),返回整行窗口的字节区间。
// old 带结尾换行时连同最后一行的换行一起替换;不带时保留原文换行。
func findLineMatches(content, old string) []editMatch {
trailingNewline := strings.HasSuffix(old, "\n")
expected := strings.Split(old, "\n")
if trailingNewline {
expected = expected[:len(expected)-1]
}
if len(expected) == 0 {
return nil
}
lines := splitLines(content)
var out []editMatch
for i := 0; i+len(expected) <= len(lines); i++ {
matched := true
for j := range expected {
if normalizeEditLine(lines[i+j].text) != normalizeEditLine(expected[j]) {
matched = false
break
}
}
if !matched {
continue
}
last := lines[i+len(expected)-1]
end := last.end
if !trailingNewline {
end = last.contentEnd
}
if len(out) > 0 && out[len(out)-1].end > lines[i].start {
continue // 与上一个命中重叠:同一个位置只算一次
}
out = append(out, editMatch{start: lines[i].start, end: end})
i += len(expected) - 1
}
return out
}
type lineSpan struct {
start int
end int // 含换行
contentEnd int // 不含 \r\n / \n
text string
}
// splitLines 切出每一行及其字节区间。空内容返回空切片;末尾换行不产生空行。
func splitLines(s string) []lineSpan {
var out []lineSpan
for start := 0; start < len(s); {
i := strings.IndexByte(s[start:], '\n')
if i < 0 {
contentEnd := len(s)
if contentEnd > start && s[contentEnd-1] == '\r' {
contentEnd--
}
out = append(out, lineSpan{start: start, end: len(s), contentEnd: contentEnd, text: s[start:contentEnd]})
break
}
end := start + i + 1
contentEnd := end - 1
if contentEnd > start && s[contentEnd-1] == '\r' {
contentEnd--
}
out = append(out, lineSpan{start: start, end: end, contentEnd: contentEnd, text: s[start:contentEnd]})
start = end
}
return out
}
// runeByteOffsets 返回每个 rune 起始处的字节偏移,外加末尾哨兵。
func runeByteOffsets(runes []rune) []int {
out := make([]int, len(runes)+1)
offset := 0
for i, r := range runes {
out[i] = offset
offset += len(string(r))
}
out[len(runes)] = offset
return out
}
// normalizeEditRune 把视觉上等价、字节上不同的字符归一。
func normalizeEditRune(r rune) rune {
switch r {
case '‘', '’', '‚', '‛':
return '\''
case '“', '”', '„', '‟':
return '"'
case '‐', '‑', '‒', '–', '—', '―', '−', '-':
return '-'
case '\u00A0', '\u2002', '\u2003', '\u2004', '\u2005', '\u2006', '\u2007', '\u2008',
'\u2009', '\u200A', '\u202F', '\u205F', '\u3000':
return ' '
}
return r
}
func normalizeEditLine(line string) string {
line = strings.TrimRight(line, " \t\r")
return string([]rune(normalizeEditTextRunes(line)))
}
func normalizeEditTextRunes(s string) []rune {
out := []rune(s)
for i := range out {
out[i] = normalizeEditRune(out[i])
}
return out
}
// applyEditMatches 从后往前替换,前面的偏移因此不受影响。
// 替换文本沿用命中区域的换行风格:命中区是 CRLF 而 new 用 LF 时,自动转成 CRLF,
// 避免一次编辑把整个文件的行尾改掉。
func applyEditMatches(content string, matches []editMatch, newText string) string {
updated := content
for i := len(matches) - 1; i >= 0; i-- {
m := matches[i]
region := content[m.start:m.end]
replacement := newText
if strings.Contains(region, "\r\n") && !strings.Contains(replacement, "\r\n") {
replacement = strings.ReplaceAll(replacement, "\n", "\r\n")
}
updated = updated[:m.start] + replacement + updated[m.end:]
}
return updated
}
+140
View File
@@ -0,0 +1,140 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// editIn runs the edit tool against a temp file. It returns the resulting file
// content and the tool error.
func editIn(t *testing.T, content, input string) (string, error) {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "f.txt")
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
reg := NewRegistry()
for _, tl := range BuiltinIn(dir, NewFence(), nil) {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
e, _ := reg.Get("edit")
input = strings.ReplaceAll(input, "PATH", strings.ReplaceAll(p, `\`, `\\`))
if _, err := e.Run(context.Background(), input); err != nil {
return "", err
}
got, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
return string(got), nil
}
// TestEditRequiresUniqueMatch:多个匹配默认必须报错,不能像以前那样静默改第一个。
func TestEditRequiresUniqueMatch(t *testing.T) {
_, err := editIn(t, "foo\nbar\nfoo\n", `{"path":"PATH","old_string":"foo","new_string":"x"}`)
if err == nil {
t.Fatal("multiple matches must fail without replace_all")
}
if !strings.Contains(err.Error(), "2 times") || !strings.Contains(err.Error(), "replace_all") {
t.Fatalf("error should name the count and the escape hatch, got %q", err)
}
}
func TestEditReplaceAll(t *testing.T) {
got, err := editIn(t, "foo\nbar\nfoo\n", `{"path":"PATH","old_string":"foo","new_string":"x","replace_all":true}`)
if err != nil {
t.Fatal(err)
}
if got != "x\nbar\nx\n" {
t.Fatalf("got %q", got)
}
}
func TestEditUniqueReplacement(t *testing.T) {
got, err := editIn(t, "alpha beta gamma\n", `{"path":"PATH","old_string":"beta","new_string":"B"}`)
if err != nil {
t.Fatal(err)
}
if got != "alpha B gamma\n" {
t.Fatalf("got %q", got)
}
}
// TestEditNormalizesTypographicCharacters:模型从网页复制的弯引号/破折号,
// 用 ASCII 也能命中;写回后文件里是 new_string 的原始字节。
func TestEditNormalizesTypographicCharacters(t *testing.T) {
got, err := editIn(t, "say “hello” — now\n", `{"path":"PATH","old_string":"say \"hello\" - now","new_string":"say \"bye\" - later"}`)
if err != nil {
t.Fatal(err)
}
if got != "say \"bye\" - later\n" {
t.Fatalf("got %q", got)
}
}
// TestEditIgnoresTrailingWhitespace:行尾多/少空格时退到逐行匹配,
// 且替换后不把旧行的多余空白带回来。
func TestEditIgnoresTrailingWhitespace(t *testing.T) {
got, err := editIn(t, "a \nb\t\nc\n", `{"path":"PATH","old_string":"a\nb\n","new_string":"x\ny\n"}`)
if err != nil {
t.Fatal(err)
}
if got != "x\ny\nc\n" {
t.Fatalf("got %q", got)
}
}
// TestEditPreservesCRLF:命中 CRLF 区域时,LF 风格的 new_string 要转成 CRLF。
func TestEditPreservesCRLF(t *testing.T) {
got, err := editIn(t, "one\r\ntwo\r\nthree\r\n", `{"path":"PATH","old_string":"one\ntwo","new_string":"1\n2"}`)
if err != nil {
t.Fatal(err)
}
if got != "1\r\n2\r\nthree\r\n" {
t.Fatalf("got %q", got)
}
}
func TestEditNotFound(t *testing.T) {
_, err := editIn(t, "abc\n", `{"path":"PATH","old_string":"zzz","new_string":"x"}`)
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("want not-found error, got %v", err)
}
}
func TestEditRejectsEmptyOldString(t *testing.T) {
_, err := editIn(t, "abc\n", `{"path":"PATH","old_string":"","new_string":"x"}`)
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("want empty-old-string error, got %v", err)
}
}
// TestEditAmbiguousDoesNotRecordSnapshot:失败的编辑不能留档。
func TestEditAmbiguousDoesNotRecordSnapshot(t *testing.T) {
dir := t.TempDir()
snap := &recordingSnap{}
reg := NewRegistry()
for _, tl := range BuiltinIn(dir, NewFence(), snap) {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
e, _ := reg.Get("edit")
p := filepath.Join(dir, "s.txt")
if err := os.WriteFile(p, []byte("x x"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := e.Run(context.Background(),
`{"path":"`+p+`","old_string":"x","new_string":"y"}`); err == nil {
t.Fatal("ambiguous edit must fail")
}
if len(snap.got) != 0 {
t.Fatalf("failed edit recorded a snapshot: %+v", snap.got)
}
}
+247
View File
@@ -0,0 +1,247 @@
package tools
import (
"context"
"fmt"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/imroc/req/v3"
"golang.org/x/net/html"
"git.gopher.ink/mirrors2/AutoZero/execx"
)
// fetch 工具:纯 HTTP 抓取(req v3 指纹仿真,TLS + HTTP/2 层伪装成真实浏览器),
// 不依赖本机浏览器——与 browser 工具(CDP)互补:fetch 毫秒级返回可读文本,
// 需要 JS 渲染、登录态或截图时才换 browser。指纹 profile 与代理由配置注入
// (SetFetchDefaults),未配置时默认 Chrome 仿真、直连。
const (
fetchTimeout = 20 * time.Second
fetchMaxBytes = 2 << 20 // 响应体读取上限(防超大文件)
fetchMaxChars = 16000 // 返回给模型的文本上限(与 bash 截断同量级)
)
var (
fetchMu sync.Mutex
fetchProfile string
fetchProxy string
fetchOnce sync.Once
fetchClient *req.Client
)
// SetFetchDefaults 在配置装载后、首次 fetch 前调用:设置指纹仿真 profile
// (chrome | firefox | safari,未知值回退 chrome)与代理 URL。
func SetFetchDefaults(profile, proxy string) {
fetchMu.Lock()
defer fetchMu.Unlock()
fetchProfile, fetchProxy = profile, proxy
}
func fetchHTTPClient() *req.Client {
fetchMu.Lock()
profile, proxy := fetchProfile, fetchProxy
fetchMu.Unlock()
fetchOnce.Do(func() {
c := req.C().SetTimeout(fetchTimeout)
switch strings.ToLower(strings.TrimSpace(profile)) {
case "firefox":
c.ImpersonateFirefox()
case "safari":
c.ImpersonateSafari()
default:
c.ImpersonateChrome()
}
if proxy != "" {
c.SetProxyURL(proxy)
}
// 响应体上限:超限截断而非报错,让模型至少看到开头内容。
// 按字符边界退:下游 renderBody 之后还有一道 execx.Truncate(fetchMaxChars),
// 所以这一处今天不会把半个字露到模型眼前——但"先夹字节、后面自然有人兜"
// 正是这类 bug 长回来的地方,而这里改一次只需要换个函数。
c.SetResponseBodyTransformer(func(raw []byte, _ *req.Request, _ *req.Response) ([]byte, error) {
if len(raw) > fetchMaxBytes {
raw, _ = execx.Truncate(raw, fetchMaxBytes)
}
return raw, nil
})
fetchClient = c
})
return fetchClient
}
// Fetch 返回 fetch 工具(GET-only:只读语义的网络访问,审批按 network 类走)。
func Fetch() *Tool {
return &Tool{
Name: "fetch",
Description: "Fetch a URL over HTTP (TLS/HTTP2 fingerprint impersonates a real browser; no local browser needed) and return readable text: HTML is converted to plain text, JSON/XML/markdown returned as-is. GET only, follows redirects. " +
"Use it as the fast default for reading web pages, docs and APIs — it works even where headless Chrome is not installed. " +
"Switch to the `browser` tool only when the page needs JavaScript rendering, login state, or a screenshot; use bash curl to download binary files.",
SideEffect: true,
Kind: KindNetwork, // 访问外部网络
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"url": map[string]any{"type": "string", "description": "Absolute http/https URL to fetch"},
},
"required": []string{"url"},
},
Run: func(ctx context.Context, input string) (Result, error) {
m, err := arg(input)
if err != nil {
return Result{}, err
}
u := strings.TrimSpace(str(m, "url"))
pu, perr := url.Parse(u)
if u == "" || perr != nil || (pu.Scheme != "http" && pu.Scheme != "https") {
return Result{}, fmt.Errorf("invalid url %q (absolute http/https required)", u)
}
rctx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()
resp, err := fetchHTTPClient().R().SetContext(rctx).Get(u)
if err != nil {
return Result{}, err
}
if resp.StatusCode >= 400 {
return Result{}, fmt.Errorf("HTTP %d for %s", resp.StatusCode, u)
}
text := renderBody(resp.GetContentType(), resp.Bytes())
if text == "" {
return Result{}, fmt.Errorf("no readable content (content-type %q) — for rendered pages use the browser tool, for binaries use bash curl", resp.GetContentType())
}
// 按字符边界截(原实现 out[:16000] 会把汉字劈成半个字节),并把
// "被截过"结构化标出来——模型看到的不是全量,UI 与统计都该知道。
raw, truncated := execx.Truncate([]byte(text), fetchMaxChars)
if truncated {
raw = append(raw, "\n...[truncated]"...)
}
return Result{Text: string(raw), Truncated: truncated}, nil
},
}
}
// renderBody 按内容类型把响应体转为可读文本:HTML 转纯文本,文本类原样,
// 二进制返回空串(由调用方报错引导换工具)。Content-Type 缺失时按内容嗅探。
func renderBody(contentType string, body []byte) string {
if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "audio/") ||
strings.HasPrefix(contentType, "video/") || strings.Contains(contentType, "octet-stream") ||
strings.Contains(contentType, "font") {
return ""
}
if isHTMLType(contentType, body) {
return htmlToText(body)
}
if isTextType(contentType) {
return string(body)
}
return ""
}
func isHTMLType(ct string, body []byte) bool {
if strings.Contains(ct, "html") {
return true
}
// 无 Content-Type(或通用 application/octet-stream)时按内容嗅探。
head := body
if len(head) > 512 {
// 只是嗅探,切坏也不会露到用户面前;但这里紧跟着就 `string(head)`,
// 留一个非法字节序列在字符串里,下一个改这个函数的人会以为是安全的。
head, _ = execx.Truncate(head, 512)
}
low := strings.ToLower(string(head))
return strings.HasPrefix(low, "<!doctype html") || strings.Contains(low, "<html")
}
func isTextType(ct string) bool {
if strings.HasPrefix(ct, "text/") {
return true
}
for _, t := range []string{"json", "xml", "javascript", "yaml", "csv"} {
if strings.Contains(ct, t) {
return true
}
}
return false
}
// ---- HTML → 可读文本 ----
// fetchSkipElems 整棵剪掉:对可读文本是纯噪声的元素。
var fetchSkipElems = map[string]bool{
"script": true, "style": true, "noscript": true, "template": true, "svg": true,
}
// fetchBlockElems 块级元素:前后补换行,让文本按视觉块分行。
var fetchBlockElems = map[string]bool{
"p": true, "div": true, "br": true, "li": true, "tr": true, "table": true,
"ul": true, "ol": true, "blockquote": true, "pre": true, "form": true,
"h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true,
"header": true, "footer": true, "main": true, "section": true, "article": true,
"figure": true, "figcaption": true, "hr": true, "option": true, "dd": true, "dt": true,
}
// \p{Zs} 覆盖 &nbsp; 等解码出的 Unicode 空格(不在 Go 的 \s 字符类里)。
var fetchSpaceRe = regexp.MustCompile(`[\s\p{Zs}]+`)
// htmlToText 把 HTML 转成可读文本:<title> 提前,块级元素分行,
// 行内空白压缩为单空格,实体由解析器解码。
func htmlToText(body []byte) string {
root, err := html.Parse(strings.NewReader(string(body)))
if err != nil {
return string(body)
}
var title, b strings.Builder
var walk func(n *html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode {
name := strings.ToLower(n.Data)
switch {
case name == "title":
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.TextNode {
title.WriteString(fetchSpaceRe.ReplaceAllString(c.Data, " "))
}
}
return
case fetchSkipElems[name]:
return
case fetchBlockElems[name]:
b.WriteString("\n")
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
if fetchBlockElems[name] {
b.WriteString("\n")
}
return
}
if n.Type == html.TextNode {
b.WriteString(fetchSpaceRe.ReplaceAllString(n.Data, " "))
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(root)
// 逐行去首尾空白、去空行:源码缩进与连续空行是噪声。
var lines []string
for _, line := range strings.Split(b.String(), "\n") {
if line = strings.TrimSpace(line); line != "" {
lines = append(lines, line)
}
}
if len(lines) == 0 {
return ""
}
text := strings.Join(lines, "\n")
if t := strings.TrimSpace(title.String()); t != "" {
return "title: " + t + "\n\n" + text
}
return text
}
+121
View File
@@ -0,0 +1,121 @@
package tools
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHTMLToText(t *testing.T) {
in := `<!DOCTYPE html><html><head><title> Doc
Title </title><meta charset="utf-8"><style>body{color:red}</style></head>
<body><h1> Hello </h1><script>alert("x")</script>
<p>a &amp; b<br>c</p><div>tail&nbsp;text</div><span>inline</span></body></html>`
out := htmlToText([]byte(in))
if !strings.Contains(out, "title: Doc Title") {
t.Fatalf("title not extracted: %q", out)
}
if strings.Contains(out, "alert") || strings.Contains(out, "color:red") {
t.Fatalf("script/style leaked: %q", out)
}
if !strings.Contains(out, "a & b") {
t.Fatalf("entity not decoded: %q", out)
}
// 块级元素分行:Hello 与 a & b 应在不同行。
if !strings.Contains(out, "Hello\na & b") {
t.Fatalf("block separation wrong: %q", out)
}
// &nbsp; 由解析器解码为普通空格,行内文本不粘连;div 为块级元素,前后换行。
if !strings.Contains(out, "tail text\ninline") {
t.Fatalf("inline text mangled: %q", out)
}
}
func TestHTMLToTextEmpty(t *testing.T) {
if out := htmlToText([]byte("<html><head></head><body></body></html>")); out != "" {
t.Fatalf("expected empty, got %q", out)
}
}
func TestFetchTool(t *testing.T) {
SetAllowedRoots() // 不限制(fetch 不走路径围栏,显式声明意图)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/page":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte("<html><title>ok</title><body><p>hello fetch</p></body></html>"))
case "/json":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"a":1}`))
case "/gone":
w.WriteHeader(http.StatusNotFound)
case "/binary":
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte{0x89, 'P', 'N', 'G'})
case "/ua":
if ua := r.Header.Get("User-Agent"); !strings.Contains(ua, "Chrome") {
w.WriteHeader(http.StatusTeapot)
_, _ = w.Write([]byte("ua=" + ua))
return
}
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("chrome impersonated"))
default:
w.WriteHeader(http.StatusTeapot)
}
}))
defer srv.Close()
call := func(url string) (string, error) {
f := Fetch()
res, err := f.Run(context.Background(), `{"url":"`+url+`"}`)
if err != nil {
return "", err
}
return res.Text, nil
}
// HTML 转文本。
out, err := call(srv.URL + "/page")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "title: ok") || !strings.Contains(out, "hello fetch") {
t.Fatalf("unexpected page result: %q", out)
}
// JSON 原样返回。
out, err = call(srv.URL + "/json")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(out) != `{"a":1}` {
t.Fatalf("unexpected json result: %q", out)
}
// 非 2xx 报错。
if _, err = call(srv.URL + "/gone"); err == nil || !strings.Contains(err.Error(), "404") {
t.Fatalf("expected 404 error, got %v", err)
}
// 二进制内容报错并引导换工具。
if _, err = call(srv.URL + "/binary"); err == nil || !strings.Contains(err.Error(), "browser") {
t.Fatalf("expected binary guidance error, got %v", err)
}
// 指纹仿真生效:默认 Chrome UA。
out, err = call(srv.URL + "/ua")
if err != nil {
t.Fatal(err)
}
if out != "chrome impersonated" {
t.Fatalf("unexpected ua result: %q", out)
}
// 非法 URL。
if _, err = call("ftp://x"); err == nil {
t.Fatal("expected invalid url error")
}
}
+227
View File
@@ -0,0 +1,227 @@
package tools
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// 路径围栏:read/write/edit/ls/grep 只允许访问允许根内的路径。
// 这是硬边界而非信任门控——`--yes` 也不能越过。
//
// 围栏是实例(Fence),不是进程级全局状态:每个会话按其工作区现场组装
// 自己的围栏(工作区目录 + 该工作区的额外授权 + spill/memory),A 工作区
// 导入的目录 B 工作区的会话看不到。包级的 Default() 围栏只服务默认工作区
// (进程 cwd)场景,历史 API(SetAllowedRoots 等)是它的薄封装。
//
// 每个根带访问模式:rw 可读写;ro 只读——read/ls/grep 放行,write/edit 拒绝。
// ro 的典型场景:导入别人的项目只做分析,防止模型改坏。
//
// bash 边界(有意为之,非遗漏):bash 不做逐路径校验,只在 cmd.Dir 绑定
// 工作区。ZCode 对持久 shell 有 bash-cwd-policy(命令结束后把逃逸的 cwd
// 重置回工作区),AutoGo 的 bash 是无状态的 `sh -c`,每次调用都在工作区
// 重新开始,cwd 不存在跨调用逃逸,该策略在此不适用。bash 仍可读写围栏外
// 文件——ro 挡的是文件类工具的误写,不是恶意进程,完整沙箱见 TODO P2。
// RootMode 是允许根的访问模式。
type RootMode string
const (
ModeRW RootMode = "rw"
ModeRO RootMode = "ro"
)
// allowedRoot 是围栏里的一个允许根。removable=false 是固有根
// (cwd/工作区目录/spill/memory),RemoveRoot 不允许摘掉它们。
type allowedRoot struct {
path string // 已解析(符号链接收敛)的绝对路径
mode RootMode
removable bool
}
// Fence 是一组允许根的实例。零值可用(空围栏 = 不限制,供测试)。
type Fence struct {
mu sync.RWMutex
roots []allowedRoot
}
// NewFence 创建空围栏。
func NewFence() *Fence { return &Fence{} }
var defaultFence = NewFence()
// Default 返回包级默认围栏(默认工作区 / 进程 cwd 场景用)。
func Default() *Fence { return defaultFence }
func normalizeRoot(r string) (string, bool) {
if r == "" {
return "", false
}
abs, err := filepath.Abs(expandHome(r))
if err != nil {
return "", false
}
// macOS 的 /var -> /private/var 等系统符号链接:根与路径必须
// 在同一(解析后的)坐标系里比较。
return evalPath(abs), true
}
// SetRoots 整体替换允许根(全部 rw、不可移除;传空 = 不限制,供测试使用)。
func (f *Fence) SetRoots(roots ...string) {
f.mu.Lock()
defer f.mu.Unlock()
f.roots = nil
for _, r := range roots {
if p, ok := normalizeRoot(r); ok {
f.roots = append(f.roots, allowedRoot{path: p, mode: ModeRW})
}
}
}
// AddRoot 追加一个允许根。要求 path 是已存在的目录;已存在时只更新模式。
// removable=false 标记固有根(工作区目录),不可被 RemoveRoot 摘除。
func (f *Fence) AddRoot(path string, mode RootMode, removable bool) error {
if mode != ModeRO {
mode = ModeRW
}
p, ok := normalizeRoot(path)
if !ok {
return fmt.Errorf("invalid path %q", path)
}
if st, err := os.Stat(p); err != nil || !st.IsDir() {
return fmt.Errorf("%s is not a directory", path)
}
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.roots {
if f.roots[i].path == p {
f.roots[i].mode = mode
// 固有根不会被追加降级为可移除(SetRoots 设的 cwd/工作区目录等)。
if f.roots[i].removable {
f.roots[i].removable = removable
}
return nil
}
}
f.roots = append(f.roots, allowedRoot{path: p, mode: mode, removable: removable})
return nil
}
// RemoveRoot 移除一个可移除的允许根(固有根不可移除)。返回是否确实移除了。
func (f *Fence) RemoveRoot(path string) bool {
p, ok := normalizeRoot(path)
if !ok {
return false
}
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.roots {
if f.roots[i].path == p {
if !f.roots[i].removable {
return false
}
f.roots = append(f.roots[:i], f.roots[i+1:]...)
return true
}
}
return false
}
// RootDetail 是允许根的展示信息(提示词注入、/dir list 用)。
type RootDetail struct {
Path string
Mode RootMode
Removable bool
}
// RootDetails 返回全部允许根(含模式)。
func (f *Fence) RootDetails() []RootDetail {
f.mu.RLock()
defer f.mu.RUnlock()
out := make([]RootDetail, 0, len(f.roots))
for _, r := range f.roots {
out = append(out, RootDetail{Path: r.path, Mode: r.mode, Removable: r.removable})
}
return out
}
// Roots 返回当前允许根路径列表(用于错误提示与展示)。
func (f *Fence) Roots() []string {
f.mu.RLock()
defer f.mu.RUnlock()
out := make([]string, 0, len(f.roots))
for _, r := range f.roots {
out = append(out, r.path)
}
return out
}
// Check 校验路径在允许根内(只读语义:ro 根放行)。
// 做尽力而为的符号链接解析(沿存在的祖先链向上解析,deepest-first),防止用软链越界。
func (f *Fence) Check(p string) error { return f.check(p, false) }
// CheckWrite 按读写语义校验:ro 根拒绝。
func (f *Fence) CheckWrite(p string) error { return f.check(p, true) }
func (f *Fence) check(p string, write bool) error {
abs, err := filepath.Abs(expandHome(p))
if err != nil {
return err
}
abs = evalPath(abs)
f.mu.RLock()
defer f.mu.RUnlock()
if len(f.roots) == 0 {
return nil
}
for _, r := range f.roots {
if abs == r.path || strings.HasPrefix(abs, r.path+string(filepath.Separator)) {
if write && r.mode == ModeRO {
return fmt.Errorf("path %q is inside read-only directory %q (imported as read-only)", p, r.path)
}
return nil
}
}
return fmt.Errorf("path %q is outside allowed roots %v (import one with /dir add)", p, f.roots)
}
// ---- 包级默认围栏的历史 API(默认工作区场景兼容保留)----
// SetAllowedRoots 整体替换默认围栏的允许根。
func SetAllowedRoots(roots ...string) { defaultFence.SetRoots(roots...) }
// AddAllowedRoot 向默认围栏追加可移除根(目录导入 /dir 命令)。
func AddAllowedRoot(path string, mode RootMode) error { return defaultFence.AddRoot(path, mode, true) }
// RemoveAllowedRoot 从默认围栏移除一个可移除根。
func RemoveAllowedRoot(path string) bool { return defaultFence.RemoveRoot(path) }
// AllowedRootDetails 返回默认围栏的全部允许根。
func AllowedRootDetails() []RootDetail { return defaultFence.RootDetails() }
// AllowedRoots 返回默认围栏的允许根路径列表。
func AllowedRoots() []string { return defaultFence.Roots() }
func checkPath(p string) error { return defaultFence.Check(p) }
func checkPathMode(p string, write bool) error { return defaultFence.check(p, write) }
// evalPath 尽力而为地把路径按真实文件系统解析(处理 /var -> /private/var
// 这类系统符号链接):从最深处向上找到第一个存在的祖先,解析后拼回尾部。
func evalPath(abs string) string {
var tail []string
cur := abs
for {
if resolved, err := filepath.EvalSymlinks(cur); err == nil {
return filepath.Join(append([]string{resolved}, tail...)...)
}
parent := filepath.Dir(cur)
if parent == cur {
return abs // 到根都没有存在的祖先:放弃解析
}
tail = append([]string{filepath.Base(cur)}, tail...)
cur = parent
}
}
+233
View File
@@ -0,0 +1,233 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCheckPath(t *testing.T) {
root := t.TempDir()
SetAllowedRoots(root)
defer SetAllowedRoots()
// 根内:直接命中
if err := checkPath(filepath.Join(root, "a.txt")); err != nil {
t.Fatalf("inside root rejected: %v", err)
}
// 根外:拒绝
if err := checkPath("/etc/passwd"); err == nil {
t.Fatal("outside root accepted")
}
// 根内不存在的新文件:父目录解析路径
if err := checkPath(filepath.Join(root, "sub", "new.txt")); err != nil {
t.Fatalf("new file under root rejected: %v", err)
}
// 符号链接越界:指向根外的链接应被解析后拒绝
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "evil")); err != nil {
t.Skip("symlink not permitted:", err)
}
err := checkPath(filepath.Join(root, "evil", "secret.txt"))
if err == nil || !strings.Contains(err.Error(), "outside allowed roots") {
t.Fatalf("symlink escape not detected: %v", err)
}
}
func TestBuiltinToolsRespectFence(t *testing.T) {
root := t.TempDir()
SetAllowedRoots(root)
defer SetAllowedRoots()
ctx := context.Background()
reg := NewRegistry()
for _, tl := range Builtin() {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
// 根外写入被拒
write, _ := reg.Get("write")
if _, err := write.Run(ctx, `{"path":"/etc/autogo-evil.txt","content":"x"}`); err == nil {
t.Fatal("write outside fence accepted")
}
// 根内写入放行
inside := filepath.Join(root, "ok.txt")
res, err := write.Run(ctx, `{"path":"`+inside+`","content":"hi"}`)
if err != nil {
t.Fatalf("write inside fence rejected: %v", err)
}
if !strings.Contains(res.Text, "wrote") {
t.Fatalf("unexpected: %q", res.Text)
}
// read / ls / grep 同样受约束
read, _ := reg.Get("read")
if _, err := read.Run(ctx, `{"path":"/etc/passwd"}`); err == nil {
t.Fatal("read outside fence accepted")
}
ls, _ := reg.Get("ls")
if _, err := ls.Run(ctx, `{"path":"/etc"}`); err == nil {
t.Fatal("ls outside fence accepted")
}
grep, _ := reg.Get("grep")
if _, err := grep.Run(ctx, `{"pattern":"x","path":"/etc"}`); err == nil {
t.Fatal("grep outside fence accepted")
}
}
// TestCheckPathMemoryDirReachable:记忆目录必须与工作区/落盘目录一起进允许根,
// 否则模型无法 read 记忆文件自查与清理(read 走 checkPath)。
func TestCheckPathMemoryDirReachable(t *testing.T) {
root := t.TempDir()
mem := filepath.Join(root, "memory")
SetAllowedRoots(root, mem)
defer SetAllowedRoots()
if err := checkPath(filepath.Join(mem, "global.md")); err != nil {
t.Fatalf("memory file inside allowed root rejected: %v", err)
}
// 同目录下的项目记忆文件同样可达。
if err := checkPath(filepath.Join(mem, "AutoGo-1a2b3c4d.md")); err != nil {
t.Fatalf("project memory file rejected: %v", err)
}
// 但记忆目录之外的路径仍然拒绝——加根不等于放宽其它地方。
if err := checkPath("/etc/passwd"); err == nil {
t.Fatal("unrelated outside path accepted")
}
}
func TestAddRemoveAllowedRoot(t *testing.T) {
base := t.TempDir()
SetAllowedRoots(base)
defer SetAllowedRoots()
// 追加:目录必须存在
if err := AddAllowedRoot(filepath.Join(base, "nope"), ModeRW); err == nil {
t.Fatal("nonexistent dir accepted")
}
extra := t.TempDir()
if err := AddAllowedRoot(extra, ModeRW); err != nil {
t.Fatalf("AddAllowedRoot: %v", err)
}
if err := checkPath(filepath.Join(extra, "a.txt")); err != nil {
t.Fatalf("imported root rejected: %v", err)
}
// 重复导入:更新模式而不是重复追加
if err := AddAllowedRoot(extra, ModeRO); err != nil {
t.Fatalf("re-add: %v", err)
}
if n := len(AllowedRootDetails()); n != 2 {
t.Fatalf("re-add duplicated root: %d roots", n)
}
// ro 模式:读放行、写拒绝
if err := checkPathMode(filepath.Join(extra, "a.txt"), false); err != nil {
t.Fatalf("ro read rejected: %v", err)
}
if err := checkPathMode(filepath.Join(extra, "a.txt"), true); err == nil {
t.Fatal("ro write accepted")
}
// 移除:仅可移除根可摘
if !RemoveAllowedRoot(extra) {
t.Fatal("RemoveAllowedRoot on imported root failed")
}
if err := checkPath(filepath.Join(extra, "a.txt")); err == nil {
t.Fatal("removed root still allowed")
}
if RemoveAllowedRoot(base) {
t.Fatal("builtin root was removable")
}
}
// 围栏实例化(v2 的账:围栏曾是进程级全局单例,A 工作区导入的目录 B 也能读)。
// 每个会话一把围栏,互不可见;默认围栏与会话围栏互不影响。
func TestFenceInstancesAreIsolated(t *testing.T) {
wsA, wsB := t.TempDir(), t.TempDir()
extraA := t.TempDir()
fa := NewFence()
if err := fa.AddRoot(wsA, ModeRW, false); err != nil {
t.Fatal(err)
}
if err := fa.AddRoot(extraA, ModeRW, true); err != nil {
t.Fatal(err)
}
fb := NewFence()
if err := fb.AddRoot(wsB, ModeRW, false); err != nil {
t.Fatal(err)
}
// A 的会话能看到 A 的工作区与 A 导入的目录。
if err := fa.Check(filepath.Join(extraA, "a.txt")); err != nil {
t.Fatalf("fence A rejected its own extra root: %v", err)
}
// B 的会话看不到 A 的任何东西——这正是隔离的目的。
if err := fb.Check(filepath.Join(wsA, "x.txt")); err == nil {
t.Fatal("fence B can read fence A's workspace")
}
if err := fb.Check(filepath.Join(extraA, "a.txt")); err == nil {
t.Fatal("fence B can read fence A's imported dir")
}
// 默认围栏不受会话围栏影响。
SetAllowedRoots(t.TempDir())
defer SetAllowedRoots()
if err := Default().Check(filepath.Join(wsA, "x.txt")); err == nil {
t.Fatal("default fence polluted by session fence")
}
}
// BuiltinIn 绑定会话围栏:工具实际执行走传入的那把,不是默认围栏。
func TestBuiltinInUsesSessionFence(t *testing.T) {
root := t.TempDir()
f := NewFence()
if err := f.AddRoot(root, ModeRW, false); err != nil {
t.Fatal(err)
}
reg := NewRegistry()
for _, tl := range BuiltinIn("", f, nil) {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
write, _ := reg.Get("write")
if _, err := write.Run(context.Background(), `{"path":"/etc/autogo-evil2.txt","content":"x"}`); err == nil {
t.Fatal("write outside session fence accepted")
}
// 默认围栏此时是空的:若工具误用默认围栏,根内写入也会被拒——用这个反差验证绑定。
res, err := write.Run(context.Background(), `{"path":"`+filepath.Join(root, "ok.txt")+`","content":"x"}`)
if err != nil {
t.Fatalf("write inside session fence rejected: %v", err)
}
if !strings.Contains(res.Text, "wrote") {
t.Fatalf("unexpected: %q", res.Text)
}
}
// 固有根不可降级:SetRoots 设的根,后续 AddRoot 不能把它变成可移除。
func TestFenceInherentRootNotDemoted(t *testing.T) {
root := t.TempDir()
f := NewFence()
f.SetRoots(root)
if err := f.AddRoot(root, ModeRO, true); err != nil {
t.Fatal(err)
}
for _, d := range f.RootDetails() {
if d.Path == evalPath(mustAbs(t, root)) && d.Removable {
t.Fatal("inherent root was demoted to removable")
}
}
}
func mustAbs(t *testing.T, p string) string {
t.Helper()
abs, err := filepath.Abs(p)
if err != nil {
t.Fatal(err)
}
return abs
}
+85
View File
@@ -0,0 +1,85 @@
package tools
import (
"os"
"path/filepath"
"strings"
)
const (
// 嵌套指令最多注入 5 个文件、每个 20KB:这些内容每轮都会进 system,
// 不设上限等于给模型塞一棵子树。
maxInstructionFiles = 5
maxInstructionBytes = 20 * 1024
)
// discoverInstructions 从 target(文件所在目录或目录本身)向上走到工作区根,
// 把沿途的 AGENTS.md 交给 emit。根自己的 AGENTS.md 已在基础 system 提示里,
// 这里跳过;工作区外的路径一律不发现。错误全部静默——读文件成功不该因为
// 顺带发现指令失败而失败。
func discoverInstructions(target string, isDir bool, root string, emit func(path, text string)) {
if emit == nil {
return
}
if root == "" {
root, _ = os.Getwd()
}
rootAbs, ok := canonicalPath(root)
if !ok {
return
}
start := target
if !isDir {
start = filepath.Dir(target)
}
startAbs, ok := canonicalPath(start)
if !ok {
return
}
if !withinDir(rootAbs, startAbs) {
return
}
found := 0
for dir := startAbs; ; dir = filepath.Dir(dir) {
if dir == rootAbs || !withinDir(rootAbs, dir) {
return
}
p := filepath.Join(dir, "AGENTS.md")
data, err := os.ReadFile(p)
if err == nil && len(data) > 0 && len(data) <= maxInstructionBytes {
text := strings.TrimSpace(string(data))
if text != "" {
emit(p, text)
found++
if found >= maxInstructionFiles {
return
}
}
}
parent := filepath.Dir(dir)
if parent == dir {
return
}
}
}
// canonicalPath 返回绝对路径,尽量解开符号链接(macOS 的 /var → /private/var)。
func canonicalPath(p string) (string, bool) {
abs, err := filepath.Abs(p)
if err != nil {
return "", false
}
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
return resolved, true
}
return abs, true
}
// withinDir 报告 p 是否等于 root 或位于 root 之下(都已是绝对路径)。
func withinDir(root, p string) bool {
rel, err := filepath.Rel(root, p)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
+103
View File
@@ -0,0 +1,103 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func writeTree(t *testing.T, root string, files map[string]string) {
t.Helper()
for name, content := range files {
p := filepath.Join(root, name)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
}
// TestDiscoverNestedInstructions:只发现目标子树到工作区根之间的 AGENTS.md,
// 根自己的那份跳过(它已在基础 system 提示里)。
func TestDiscoverNestedInstructions(t *testing.T) {
root := t.TempDir()
writeTree(t, root, map[string]string{
"AGENTS.md": "ROOT-RULES",
"sub/AGENTS.md": "SUB-RULES",
"sub/deep/AGENTS.md": "DEEP-RULES",
"sub/deep/x.go": "package deep",
})
var got []string
discoverInstructions(filepath.Join(root, "sub/deep/x.go"), false, root, func(p, text string) {
got = append(got, text)
})
joined := strings.Join(got, ",")
if !strings.Contains(joined, "DEEP-RULES") || !strings.Contains(joined, "SUB-RULES") {
t.Fatalf("nested instructions not discovered: %v", got)
}
if strings.Contains(joined, "ROOT-RULES") {
t.Fatalf("root AGENTS.md must be skipped: %v", got)
}
if len(got) != 2 || got[0] != "DEEP-RULES" || got[1] != "SUB-RULES" {
t.Fatalf("order = %v, want nearest first", got)
}
// 目录目标:目录自己的 AGENTS.md 也要发现。
got = nil
discoverInstructions(filepath.Join(root, "sub"), true, root, func(_ string, text string) {
got = append(got, text)
})
if len(got) != 1 || got[0] != "SUB-RULES" {
t.Fatalf("directory discovery = %v", got)
}
// 工作区外:不发现。
outside := t.TempDir()
writeTree(t, outside, map[string]string{"AGENTS.md": "OUTSIDE"})
got = nil
discoverInstructions(filepath.Join(outside, "x.go"), false, root, func(_ string, text string) {
got = append(got, text)
})
if len(got) != 0 {
t.Fatalf("outside instructions leaked: %v", got)
}
}
// TestReadFeedsNestedInstructions:读取子目录文件时,read 把发现的约定交给宿主钩子。
func TestReadFeedsNestedInstructions(t *testing.T) {
root := t.TempDir()
writeTree(t, root, map[string]string{
"AGENTS.md": "ROOT-RULES",
"sub/AGENTS.md": "SUB-RULES",
"sub/x.txt": "hello",
})
var got []string
reg := NewRegistry()
for _, tl := range BuiltinWithHooks(root, NewFence(), nil, BuiltinHooks{
OnInstructions: func(path, text string) { got = append(got, text) },
}) {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
r, _ := reg.Get("read")
if _, err := r.Run(context.Background(), `{"path":"sub/x.txt"}`); err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0] != "SUB-RULES" {
t.Fatalf("read hooks = %v", got)
}
// 根目录的文件不重复注入根约定。
got = nil
if _, err := r.Run(context.Background(), `{"path":"AGENTS.md"}`); err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("root instructions should not be discovered again: %v", got)
}
}
+105
View File
@@ -0,0 +1,105 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func readIn(t *testing.T, input string, files map[string]string) (string, error) {
t.Helper()
dir := t.TempDir()
for name, content := range files {
p := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
reg := NewRegistry()
for _, tl := range BuiltinIn(dir, NewFence(), nil) {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
r, _ := reg.Get("read")
res, err := r.Run(context.Background(), input)
return res.Text, err
}
// TestReadSmallFileUnchanged:不分页的小文件保持原样,不加头尾。
func TestReadSmallFileUnchanged(t *testing.T) {
got, err := readIn(t, `{"path":"f.txt"}`, map[string]string{"f.txt": "a\nb\nc\n"})
if err != nil {
t.Fatal(err)
}
if got != "a\nb\nc\n" {
t.Fatalf("got %q", got)
}
}
func TestReadOffsetLimit(t *testing.T) {
got, err := readIn(t, `{"path":"f.txt","offset":2,"limit":2}`,
map[string]string{"f.txt": "l1\nl2\nl3\nl4\nl5\n"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "lines 2-3 of 5") || !strings.Contains(got, "l2\nl3") {
t.Fatalf("got %q", got)
}
if !strings.Contains(got, "continue with offset: 4") {
t.Fatalf("missing continuation: %q", got)
}
if strings.Contains(got, "l1") || strings.Contains(got, "l4") {
t.Fatalf("paging leaked other lines: %q", got)
}
}
func TestReadBeyondEOF(t *testing.T) {
got, err := readIn(t, `{"path":"f.txt","offset":99}`, map[string]string{"f.txt": "a\nb\n"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "beyond EOF") {
t.Fatalf("got %q", got)
}
}
func TestReadDirectoryListing(t *testing.T) {
got, err := readIn(t, `{"path":".","offset":2,"limit":1}`, map[string]string{
"a.txt": "x",
"sub/b.go": "y",
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "entries 2-2 of 2") || !strings.Contains(got, "sub/") {
t.Fatalf("got %q", got)
}
if strings.Contains(got, "a.txt") {
t.Fatalf("paging leaked other entries: %q", got)
}
}
func TestReadRejectsBinary(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "bin")
if err := os.WriteFile(p, []byte{'a', 0, 'b'}, 0o644); err != nil {
t.Fatal(err)
}
reg := NewRegistry()
for _, tl := range BuiltinIn(dir, NewFence(), nil) {
if err := reg.Register(tl); err != nil {
t.Fatal(err)
}
}
r, _ := reg.Get("read")
if _, err := r.Run(context.Background(), `{"path":"bin"}`); err == nil ||
!strings.Contains(err.Error(), "binary") {
t.Fatalf("want binary error, got %v", err)
}
}
+231
View File
@@ -0,0 +1,231 @@
// 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
}
+114
View File
@@ -0,0 +1,114 @@
package tools
import (
"fmt"
"image"
"image/color"
"image/jpeg"
_ "image/png" // 解码 PNG 截图
"os"
"time"
)
// 视觉压缩策略:视觉模型会把图片缩到 ~1568px 再处理,原始 Retina 全屏 PNG
// (2-3MB)纯属浪费且容易顶爆网关的单请求字节上限。超过阈值就降采样到
// maxVisionWidth 并转 JPEG(截图场景质量足够,体积约 1/10)。
const (
shrinkThresholdBytes = 600 << 10 // 600KB
maxVisionWidth = 1280
visionJPEGQuality = 80
)
// ShrinkForVision 尽力压缩大图:PNG 且超过阈值时降采样 + 转 JPEG,
// 返回新路径;小图或解码失败时原样返回(调用方无需关心错误)。
func ShrinkForVision(path string) (string, error) {
st, err := os.Stat(path)
if err != nil || st.Size() <= shrinkThresholdBytes {
return path, nil
}
f, err := os.Open(path)
if err != nil {
return path, nil
}
src, _, err := image.Decode(f)
f.Close()
if err != nil {
return path, nil // 非图片/损坏:保持原样
}
scaled := scaleToWidth(src, maxVisionWidth)
out := fmt.Sprintf("%s/vision-%d.jpg", os.TempDir(), time.Now().UnixNano())
of, err := os.Create(out)
if err != nil {
return path, nil
}
if err := jpeg.Encode(of, scaled, &jpeg.Options{Quality: visionJPEGQuality}); err != nil {
of.Close()
os.Remove(out)
return path, nil
}
if err := of.Close(); err != nil {
os.Remove(out)
return path, nil
}
// 压缩后反而更大(异常情况)则回退原图
if ost, err2 := os.Stat(out); err2 != nil || ost.Size() >= st.Size() {
os.Remove(out)
return path, nil
}
return out, nil
}
// scaleToWidth 等比缩到 maxW 宽(box 平均采样,stdlib 实现)。
func scaleToWidth(src image.Image, maxW int) image.Image {
b := src.Bounds()
w, h := b.Dx(), b.Dy()
if w <= maxW || w == 0 || h == 0 {
return src
}
nh := h * maxW / w
if nh < 1 {
nh = 1
}
dst := image.NewRGBA(image.Rect(0, 0, maxW, nh))
for y := 0; y < nh; y++ {
sy0, sy1 := y*h/nh, (y+1)*h/nh
if sy1 <= sy0 {
sy1 = sy0 + 1
}
for x := 0; x < maxW; x++ {
sx0, sx1 := x*w/maxW, (x+1)*w/maxW
if sx1 <= sx0 {
sx1 = sx0 + 1
}
var rs, gs, bs, as, n uint64
stepY := (sy1 - sy0) / 4
if stepY < 1 {
stepY = 1
}
stepX := (sx1 - sx0) / 4
if stepX < 1 {
stepX = 1
}
for sy := sy0; sy < sy1; sy += stepY {
for sx := sx0; sx < sx1; sx += stepX {
r, g, bl, a := src.At(b.Min.X+sx, b.Min.Y+sy).RGBA()
rs += uint64(r)
gs += uint64(g)
bs += uint64(bl)
as += uint64(a)
n++
}
}
if n == 0 {
n = 1
}
dst.Set(x, y, rgbaFrom64(rs/n, gs/n, bs/n, as/n))
}
}
return dst
}
func rgbaFrom64(r, g, b, a uint64) color.RGBA {
return color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)}
}
+110
View File
@@ -0,0 +1,110 @@
package tools
import (
"bytes"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"testing"
)
// makePNG 生成 w×h 的纯色 PNG 文件(可压缩性差的真实体积)。
func makePNG(t *testing.T, w, h int, noise bool) string {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
seed := uint32(1)
next := func() uint8 {
seed = seed*1664525 + 1013904223 // LCG 伪随机
return uint8(seed >> 24)
}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
if noise {
// 逐像素伪随机:PNG 压不动,接近真实截图体积
img.SetRGBA(x, y, color.RGBA{next(), next(), next(), 255})
} else {
img.SetRGBA(x, y, color.RGBA{200, 100, 50, 255})
}
}
}
p := filepath.Join(t.TempDir(), "big.png")
f, err := os.Create(p)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
t.Fatal(err)
}
return p
}
// TestShrinkForVision:大 PNG 降采样转 JPEG;小图原样返回。
func TestShrinkForVision(t *testing.T) {
big := makePNG(t, 2400, 1600, true)
st, _ := os.Stat(big)
if st.Size() <= shrinkThresholdBytes {
t.Skipf("test png too small (%d bytes), raise noise complexity", st.Size())
}
out, err := ShrinkForVision(big)
if err != nil {
t.Fatal(err)
}
if out == big {
t.Fatal("big png should be shrunk")
}
if filepath.Ext(out) != ".jpg" {
t.Fatalf("expect jpg output, got %s", out)
}
ost, err := os.Stat(out)
if err != nil {
t.Fatal(err)
}
if ost.Size() >= st.Size() {
t.Fatalf("shrunk %d should be smaller than original %d", ost.Size(), st.Size())
}
// 宽度不超过上限
f, _ := os.Open(out)
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
t.Fatal(err)
}
if w := img.Bounds().Dx(); w > maxVisionWidth {
t.Fatalf("width %d exceeds %d", w, maxVisionWidth)
}
os.Remove(out)
// 小图:原样返回
small := makePNG(t, 64, 64, false)
out2, err := ShrinkForVision(small)
if err != nil || out2 != small {
t.Fatalf("small image passthrough: %q %v", out2, err)
}
// 非图片文件:原样返回
txt := filepath.Join(t.TempDir(), "x.png")
os.WriteFile(txt, []byte("not an image"), 0o644)
out3, err := ShrinkForVision(txt)
if err != nil || out3 != txt {
t.Fatalf("non-image passthrough: %q %v", out3, err)
}
}
// TestVisionJPEGDecodable:压缩产物可被 image/jpeg 解码且尺寸正确。
func TestVisionJPEGDecodable(t *testing.T) {
big := makePNG(t, 2000, 1000, true)
out, err := ShrinkForVision(big)
if err != nil || out == big {
t.Fatalf("expected shrink: %q %v", out, err)
}
data, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if !bytes.HasPrefix(data, []byte{0xFF, 0xD8}) {
t.Fatal("output is not JPEG (missing SOI marker)")
}
}
+430
View File
@@ -0,0 +1,430 @@
package tools
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/net/html"
"git.gopher.ink/mirrors2/AutoZero/execx"
)
// websearch 工具:关键词 → 一组带标题/链接/摘要的搜索结果。
//
// 为什么必须有:一次真实的「B 站投币视频下载工具」任务里,模型没有检索能力,
// 只能凭记忆猜接口,于是连着吃了十几次 `code:-400`、手写 wbi 签名、又试图
// clone 文档仓库(撞 120 秒超时挂死),烧掉约 20 步。同样的问题在别家有检索
// 工具的运行时里是**三次搜索调用**就定位到 `bilibili-API-collect` 仓库。
// 这就是 G1:工具箱里缺那把扳手,模型就绕行。
//
// 后端选择:Brave 的搜索结果页,**无需 API key**。本项目已有
// `github.com/imroc/req/v3` 的指纹仿真与 `golang.org/x/net/html`(都在依赖里)。
//
// 实测的诚实结论(2026-10-09,本机):解析没问题——真实页面上稳定拿到
// `SocialSisterYi/bilibili-API-collect` 作为第 1 条。但公开搜索页对程序化请求
// 会**间歇 429**:同一分钟内 curl 走 HTTP/2 稳定 200,而 Go 客户端在
// chrome / 无仿真 / firefox / safari 四种画像下时通时不通,与画像无关,是
// 按来源的累计限流。所以这个后端不能当"永远在"的依赖:限流时给出的建议是
// 换 browser 工具(真浏览器指纹)或 fetch 已知 URL,而不是让模型继续打。
// 若要稳定,正解是接一个有 key 的搜索 API(Brave Search API / Tavily / Exa)——
// 那属于配置项,不在本次范围。
const (
searchTimeout = 25 * time.Second
searchMaxResult = 8
searchSnippetBytes = 220 // 每条摘要的字节上限(按字符边界截)
// 后端很慷慨地回 429,所以:全局最小间隔 + 有限次退避重试。
searchMinInterval = 2 * time.Second
searchRetryCap = 30 * time.Second
searchAttempts = 3 // 首次 + 两次退避;再多就是替后端扛流量,且一次调用拖几十秒
)
// searchBackoff 是两次重试之间的等待(受 Retry-After 下限约束)。
var searchBackoff = []time.Duration{3 * time.Second, 8 * time.Second}
// searchEngines 是可试的后端;顺序即优先级。留成切片是为了 Brave 失效时
// 加第二个后端(各家的解析规则不同,加之前先实测确认能通)。
var searchEngines = []struct {
name string
build func(q string) string
lookup func(*html.Node) []searchResult
}{
{"brave", braveSearchURL, extractBrave},
}
// searchResult 是一条搜索结果。
type searchResult struct {
Title string
URL string
Snippet string
}
// WebSearch 返回 websearch 工具。与 fetch 同为网络类:审批档位一致,
// 一次批准后按工具名缓存(会话内不再打扰)。
func WebSearch() *Tool {
return &Tool{
Name: "websearch",
Description: "Search the web by keywords and get back titles + URLs + snippets — use it BEFORE guessing: " +
"library/API names, error messages, doc locations. Cheap (one HTTP round trip) and the usual way to " +
"find the authoritative repo/doc, which fetch/bash-curl cannot do (they need a URL you already know). " +
"Then read the promising link with the fetch tool.",
SideEffect: true,
Kind: KindNetwork,
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string",
"description": "search keywords; put the exact error string or API name in quotes"},
"count": map[string]any{"type": "integer",
"description": fmt.Sprintf("results to return, default %d", searchMaxResult)},
},
"required": []string{"query"},
},
Run: func(ctx context.Context, input string) (Result, error) {
m, err := arg(input)
if err != nil {
return Result{}, err
}
q := strings.TrimSpace(str(m, "query"))
if q == "" {
return Result{}, fmt.Errorf("query is empty")
}
limit := searchMaxResult
if v, ok := m["count"].(float64); ok && int(v) > 0 && int(v) <= searchMaxResult {
limit = int(v)
}
rctx, cancel := context.WithTimeout(ctx, searchTimeout)
defer cancel()
var tried []string
for _, eng := range searchEngines {
body, err := searchHTTPGet(rctx, eng.build(q))
if err != nil {
// 把后端真实状态带上:429/403(被限流)与"版面不认"是两种
// 完全不同的故障,混成一句会让模型做出错误的重试决定。
tried = append(tried, eng.name+": "+err.Error())
continue
}
root, perr := html.Parse(strings.NewReader(body))
if perr != nil {
tried = append(tried, eng.name+": 页面解析失败 "+perr.Error())
continue
}
if rs := dedupe(eng.lookup(root)); len(rs) > 0 {
return TextResult(renderResults(q, rs, limit)), nil
}
tried = append(tried, fmt.Sprintf("%s: 响应 %d 字节但没解析出结果(版面可能改了)",
eng.name, len(body)))
}
// 两种故障给两种建议:限流是"换条路,别再打",版面不认是"重试一次再换工具"。
joined := strings.Join(tried, "; ")
if strings.Contains(joined, "rate limited") {
return Result{}, fmt.Errorf("web search rate limited by the engine (%s) — retrying now makes it worse. "+
"Options: use the browser tool on https://search.brave.com/search?q=… (real browser fingerprint), "+
"fetch a URL you already know, or answer from what you have", joined)
}
return Result{}, fmt.Errorf("web search failed — %s; "+
"the engine markup may have changed: retry once with different keywords, then use the fetch tool", joined)
},
}
}
// ---- HTTP ----
// searchHTTPGet 复用 fetch 的客户端:同一套浏览器指纹仿真与代理配置,
// 不另开一份连接池。
//
// 两次实时试验之间就被 Brave 打过 429,所以这里必须有:
// - 全局最小间隔(并发批次里模型可能一口气发好几条搜索,自己把自己打爆);
// - 429/503 的有限退避重试(尊重 Retry-After)。
//
// 仍然失败时把真实状态交给上层——"限流"和"版面不认"要给出不同的建议。
func searchHTTPGet(ctx context.Context, u string) (string, error) {
waitSearchSlot(ctx)
var (
lastErr error
retryAfter time.Duration
)
for attempt := 0; attempt < searchAttempts; attempt++ {
if attempt > 0 {
d := searchBackoff[attempt-1]
if retryAfter > d {
d = retryAfter
}
select {
case <-time.After(d):
case <-ctx.Done():
return "", ctx.Err()
}
}
waitSearchSlot(ctx)
resp, err := fetchHTTPClient().R().SetContext(ctx).Get(u)
if err != nil {
lastErr = err
continue
}
switch {
case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable:
retryAfter = retryAfterDelay(resp.Header.Get("Retry-After"))
lastErr = fmt.Errorf("HTTP %d (rate limited)", resp.StatusCode)
continue
case resp.StatusCode >= 400:
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
return string(resp.Bytes()), nil
}
return "", lastErr
}
// retryAfterDelay 解析 Retry-After 的秒数形式;解析失败或非正值返回 0。
// 上限 searchRetryCap:头部写"一小时"这种,不该让工具挂着不走。
func retryAfterDelay(v string) time.Duration {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || n <= 0 {
return 0
}
if d := time.Duration(n) * time.Second; d > searchRetryCap {
return searchRetryCap
} else {
return d
}
}
// searchMinInterval 是全局限流闸:两次搜索请求之间至少隔这么多。
// 用 Mutex 跨睡眠持有——并发批次里的多条搜索自然被串行化。
var searchGate = struct {
sync.Mutex
last time.Time
}{}
func waitSearchSlot(ctx context.Context) {
searchGate.Lock()
defer searchGate.Unlock()
if d := time.Since(searchGate.last); d < searchMinInterval {
select {
case <-time.After(searchMinInterval - d):
case <-ctx.Done():
return
}
}
searchGate.last = time.Now()
}
// ---- Brave 版面解析 ----
func braveSearchURL(q string) string {
return "https://search.brave.com/search?q=" + url.QueryEscape(q) + "&source=web"
}
// braveSkipHosts 是导航/图片代理一类不该出现在结果里的域名。
var braveSkipHosts = []string{"search.brave.com", "imgs.search.brave.com", "brave.com"}
// extractBrave 取所有 class 含 "snippet" 的结果块。
//
// Brave 的 class 带 svelte 哈希(`snippet svelte-jmfu5f`),所以按**词**匹配而不是
// 全字符串比较。找不到时返回空,由调用方决定怎么交代。
func extractBrave(root *html.Node) []searchResult {
var out []searchResult
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && hasClass(n, "snippet") {
// 只认真正的结果块;视频/图片等其它 data-type 不是我们要的
if dt := attr(n, "data-type"); dt != "" && dt != "web" {
return
}
if r, ok := parseBraveSnippet(n); ok {
out = append(out, r)
return // 结果块内部不再递归(避免嵌套块重复计数)
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(root)
return out
}
// parseBraveSnippet 从一个结果块里抽 URL / 标题 / 摘要。
func parseBraveSnippet(n *html.Node) (searchResult, bool) {
var r searchResult
a := firstAnchor(n)
if a != nil {
r.URL = absURL(attr(a, "href"))
// 锚点整棵子树还包含站点名与面包屑("GitHub github.com › x › y"),
// 直接 textOf(a) 会把它们混进标题;优先取 .title。
if t := firstByClass(a, "title"); t != nil {
r.Title = textOf(t)
} else {
r.Title = textOf(a)
}
}
if r.URL == "" || !httpURL(r.URL) || hostIn(r.URL, braveSkipHosts) {
return r, false
}
if r.Title == "" {
r.Title = r.URL
}
// 摘要:Brave 两种版面都见过(generic-snippet 与 snippet-description),都认。
// 认不出就留空——**不要**退回整块文本,那会把站点名、面包屑、URL 一起吐给模型。
for _, class := range []string{"generic-snippet", "snippet-description"} {
if d := firstByClass(n, class); d != nil {
r.Snippet = textOf(d)
break
}
}
return r, true
}
// firstAnchor 返回块里第一个 http(s) 链接(Brave 把图标包在 <a> 里,需要跳过空 href)。
func firstAnchor(n *html.Node) *html.Node {
var found *html.Node
var walk func(*html.Node)
walk = func(x *html.Node) {
if found != nil {
return
}
if x.Type == html.ElementNode && x.Data == "a" {
if h := absURL(attr(x, "href")); httpURL(h) {
found = x
return
}
}
for c := x.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(n)
return found
}
// firstByClass 按 class 词查找第一个元素(含自身)。
func firstByClass(n *html.Node, class string) *html.Node {
if n.Type == html.ElementNode {
if hasClass(n, class) {
return n
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if got := firstByClass(c, class); got != nil {
return got
}
}
return nil
}
// ---- DOM 小工具 ----
func attr(n *html.Node, key string) string {
for _, a := range n.Attr {
if a.Key == key {
return a.Val
}
}
return ""
}
// hasClass 判断 class 属性里是否有某个**完整词**(svelte 哈希不会误命中)。
func hasClass(n *html.Node, class string) bool {
for _, w := range strings.Fields(attr(n, "class")) {
if w == class {
return true
}
}
return false
}
// textOf 收集子树可见文本(跳过 script/style,空白压缩)。
func textOf(n *html.Node) string {
var b strings.Builder
var walk func(*html.Node)
walk = func(x *html.Node) {
if x.Type == html.ElementNode && fetchSkipElems[strings.ToLower(x.Data)] {
return
}
if x.Type == html.TextNode {
b.WriteString(x.Data)
b.WriteString(" ")
}
for c := x.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(n)
return strings.TrimSpace(fetchSpaceRe.ReplaceAllString(b.String(), " "))
}
// ---- 输出 ----
// absURL 把结果里可能出现的相对链接补成绝对链接。
func absURL(h string) string {
h = strings.TrimSpace(h)
if h == "" || strings.HasPrefix(h, "//") {
if h != "" {
return "https:" + h
}
return h
}
// 带跟踪跳转的 brave 链接(/search?...)不是我们要的结果
if strings.HasPrefix(h, "/") {
return "https://search.brave.com" + h
}
return h
}
func httpURL(u string) bool {
return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://")
}
func hostIn(u string, hosts []string) bool {
pu, err := url.Parse(u)
if err != nil {
return false
}
for _, h := range hosts {
if pu.Host == h || strings.HasSuffix(pu.Host, "."+h) {
return true
}
}
return false
}
// dedupe 按 URL 去重(Brave 同一结果常出现两次:图标 + 标题)。
func dedupe(rs []searchResult) []searchResult {
seen := map[string]bool{}
out := make([]searchResult, 0, len(rs))
for _, r := range rs {
key := strings.TrimSuffix(strings.TrimPrefix(r.URL, "https://"), "/")
if key == "" || seen[key] {
continue
}
seen[key] = true
out = append(out, r)
}
return out
}
// renderResults 排版给模型看:编号 + 标题 + URL + 摘要。
func renderResults(q string, rs []searchResult, limit int) string {
var b strings.Builder
fmt.Fprintf(&b, "web search: %q (%d results)\n", q, min(limit, len(rs)))
for i, r := range rs {
if i >= limit {
break
}
snip, cut := execx.Truncate([]byte(r.Snippet), searchSnippetBytes)
if cut {
snip = append(snip, "…"...)
}
fmt.Fprintf(&b, "\n%d. %s\n %s\n %s\n", i+1, r.Title, r.URL, snip)
}
b.WriteString("\nnext: pick the most authoritative link and read it with fetch")
return strings.TrimRight(b.String(), "\n")
}
+170
View File
@@ -0,0 +1,170 @@
package tools
import (
"context"
"os"
"strings"
"testing"
"unicode/utf8"
"golang.org/x/net/html"
)
// braveFixture 按 Brave 真实版面精简:class 带 svelte 哈希、图标 <a> 无文本、
// 广告块 data-type 非 web、站内链接要过滤、同一结果重复出现。
var braveFixture = `<html><body><main class="main-column">
<div class="snippet svelte-jmfu5f" data-pos="0" data-type="web" data-keynav="true">
<div class="result-body"><div class="result-content">
<a href="https://github.com/SocialSisterYi/bilibili-API-collect" target="_self" class="svelte-14r20fy">
<div class="site-name-wrapper"><img class="favicon" src="https://imgs.search.brave.com/xx"></div>
<div class="title">SocialSisterYi/bilibili-API-collect · GitHub</div>
</a>
<div class="snippet-description">bilibili-API-collect 收集了各种 bilibili 的 API 与用法。</div>
</div></div>
</div>
<div class="snippet svelte-jmfu5f" data-pos="1" data-type="web">
<div class="result-content">
<a href="https://github.com/SocialSisterYi/bilibili-API-collect" class="svelte-14r20fy">
<div class="title">重复的同一条结果</div>
</a>
<div class="snippet-description">应当被去重掉。</div>
</div>
</div>
<div class="snippet svelte-jmfu5f" data-pos="2" data-type="ad">
<div class="result-content">
<a href="https://ads.example.com/bili"><div class="title">广告:别选这条</div></a>
<div class="snippet-description">广告摘要</div>
</div>
</div>
<div class="snippet svelte-jmfu5f" data-pos="3" data-type="web">
<div class="result-content">
<a href="/internal/only"><div class="title">站内链接</div></a>
<div class="snippet-description">不该出现在结果里</div>
</div>
</div>
<div class="snippet svelte-jmfu5f" data-pos="4" data-type="web">
<div class="result-content">
<a href="https://example.com/docs"><div class="title search-snippet-title line-clamp-1">中文标题很长</div></a>
<div class="generic-snippet svelte-1cwdgg3"><div class="content line-clamp-dynamic">` + strings.Repeat("很长的中文摘要内容", 40) + `</div></div>
</div>
</div>
</main></body></html>`
func parseFixture(t *testing.T) []searchResult {
t.Helper()
root, err := html.Parse(strings.NewReader(braveFixture))
if err != nil {
t.Fatal(err)
}
return dedupe(extractBrave(root))
}
func TestExtractBraveResults(t *testing.T) {
got := parseFixture(t)
if len(got) != 2 {
t.Fatalf("结果数 = %d, want 2(广告与站内链接必须被滤掉,重复要合并):\n%s", len(got), dumpResults(got))
}
if got[0].URL != "https://github.com/SocialSisterYi/bilibili-API-collect" {
t.Errorf("首条 URL = %q", got[0].URL)
}
if !strings.Contains(got[0].Title, "bilibili-API-collect") {
t.Errorf("标题没取到:%q", got[0].Title)
}
if !strings.Contains(got[0].Snippet, "收集了各种 bilibili") {
t.Errorf("摘要没取到:%q", got[0].Snippet)
}
if got[1].URL != "https://example.com/docs" {
t.Errorf("第二条 URL = %q", got[1].URL)
}
}
// TestRenderResultsCutsOnRuneBoundary:中文摘要截断不许切半个字。
func TestRenderResultsCutsOnRuneBoundary(t *testing.T) {
got := parseFixture(t)
out := renderResults("bilibili 投币 API", got, 8)
if !strings.Contains(out, "1. ") || !strings.Contains(out, "web search:") {
t.Errorf("排版缺编号或回显查询:\n%s", out)
}
// 每一行都必须合法 UTF-8(截断标记除外)。
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSuffix(line, "…")
if !isUTF8Valid(line) {
t.Errorf("切坏了 UTF-8:%q", line)
}
}
// 只渲染前 limit 条。
if n := strings.Count(renderResults("q", got, 1), "\n "); n > 3 {
t.Errorf("limit=1 却渲染了多条:%d", n)
}
}
// TestExtractBraveEmptyOnUnknownMarkup:版面不认识时必须返回空(而不是崩或返回垃圾),
// 由工具层给出"换 fetch"的可操作错误。
func TestExtractBraveEmptyOnUnknownMarkup(t *testing.T) {
root, _ := html.Parse(strings.NewReader(`<html><body><div class="whatever"><a href="https://x.com">hi</a></div></body></html>`))
if rs := extractBrave(root); len(rs) != 0 {
t.Errorf("无关版面应解析出 0 条,got %d", len(rs))
}
}
// TestWebSearchToolShape:标注必须是网络类副作用工具——与 fetch 同档,
// 一次批准按工具名缓存,不会每次轮询都打扰。
func TestWebSearchToolShape(t *testing.T) {
tl := WebSearch()
if tl.Name != "websearch" || !tl.SideEffect || tl.Kind != KindNetwork {
t.Errorf("工具标注不对:%+v", tl)
}
if tl.ReadOnly || tl.ConcurrencySafe {
t.Error("websearch 访问外部网络,不该标只读/可并发")
}
if _, err := tl.Run(context.Background(), `{"query":""}`); err == nil {
t.Error("空 query 必须报错")
}
}
// TestBraveSnippetNotFallbackToBlockText:认不出摘要元素时留空。
// 退回整块文本会把站点名/面包屑/URL 一起吐给模型(实时页面上真出现过)。
func TestBraveSnippetNotFallbackToBlockText(t *testing.T) {
root, _ := html.Parse(strings.NewReader(`<html><body>
<div class="snippet" data-type="web"><a href="https://a.example/x"><div class="title">标题</div></a>
<div class="site-name-wrapper">Example a.example</div></div></body></html>`))
rs := extractBrave(root)
if len(rs) != 1 {
t.Fatalf("应解析出 1 条,got %d", len(rs))
}
if rs[0].Snippet != "" {
t.Errorf("摘要应为空而不是整块噪声:%q", rs[0].Snippet)
}
if rs[0].Title != "标题" {
t.Errorf("标题 = %q", rs[0].Title)
}
}
// TestWebSearchLive 是真实网络冒烟:默认跳过,AUTOGO_LIVE_NET=1 时才跑。
// 单元夹具证明不了"后端今天还认我们"这件事,而这条工具整条链路就悬在这上面。
func TestWebSearchLive(t *testing.T) {
if os.Getenv("AUTOGO_LIVE_NET") == "" {
t.Skip("需要联网:AUTOGO_LIVE_NET=1")
}
res, err := WebSearch().Run(context.Background(),
`{"query":"bilibili-API-collect 投币记录 API","count":5}`)
if err != nil {
t.Fatalf("实时搜索失败:%v", err)
}
t.Logf("结果:\n%s", res.Text)
if !strings.Contains(res.Text, "bilibili-API-collect") {
t.Errorf("这条查询应当命中权威文档仓库:\n%s", res.Text)
}
}
func dumpResults(rs []searchResult) string {
var b strings.Builder
for _, r := range rs {
b.WriteString(" " + r.URL + " | " + r.Title + " | " + r.Snippet + "\n")
}
return b.String()
}
func isUTF8Valid(s string) bool {
return utf8.ValidString(s)
}