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
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user