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。
401 lines
13 KiB
Go
401 lines
13 KiB
Go
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
|
||
},
|
||
},
|
||
}
|
||
}
|