feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)

llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、
builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、
编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。

产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt)
留在 AutoGo,宿主接入方式见 MIGRATION.md。
This commit is contained in:
Sakurasan committed 2026-10-26 00:54:46 +08:00
commit d5dac5a6fc
140 files changed
+26497

No files matched your search

+357
View File
@@ -0,0 +1,357 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"sync"
"sync/atomic"
"git.gopher.ink/mirrors2/AutoZero/llm"
"git.gopher.ink/mirrors2/AutoZero/session"
"git.gopher.ink/mirrors2/AutoZero/tools"
)
// maxSubAgentTasks 限制单次 agent 调用的并行子任务数,防止一次 fan-out 打爆。
const maxSubAgentTasks = 16
// subAgentSeq 给每个子 agent 一个唯一序号,用作落盘文件名的命名空间前缀
// (并行子 agent 的 tool_call ID 会重复)。
var subAgentSeq atomic.Int64
// SubAgentTool 构造 "agent" 工具:把自包含子任务委派给全新的嵌套 agent
// (借鉴 ccb AgentTool 的"递归复用主循环"与 picoclaw SubTurn 的会话隔离/深度/并发上限)。
// 嵌套 agent 使用独立 ephemeral 会话(不落盘、不污染主会话上下文),
// 信任授权从父会话继承。
func (a *Agent) SubAgentTool() *tools.Tool {
return newSubAgentTool(a)
}
type subAgentInput struct {
Prompt string `json:"prompt"` // 单任务(与历史行为逐字节一致)
Tasks []string `json:"tasks"` // 多任务:并行 fan-out
// SessionID 继续一个已有的子对话:上下文还在(同一进程内),不必重发前情。
SessionID string `json:"session_id"`
}
// subAgentOutcome 是单个子任务的执行结果。
type subAgentOutcome struct {
text string
sessionID string
err error
}
// 子会话表是进程级的:一次 agent 调用产生的内存会话留在这里,后续带
// session_id 的调用可以接着聊。与 bash_bg 同一已知边界——进程重启即失。
const subAgentSessionKeep = 32
var (
subAgentSessMu sync.Mutex
subAgentSessions = map[string]*session.Session{}
subAgentSessOrder []string
)
func registerSubAgentSession(sess *session.Session) string {
id := fmt.Sprintf("sa-%d", subAgentSeq.Add(1))
subAgentSessMu.Lock()
subAgentSessions[id] = sess
subAgentSessOrder = append(subAgentSessOrder, id)
for len(subAgentSessOrder) > subAgentSessionKeep {
delete(subAgentSessions, subAgentSessOrder[0])
subAgentSessOrder = subAgentSessOrder[1:]
}
subAgentSessMu.Unlock()
return id
}
func lookupSubAgentSession(id string) *session.Session {
subAgentSessMu.Lock()
defer subAgentSessMu.Unlock()
return subAgentSessions[id]
}
func newSubAgentTool(parent *Agent) *tools.Tool {
desc := "Delegate a self-contained subtask (research, exploration, bulk refactoring) " +
"to a fresh sub-agent with the same tools. The sub-agent runs in an isolated context; " +
"only its final answer comes back. Give it a complete, self-sufficient prompt. " +
"The result carries a sessionID; pass it back as session_id to continue that exact " +
"sub-agent conversation without re-sending the earlier context (same process only). " +
"Use `tasks` (array) to delegate several independent subtasks at once — they run in " +
"parallel, share the same workspace, and must not touch the same files."
if parent.CanSpawnSubAgent() {
desc += " Nested sub-agents may delegate further."
} else {
desc += " Sub-agents spawned here cannot delegate further."
}
return &tools.Tool{
Name: "agent",
Description: desc,
SideEffect: true, // 子 agent 可能执行副作用工具:整体过一次信任门控
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"prompt": map[string]any{
"type": "string",
"description": "Complete, self-sufficient task description with all needed context",
},
"tasks": map[string]any{
"type": "array",
"description": "Several independent self-contained subtasks to run in parallel",
"items": map[string]any{"type": "string"},
},
"session_id": map[string]any{
"type": "string",
"description": "Continue a specific previous sub-agent conversation (the sessionID from its result). Omit to start a new one.",
},
},
},
Run: func(ctx context.Context, input string) (tools.Result, error) {
if !parent.CanSpawnSubAgent() {
return tools.Result{}, fmt.Errorf("max sub-agent depth (%d) reached", parent.MaxDepth)
}
// 在 fan-out 之前把 stderr 包成并发安全的 writer——必须在并发之前做,
// 因为它会改写 parent.Opt.Stderr(放在 goroutine 里就是 data race)。
parent.ensureLockedStderr()
var p subAgentInput
if err := json.Unmarshal([]byte(input), &p); err != nil {
return tools.Result{}, fmt.Errorf("invalid agent tool input: %w", err)
}
if strings.TrimSpace(p.SessionID) != "" && len(p.Tasks) > 0 {
return tools.Result{}, fmt.Errorf("session_id continues one conversation; it cannot be combined with tasks")
}
tasks, err := normaliseSubAgentTasks(p)
if err != nil {
return tools.Result{}, err
}
if len(tasks) == 1 {
// 单任务:返回子 agent 最终回复与可续的 sessionID。
out := parent.runSubAgent(ctx, tasks[0], strings.TrimSpace(p.SessionID))
if out.err != nil {
if out.sessionID != "" {
return tools.Result{}, fmt.Errorf("%w (sessionID: %s)", out.err, out.sessionID)
}
return tools.Result{}, out.err
}
return tools.TextResult(fmt.Sprintf(
"<subagent sessionID=%q state=\"completed\">\n%s\n</subagent>",
out.sessionID, out.text)), nil
}
return parent.runSubAgentBatch(ctx, tasks), nil
},
}
}
// normaliseSubAgentTasks 归一化 prompt / tasks 两种入参,返回非空任务列表。
func normaliseSubAgentTasks(p subAgentInput) ([]string, error) {
raw := p.Tasks
if strings.TrimSpace(p.Prompt) != "" {
if len(raw) > 0 {
return nil, fmt.Errorf("agent tool takes either prompt or tasks, not both")
}
raw = []string{p.Prompt}
}
if len(raw) == 0 {
return nil, fmt.Errorf("agent tool requires a non-empty prompt or tasks")
}
if len(raw) > maxSubAgentTasks {
return nil, fmt.Errorf("too many tasks (%d > %d)", len(raw), maxSubAgentTasks)
}
out := make([]string, 0, len(raw))
for i, t := range raw {
t = strings.TrimSpace(t)
if t == "" {
return nil, fmt.Errorf("task %d is empty", i+1)
}
out = append(out, t)
}
return out, nil
}
// runSubAgentBatch 执行多个子任务。只有**问不到人**时才并行——判据是"有没有审批
// 通道"(回调或 stdin),不是 ApproveAll:危险门不看 ApproveAll(见 execOne),
// 而宿主端同一时刻只有一个待裁决槽位(WebUI 的 run.pending 会被后一条直接盖掉),
// 于是「完全访问」档的 WebUI 回合既是 ApproveAll 又有回调,并行 fan-out 会让前一条
// 询问挂到超时。真无人值守(-p / cron / 渠道)两个通道都没有,才谈得上并行。
func (a *Agent) runSubAgentBatch(ctx context.Context, tasks []string) tools.Result {
conc := 1
if a.Opt.Approval == nil && a.Opt.Stdin == nil && a.SubAgentConcurrency > 1 {
conc = a.SubAgentConcurrency
} else if a.SubAgentConcurrency > 1 {
fmt.Fprintln(a.Opt.Stderr, "[agent] running sub-tasks serially (an approver is reachable; parallel would contend for the single approval slot)")
}
fmt.Fprintf(a.Opt.Stderr, "[agent] delegating %d sub-tasks (concurrency %d)\n", len(tasks), conc)
outs := make([]subAgentOutcome, len(tasks))
if conc == 1 {
for i := range tasks {
outs[i] = a.runSubAgent(ctx, tasks[i], "")
}
} else {
sem := make(chan struct{}, conc)
var wg sync.WaitGroup
for i := range tasks {
wg.Add(1)
go func(i int) {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
outs[i] = subAgentOutcome{err: ctx.Err()}
return
}
defer func() { <-sem }()
outs[i] = a.runSubAgent(ctx, tasks[i], "")
}(i)
}
wg.Wait()
}
var b strings.Builder
okCount := 0
for _, o := range outs {
if o.err == nil {
okCount++
}
}
fmt.Fprintf(&b, "Delegated %d sub-tasks (concurrency %d; %d ok, %d failed):\n",
len(tasks), conc, okCount, len(tasks)-okCount)
for i, o := range outs {
if o.err != nil {
fmt.Fprintf(&b, "\n===== [%d/%d] failed =====\n%v\n", i+1, len(tasks), o.err)
continue
}
fmt.Fprintf(&b, "\n===== [%d/%d] ok =====\n%s\n", i+1, len(tasks), o.text)
}
text := strings.TrimRight(b.String(), "\n")
if okCount == 0 {
// 全军覆没:以失败返回,让父级的失败熔断计数生效。
return tools.Result{Text: text, Err: fmt.Errorf("all %d sub-tasks failed", len(tasks))}
}
return tools.TextResult(text)
}
// runSubAgent 跑一个子任务:新建嵌套 agent(独立会话、深度+1),返回其最终回复。
// sessionID 非空时复用进程内的子会话,让模型能接着上一轮追问,而不必重发前情。
func (a *Agent) runSubAgent(ctx context.Context, prompt, sessionID string) subAgentOutcome {
trusted := make(map[string]bool, len(a.trusted))
for k, v := range a.trusted {
trusted[k] = v
}
var sess *session.Session
if sessionID != "" {
sess = lookupSubAgentSession(sessionID)
if sess == nil {
return subAgentOutcome{
sessionID: sessionID,
err: fmt.Errorf("no such subagent session %q (sessions live in this a2go process)", sessionID),
}
}
} else {
sess = &session.Session{} // 内存会话:不落盘,但留在子会话表里供后续续聊
sessionID = registerSubAgentSession(sess)
}
fmt.Fprintln(a.Opt.Stderr, "[agent] sub-agent started")
// 每层重建工具表:必须去掉父级的 agent 闭包(它绑的是父级,会让深度失效),
// 再按本层深度重新注册指向自己的 agent 工具。plan/memory 一律不继承——它们由
// 拥有者维护;子 agent 经 System(sysNow 的冻结快照)**只读**看到父级记忆与计划。
//
// ask_user 也必须去掉:那张表里的工具闭包绑的是**父 agent**,子 agent 调用它
// 等于经父级的通道去问人、并把父级的回合收尾标志设上。ZCode 能给子 agent 用
// 是因为它专门做了"路由回父会话",那是另一期工程;在这里挂住等一个没人能答的
// 问题才是真的糟。
reg := a.Tools.CloneWithout("agent", "plan", "memory", "ask_user")
nested := &Agent{
Provider: a.Provider,
Tools: reg,
Session: sess, // 内存会话:不落盘;续聊靠进程内的子会话表
// 父计划的只读快照:子 agent 能看到当前进展,但不能修改(无 plan 工具)。
System: a.sysNow(),
Thinking: a.Thinking, // 继承父级思考深度
ResultDir: a.ResultDir, // 子 agent 的超长结果同样落盘可回捞
// 深度与并发:子 agent 继承父级上限,深度 +1。
Depth: a.Depth + 1,
MaxDepth: a.MaxDepth,
SubAgentConcurrency: a.SubAgentConcurrency,
spillNS: fmt.Sprintf("sa%d-", subAgentSeq.Add(1)),
Opt: Options{
ApproveAll: a.Opt.ApproveAll,
Stdin: a.Opt.Stdin,
Stdout: io.Discard, // 子过程输出不与主会话混流
// 继承同一个(已加锁的)writer 实例:并行子 agent 共享父级 stderr。
// 保证由本 agent 自己的 Run 提前完成(见工具 Run 开头)。
Stderr: a.Opt.Stderr,
// 审批通道必须继承:它是**宿主**的回调(TUI 面板 / WebUI 弹框),不是绑在
// 某个 agent 上的闭包。此前只带了 Stdin,于是 WebUI 里子 agent 撞上危险门
// 时既没有回调也没有 stdin(那边 stdin 传的就是 nil),询问直接落到
// "无人可问→拒绝",弹框根本不会出现,模型只收到一句"没有审批人"。
Approval: a.Opt.Approval,
},
trusted: trusted,
// 继承父级已解析的预算与策略:否则用户改过的上下文预算/审批策略
// 在子 agent 里静默失效(applyDefaults 会覆盖成默认值)。
ContextWindow: a.ContextWindow,
CompactAt: a.CompactAt,
MinCompactTokens: a.MinCompactTokens,
MaxToolResultChars: a.MaxToolResultChars,
MaxImages: a.MaxImages,
ImageBudgetBytes: a.ImageBudgetBytes,
StreamRetryBudget: a.StreamRetryBudget,
ApprovalPolicy: a.ApprovalPolicy,
// 审批档位继承:「完全访问」是人对本回合说的"全部自动放行",委派给下级
// 不该悄悄收回这个授权(否则模型把 push 交给子 agent 又会撞上危险门)。
// DangerousPolicy 刻意**不**继承:子 agent 一律留在 fail-closed(ask),
// 父级设了 dangerous=allow 也不该把下级的拦截一起关掉。
Mode: a.Mode,
// 密钥脱敏必须继承:否则子 agent 读到的一份密钥会原样写进它的结果,
// 再作为文本回到父级 transcript 与外发通道。
Secrets: a.Secrets,
Skills: a.Skills,
}
nested.applyDefaults()
if nested.CanSpawnSubAgent() {
_ = reg.Register(nested.SubAgentTool()) // 闭包绑 nested:深度才能正确递增
}
if err := nested.Run(ctx, prompt); err != nil {
return subAgentOutcome{sessionID: sessionID, err: fmt.Errorf("sub-agent: %w", err)}
}
fmt.Fprintln(a.Opt.Stderr, "[agent] sub-agent finished")
for i := len(nested.Session.Messages) - 1; i >= 0; i-- {
if nested.Session.Messages[i].Role == llm.RoleAssistant {
final := strings.TrimSpace(nested.Session.Messages[i].Content)
if final == "" {
break
}
return subAgentOutcome{text: final, sessionID: sessionID}
}
}
return subAgentOutcome{sessionID: sessionID, err: fmt.Errorf("sub-agent produced no final reply")}
}
// ensureLockedStderr 保证 stderr 是并发安全的:首次调用时包一层 lockedWriter 并
// 缓存,子孙 agent 直接**继承同一个实例**(不再重复包装——那样会各自持锁而失去
// 串行化,甚至同锁重入自锁)。
func (a *Agent) ensureLockedStderr() io.Writer {
if lw, ok := a.Opt.Stderr.(*lockedWriter); ok {
return lw
}
lw := &lockedWriter{w: a.Opt.Stderr}
a.Opt.Stderr = lw
return lw
}
// lockedWriter 串行化对底层 writer 的写入:并行子 agent 共享父级 stderr。
type lockedWriter struct {
mu sync.Mutex
w io.Writer
}
func (l *lockedWriter) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.w.Write(p)
}