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。
358 lines
14 KiB
Go
358 lines
14 KiB
Go
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)
|
||
}
|