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

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

401 lines
13 KiB
Go
Raw Permalink Blame History

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