feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
This commit is contained in:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
+400
@@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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} 覆盖 等解码出的 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
|
||||
}
|
||||
@@ -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 & b<br>c</p><div>tail 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)
|
||||
}
|
||||
// 由解析器解码为普通空格,行内文本不粘连;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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user