Files
AutoZero/agent/agent.go
T
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

1605 lines
68 KiB
Go
Raw 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 agent 实现 Agent 循环(蓝本:pigo runtime/loop.go + ccb query.ts):
// Run 是薄驱动(轮次上限保护),每轮 step() 返回显式转移原因(借鉴 ccb
// query/transitions.ts 的 Terminal/Continue 枚举),终止路径可枚举、可测试。
package agent
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.gopher.ink/mirrors2/AutoZero/execx"
"git.gopher.ink/mirrors2/AutoZero/imagecache"
"git.gopher.ink/mirrors2/AutoZero/llm"
"git.gopher.ink/mirrors2/AutoZero/session"
"git.gopher.ink/mirrors2/AutoZero/skills"
"git.gopher.ink/mirrors2/AutoZero/textx"
"git.gopher.ink/mirrors2/AutoZero/tools"
)
// DefaultMaxTurns 是单条消息内模型往返次数的默认上限,与 config 的钳制上限同值。
// 到顶**不是报错**:agent 会强制收尾并交代"还剩什么没做"(见 wrapUpNote),所以这个数
// 字只管"跑偏的模型最多能白转多久",不会把干到一半的任务打断。
//
// 原来按「一次多文件改造」的规模定的是 120,但真实取证里一个任务单回合就能走到
// 101 步(会话 20261012-120622),再保守下去就是人被"到顶收尾"卡住。仍要留上限:
// 无人值守入口(cron / -p / 渠道)没人能按停止。可用 config 的 max_turns 调小。
const DefaultMaxTurns = 1024
// 一次模型调用的重试预算,按**时间**算而不是按次数算:坏流量是成簇来的,一簇几十秒到
// 几分钟(20261012 那份会话 13 个失败回合里 8 个落在 22:00–01:00;10-15 夜里 23:55 与
// 00:04 两条连续失败是同一簇),而“2 次、间隔 400ms”根本熬不过去,症状就是干到一半
// 停下来等人打「继续」。上限仍然要有:网关真死了,无限重试就是无限烧钱。
//
// 成 var 而不是 const:用例要把预算压到毫秒级,否则一条“始终失败”的用例白等 10 分钟。
var (
streamRetryFirst = time.Second
streamRetryCeil = 20 * time.Second
streamRetryBudget = 10 * time.Minute
// streamRetrySwitchAfter 是同一个模型连试几次还没答上就换备选模型。取 3:一簇
// 抖动通常头几次就过去;连到第 3 次说明更像"这个模型/这条上游在抽风",换人比
// 继续耗着值钱(未配置 fallback_model 时这条整个跳过)。
streamRetrySwitchAfter = 3
)
// sleepCtx 睡到点;期间 ctx 被取消就立刻返回 false——叫停之后不该再等完这一段。
func sleepCtx(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
// ToolEvent 描述一次工具调用的生命周期,供上层观察者(如 WebUI)实时展示。
// 执行前回调一次 start(Done=false,Result 为空),执行后回调一次完成事件
// (Done=true,Result 为结果预览)。ID 用于把两次回调配对。
type ToolEvent struct {
ID string // tool_call id
Name string
Input string // 原始 JSON 参数字符串
Result string // 完成时的结果预览(start 时为空)
Done bool
// SideEffect 表示这个工具会改变现实世界(write/edit/bash/agent…)。
// 前端据此在回合失败时禁用"自动整轮重试"——副作用已经发生,自动重跑
// 可能把它做第二遍。
SideEffect bool
// 本地 trace:start 帧带 StartedAt,done 帧带 DurationMs(毫秒),
// 便于前端在消息落盘前就显示耗时。
StartedAt int64 // unix ms
DurationMs int64
// Images 是工具结果里图片的「可服务文件名」(已写入图片缓存),
// 供前端在工具行内联渲染(如截图);无图片则为空。
Images []string
}
// SteerMessage 是一条"回合中插话":用户在当前回合还没结束时补充的输入。
// Images 是图片缓存里的文件名(不是像素);与普通用户消息一样随轮次落盘。
type SteerMessage struct {
Text string
Images []string
}
// ApprovalVerdict 是交互审批的裁决。
type ApprovalVerdict int
const (
ApprovalDeny ApprovalVerdict = iota // 拒绝本次调用
ApprovalOnce // 仅本次放行
ApprovalAlways // 放行并记住该工具(会话内信任)
)
// 审批档位(Agent.Mode)。零值 "" 等同 ModeAsk(= 历史行为)。
const (
ModeAsk = "ask" // 变更前确认:副作用工具都要问
ModeAcceptEdits = "accept-edits" // 自动编辑:改文件自动放行,执行类仍要问
ModeBypass = "bypass" // 完全访问:全部放行
)
// 危险动作策略(Agent.DangerousPolicy)。零值 "" 等同 DangerousAsk(fail-closed)。
const (
DangerousAsk = "ask" // 危险动作强制确认(无交互通道时拒绝)
DangerousAllow = "allow" // 关闭危险拦截,恢复全放行(供自动化)
)
// Options 控制 Agent 行为。
type Options struct {
ApproveAll bool // 跳过副作用工具确认(--yes / 无人值守入口)
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
OnTool func(ToolEvent) // 可空;非空时每次工具执行前后各回调一次
OnReasoning func(string) // 可空;非空时把模型的推理文本增量回传(展示用)
// OnAbandon 可空;一次模型尝试失败、马上要重跑同一步时回调一次(带原因)。
// 那半截字已经通过 Stdout/OnReasoning 流给界面了,重跑会把同一段话再流一遍——
// 宿主据此把屏幕上那段作废。会话里不受影响:落盘的是 provider 返回值。
// 在 agent 工作 goroutine 上调用,实现方必须非阻塞。
OnAbandon func(reason string, dropBytes int)
// OnCompact 可空;自动压缩完成后回调一次结果(UI 借此把压缩落成
// 用户可见的一等事件)。在 agent 工作 goroutine 上调用,实现方必须
// 非阻塞(TUI 投递进带缓冲的 channel)。
OnCompact func(CompactionResult)
// OnNotice 可空;一句"不是模型说的、也不是工具说的"的系统说明要落进界面时回调
// (自动压缩、checkpoint 留档……)。failed 让界面把它渲染成警示而不是普通灰字。
// 与 OnCompact 同一条约束:在 agent 工作 goroutine 上调用,实现方必须非阻塞。
OnNotice func(text string, failed bool)
// OnTurnEnd 可空;Run 的唯一出口 finishTurn 落完账之后回调一次,带本回合的
// 结构化收尾报告(Err 是本轮返回值,nil = 正常收尾)。工作区级的收尾(进展
// 沉淀、复盘、checkpoint 留档)挂这一处才能四端全覆盖——分散到各入口必然漏
// (复盘钩子当初就是这么挪的)。同步调用,实现方自己保证会结束;
// 子 agent(Depth>0)不回调。
OnTurnEnd func(TurnReport)
// Steer 可空;每个 step 边界(工具结果已回填、下一次模型调用之前)以及
// "模型本来要收尾但又有新输入"时调用,返回本轮要插入的用户消息(按顺序)。
// 宿主用它实现回合中插话:消息作为普通 user 消息落盘,模型下一步就能看到。
// 必须非阻塞;返回空表示没有插话。
Steer func() []SteerMessage
// OnSteer 可空;一条插话真正写进会话后回调一次(UI 据此把气泡放到正确位置)。
OnSteer func(SteerMessage)
// Occupy 可空;Run 在碰任何东西(连补齐悬挂 tool_use 都不做)之前调用一次,
// 返回 error 就代表这一轮不该开始——错误原样交给宿主。只有返回 nil 才会配对
// 调用 Release。子 agent(Depth>0)不回调:占用窗口是父级拿的,子级在同一
// 个目录里干活,自己撞自己。
Occupy func() error
// Release 可空;本次 Run 彻底收尾后调用一次(在 OnTurnEnd 之后——checkpoint
// 的 git 操作也在占用窗口内)。用 defer,中断与报错都还得回来。
Release func()
// Approval 可空的交互审批回调:返回裁决替代 stdin 询问。阻塞期间
// 由入口自行决定如何呈现(TUI 弹确认行);nil 时走 stdin [y/N]。
//
// 注意它**没有 ctx、也没有超时**:不返回就会一直阻塞那条工具 goroutine。
// 所以无人值守的调用方必须自己保证不会挂住(例如 WebUI 在没人订阅时直接
// 返回拒绝,TUI 有 10 分钟兜底)。
Approval func(ApprovalRequest) ApprovalVerdict
// Ask 可空的提问回调(ask_user 工具到人这一端的通道)。与 Approval 同样的
// 阻塞契约,同样的**没有 ctx、没有超时**——接入方必须自己保证会结束。
//
// nil = 这个入口没有到人的交互通道:ask_user 按 Agent.AskDegradation 退化
// (默认把问题作为本轮结尾发出去,headless/cron 则自动采纳推荐项)。
// 所以渠道与定时任务**不必**注入任何东西就已经"能问且不挂死"。
Ask func(AskRequest) AskAnswer
}
// ApprovalRequest 是一次审批询问。
type ApprovalRequest struct {
Tool string
Preview string
// Danger 表示这次询问来自**不可逆动作门**(DangerousPolicy=ask 拦下的
// rm -rf / git push / dd 之类),而不是常规的副作用工具确认。
//
// 调用方据此区别对待:无人值守场景下普通工具可以放行,危险动作必须拒绝
// ——两者走的是同一个回调,没有这个标识就分不出来,只能要么全放行(危险)
// 要么全拒绝(后台任务什么也做不了)。
Danger bool
}
// Agent 把 Provider、工具表和会话组装成可运行的循环。
type Agent struct {
Provider llm.Provider
Tools *tools.Registry
Session *session.Session
System string
Opt Options
trusted map[string]bool // 已批准的工具名
// ApprovalPolicy 工具审批策略:never | dangerous-only | always。
// ""(零值)按 dangerous-only 门控(只问副作用工具);
// always 额外询问非副作用工具;never 由入口直接 ApproveAll。
ApprovalPolicy string
// Mode 是审批档位(按工具类别预批准):
// ""(零值) ≡ 旧 dangerous-only:所有副作用工具都要问
// ModeAsk 同零值(显式表达「变更前确认」)
// ModeAcceptEdits 自动放行「改文件」类(write/edit),执行类仍需确认
// ModeBypass 全部放行(等价 ApproveAll)
Mode string
// DangerousPolicy 决定不可逆动作(rm -rf / git push / dd …)是否强制确认。
// 零值 "" 与 DangerousAsk 同为启用;DangerousAllow 关闭拦截。
// 该门**先于档位、ApproveAll 与信任缓存**,故 bypass / --yes 也拦得住。
DangerousPolicy string
// ---- ask_user(向人提问)的策略 ----
// AskDegradation 是没有交互通道(Opt.Ask==nil)时的落点:
// "" / AskDegradeDeferred 把问题渲染成本轮最终回复,回合结束(有人在场,
// 只是暂时没面板:TUI、REPL、聊天渠道、WebUI)
// AskDegradeUnattended 自动采纳推荐项继续(-p headless、定时任务)
// 默认取 deferred 而不是 unattended:屏幕后面通常有人,把问题问出来比替他
// 做决定好;只有真正没人值守的入口该显式改成 unattended。
AskDegradation string
// AskGraceSeconds 是宽限期:这段时间里界面不显示倒计时(用户正在打字时旁边
// 跳数字很扰)。0 = 立刻显示倒计时。
AskGraceSeconds int
// AskTimeoutSeconds 是自动结束的总时限;<=0 = 永不自动结束(一直等)。
AskTimeoutSeconds int
// AskAutoContinue 决定倒计时到点之后怎么办:true = 采纳推荐项继续,
// false = 按 Deferred 收尾(问题留在界面上,等人回答)。
// 默认值来自配置(true),所以由入口显式赋值——忘了接的后果是"到点不替
// 用户决定",方向上是安全的。
AskAutoContinue bool
// 本回合的提问账(见 ask.go):asked/askAuto 供复盘,askDegraded 用于
// 「连续两次没人可答就停用」的硬护栏,pendingAsk 是 Deferred 的收尾文本。
// askMu 保护它们——工具本身串行执行,但 step 收尾与渲染分处两个调用点。
askMu sync.Mutex
asked int
askAuto int
askDegraded int
pendingAsk string
// Secrets 是"绝不能出现在工具结果里"的字面量(本进程配置中的 API key 等)。
// 由 main 从配置注入;凭据读取拦截挡不住绕过名字匹配的读法(变量间接、脚本里
// open()),这一层保证读到之后密钥也进不了 transcript 与外发通道。见 redactSecrets。
Secrets []string
// Thinking 是本轮请求的思考/推理深度(零值 = off,不发新字段)。
Thinking llm.ThinkingLevel
// SystemSuffix 可空的每轮动态后缀:在 step() 里追加到 System 之后发给模型。
// 用于注入不进 transcript、因而**躲过上下文压缩**的内容(如当前计划,
// 见 PlanSection)。为空时行为与以前完全一致。
SystemSuffix func() string
// ExtraSections 可空;宿主提供的「每轮注入、不进 transcript」的额外动态段
// (AutoGo 里的计划块与记忆段经它接入,躲过上下文压缩)。经 DynamicSections
// 与框架自有段(放弃分支说明、子树指令)拼在一起。每次 step 重算,必须无副作用。
ExtraSections func() string
// failStreak 记录每个工具的连续失败次数(用于 [STUCK] 换思路提示),
// failMu 保护它:execConcurrent 会在多个 goroutine 里调 execResult。
failStreak map[string]int
failMu sync.Mutex
// 重复调用检测(见 anomaly.go):数的是"同名同参",盖的是**成功却空转**那一类,
// 与只数失败的 failStreak 互补。repeatMu 同样是因为并发批次会多 goroutine 进来。
repeatCounts map[string]int
anomalyNotes int
repeatMu sync.Mutex
// 上下文预算(零值走 applyDefaults 的默认)。
ContextWindow int // 模型上下文窗口(tokens,粗估)
CompactAt float64 // 窗口占用率达到该比例触发摘要压缩
MinCompactTokens int // 低于该 token 数不压缩(保护短对话)
MaxToolResultChars int // 单条工具结果超过该字符数在视图层截断
MaxImages int // 工作集中最多保留的图片数(驱逐更旧的截图)
// StreamRetryBudget 是一次模型调用"没答上"时的重试时间预算(config 的
// retry_budget_seconds):0 = 用内置默认(10 分钟),负数 = 不限(只被停止/取消
// 打断)。无人值守入口想更快放弃或更肯等,就调它——子 agent 继承父级。
StreamRetryBudget time.Duration
ImageBudgetBytes int64 // 工作集图片 base64 总字节预算(防网关 413)
// ResultDir 非空时,视图层截断的超长工具结果会落盘到该目录,提示里带上
// 可读路径(模型可用 read 取回全文)。空则退回"重跑工具"提示。
// 注意:该目录必须已在 tools.SetAllowedRoots 内,否则 read 读不到。
ResultDir string
// ImgCache 是图片缓存(可空)。工具结果里的截图经它落盘得到「可服务文件名」,
// 写入 ToolEvent.Images,供前端在工具行内联渲染(边跑边显示,不必等整轮答完)。
// 同时回填 result.Images[*].Filename,让 collectToolImages 跳过、会话重进仍可加载。
ImgCache *imagecache.Cache
// 使用统计(当前会话累计,不含子 agent)。
usedIn, usedOut int
// curStep 是模型调用序号(每轮 step 自增),用于给 trace 的 Step 字段赋值。
curStep int
// outputContinues 是本回合因"输出上限切断"而自动续写的次数(见 continue.go)。
// 按回合清零:它既是复盘里的一个指标,也是续写预算的计数器。
outputContinues int
// 子 agent 委派控制。Depth 是当前层级(顶层 0,子 agent = 父+1);
// MaxDepth 是允许的最大委派层数——**零值 0 表示禁止委派**(fail-closed)。
// MaxDepth=1 即"顶层可派发、子 agent 不能再派发"(历史行为)。
Depth int
MaxDepth int
SubAgentConcurrency int // 单次 agent 调用内并行子任务的上限
// nextSynthetic / syntheticTurn:本次 Run 追加的用户消息是否由系统生成
// (后台通知)。RunSynthetic 置位一次,Run 开头消费并记到 syntheticTurn,
// 供 beginTurn / 复盘跳过人工提问计数。
nextSynthetic bool
syntheticTurn bool
// 副作用门控:stepHadSideEffect 一旦为真,本 step 的模型调用不再重试
//(提前启动工具/工具执行中途失败时,重跑会重复副作用);turnHadSideEffect
// 跨 step 保留,回合失败时据此告诉宿主"别自动整轮重试"。
stepHadSideEffect bool
turnHadSideEffect bool
// MaxTurns 是单条消息内模型往返次数上限(<=0 时用 DefaultMaxTurns)。
// 超限后不是抛错,而是禁掉工具再要一轮收尾(见 wrapUp)。
MaxTurns int
// SelfAssess 开启回合收尾的模型自评(config retro_selfassess,默认关):
// 每个有工具调用的回合结束后异步发一次小调用做归因(retro.SelfAssess),
// 不阻塞回合返回。复盘的硬信号记录不受此开关影响,始终落盘。
SelfAssess bool
// 模型预设(config fallback_model / multimodal_model,空 = 不启用):
// FallbackModel 在调用失败时粘性回落;MultimodalModel 承接含图回合
// (回合结束恢复原模型)。路由实现见 model_routing.go。
FallbackModel string
MultimodalModel string
// spillNS 是超长结果落盘文件名的前缀。并行子 agent 的 tool_call ID 常相同
// (call_1 等),不加前缀会互相复用同名文件读到别人的内容。
spillNS string
// 压缩后的工作集缓存:Session 是完整 transcript(JSONL),
// contextView = compacted + Session[compactFrom:],新消息自动接上。
// compactMu 保护 compacted/compactFrom:Run 循环的自动压缩与手动
// CompactNow(/compact 命令)可能并发访问同一对字段。
compactMu sync.Mutex
compacted []llm.Message
compactFrom int
// 压缩交接文档:checkpoint 是结构化摘要,checkpointCovered 是它覆盖到原
// transcript 的第几条,checkpointHash 是该边界消息的指纹(失效保护)。
// 三者与 compacted/compactFrom 一起受 compactMu 保护。
checkpoint string
checkpointCovered int
checkpointHash string
// 真实 token 观测:上一次成功请求的 prompt tokens 与当时的视图规模。
// 压缩判据优先用它(见 observedTokens),不再只靠 bytes/3。
usageMu sync.Mutex
lastInputTokens int
lastInputMsgs int
lastSystemBytes int
// abandonedNotes 是"用户已经回退掉的那条分支"的摘要文本,经 AbandonedSection
// 注入 system 后缀:模型因此知道这条路试过、被放弃了(连同它查出来的事实),
// 而不是整段失忆。它不进 transcript、不参与压缩、重载会话后自然消失。
//
// 用**自己那把锁**:这段经 sysNow 注入,而 sysNow 在 contextView/CompactNow 里
// 是持着 compactMu 跑的——复用 compactMu 会自己等自己(真死过一次,测试里抓到的)。
abandonedMu sync.Mutex
abandonedNotes []string
// compactGaveUp 记着"本回合压缩试过但没成功"。不记的话每个 step 都会再撞一次
// 摘要请求——一次 30 步的回合就是 30 次注定失败的调用,慢、烧钱,而且用户什么
// 都看不到。Run 开头清零:下一回合再给一次机会(网关可能已经恢复)。
compactGaveUp bool
// Skills 是可用技能列表(SKILL.md);skillFilter 非空时工具表白名单生效。
Skills []skills.Skill
skillFilter map[string]bool
// 读取文件时发现的嵌套 AGENTS.md(子树约定)。经 DynamicSections 每轮注入,
// 不进 transcript,因此躲过上下文压缩;按绝对路径去重,并限制总量。
instructionsMu sync.Mutex
discoveredInstructions []discoveredInstruction
discoveredSeen map[string]bool
discoveredBytes int
// TurnMarker 是会话留档层的回合钩子:每开始一个回合报一次「第几个用户
// prompt」,留档条目据此归档,按 prompt 粒度回退才有意义。声明成内联小接口
// 而不是具体类型,agent 层就不必依赖留档包。
TurnMarker interface{ BeginTurn(int) }
}
// ErrAfterSideEffects 表示本回合已经执行过副作用工具,失败不能自动整轮重试。
// 宿主(WebUI 前端)据此把"自动重试"降级成"手动重试"。
var ErrAfterSideEffects = errors.New("turn already executed side-effecting tools; retry manually")
// Run 执行一个完整 turn:持续消费模型输出与工具调用,直到模型给出最终回复。
// userMsg 为空表示续跑(历史已在会话中)。
func (a *Agent) Run(ctx context.Context, userMsg string) error {
synthetic := a.nextSynthetic
a.nextSynthetic = false
a.syntheticTurn = synthetic
// 续跑(userMsg 为空)时,未完结的回合可能已经跑过副作用;新回合从零算。
if userMsg == "" {
a.turnHadSideEffect = a.historyHasSideEffects()
} else {
a.turnHadSideEffect = false
}
defer func() { a.syntheticTurn = false }()
if a.trusted == nil {
a.trusted = map[string]bool{}
}
a.applyDefaults()
// 工作区互斥:整个回合(含收尾的 checkpoint)都待在占用窗口里,抢不到就不开始。
// 登记动作挂在 Run 上而不是各入口里——分散到入口必然漏一个(终端就是漏的那个:
// 一个终端会话和一个 WebUI 会话并行改同一个项目,互相覆盖之外还会撞 git 的
// index.lock)。宿主只在顶层回合调它,子 agent 与父级共享同一个窗口。
if a.Opt.Occupy != nil && a.Depth == 0 {
if err := a.Opt.Occupy(); err != nil {
return err
}
if a.Opt.Release != nil {
defer a.Opt.Release()
}
}
// 提问账按回合清零(见 ask.go:连续退化护栏只在本次任务内生效)。
a.resetAskTurn()
a.outputContinues = 0
a.compactGaveUp = false // 压缩失败的"本回合放弃"标记按回合清零
a.resetAnomalyCounters()
// 上一次运行可能死在工具执行中途(Ctrl-C、进程被杀、机器重启),留下没有
// tool_result 配对的 tool_use。必须在写入本回合的新消息**之前**补齐:
// Anthropic 要求 tool_result 紧挨着含 tool_use 的那条 assistant,中间插了
// user 消息就修不回来了——届时这条会话每次请求都 400,问什么都不回。
// (幂等:已配对的调用不动。)
a.synthMissingToolResults()
a.beginTurn(userMsg)
goalIdx := len(a.Session.Messages) // 本回合新消息的起始下标(user 消息即将落在这里)
started := time.Now()
if userMsg != "" {
if err := a.Session.Append(llm.Message{Role: llm.RoleUser, Content: userMsg, Synthetic: synthetic}); err != nil {
return err
}
}
// 含图回合路由到多模态模型(回合结束恢复原模型)。
// 续跑(userMsg 为空)时触发回合的用户消息已在历史末尾,扫描起点回退一条。
imgStart := goalIdx
if userMsg == "" && imgStart > 0 {
imgStart--
}
hasImages := false
for _, m := range a.Session.Messages[imgStart:] {
if len(m.Images) > 0 {
hasImages = true
break
}
}
if restore := a.routeMultimodal(hasImages); restore != nil {
defer restore()
}
for turn := 0; turn < a.MaxTurns; turn++ {
term, err := a.step(ctx)
if err != nil {
return a.finishTurn(goalIdx, started, ctx, err, false)
}
// 插话只在 step 边界落盘:工具结果已回填、下一次请求之前。模型本来说完了
// 也不算收尾——有新输入就继续这一回合(与"回合结束再发"的旧排队语义兼容,
// 只是提前到了最近的边界)。
delivered, serr := a.deliverSteers()
if serr != nil {
return a.finishTurn(goalIdx, started, ctx, serr, false)
}
if term == transitionCompleted {
if delivered == 0 {
return a.finishTurn(goalIdx, started, ctx, nil, false)
}
continue
}
// continueNextTurn:工具结果已回填,进入下一轮
}
// 步数用尽:不再直接抛错(那会让用户只拿到一句 reached max turns,完全不知道
// 做到哪了、还剩什么),改为禁掉工具再要最后一轮收尾说明,把上限从「中断」
// 降级成「体面收尾」。工作不会丢——消息早就逐条落盘,用户回一句「继续」即
// 可接着跑(续跑会重新拿到一份完整的步数预算)。
return a.finishTurn(goalIdx, started, ctx, a.wrapUp(ctx), true)
}
// RunSynthetic 与 Run 相同,但把本次追加的用户消息标记为系统生成(后台通知等):
// 展示上按系统说明处理,统计与 /rewind 提问号不计入人工输入。
func (a *Agent) RunSynthetic(ctx context.Context, text string) error {
a.nextSynthetic = true
return a.Run(ctx, text)
}
// SetModel 切换当前 Provider 的模型(运行时生效,作用于下一次调用)。
func (a *Agent) SetModel(model string) error {
if model == "" {
return fmt.Errorf("model name is empty")
}
if s, ok := a.Provider.(llm.ModelSwitcher); ok {
s.SetModel(model)
return nil
}
return fmt.Errorf("provider does not support live model switching")
}
// ListModels 枚举当前 Provider 可用模型。
func (a *Agent) ListModels(ctx context.Context) ([]string, error) {
if l, ok := a.Provider.(llm.ModelLister); ok {
return l.ListModels(ctx)
}
return nil, fmt.Errorf("provider does not support listing models")
}
// TurnReport 是一次顶层回合收尾的结构化报告,OnTurnEnd 的入参。
// 复盘、进展沉淀、checkpoint 留档等宿主侧收尾经它取数。
type TurnReport struct {
Err error
ByTurnCap bool // true = 因 MaxTurns 收尾(err 为 nil 但不是模型自己说完)
Steps int // 本回合模型调用次数
Ms int64
GoalIndex int // 本回合第一条用户消息在 transcript 里的下标(复盘取目标用)
Started time.Time
}
// 回合结束记录的原因码,写入 session.TurnEnd.Reason。
// **取值必须与 AutoGo retro 包保持一致**:会话文件格式跨两端兼容。
const (
OutcomeCompleted = "completed"
OutcomeFailed = "failed"
OutcomeStopped = "stopped"
OutcomeMaxTurns = "max_turns"
)
// finishTurn 是 Run 的唯一终止出口:先落会话文件里的回合结束记录(best effort,
// 不改变返回值),再原样返回 err。
//
// byTurnCap 用来区分"模型自己说完了"和"步数上限收尾"——两者的 err 都是 nil,
// 光看错误分不出来,而"回合到底为什么停"恰恰是事后取证最想知道的那件事
// (实报:一条会话里 8 次发言有 4 次的上一条是工具结果,从会话文件看不出是被谁切的)。
func (a *Agent) finishTurn(goalIdx int, started time.Time, ctx context.Context, err error, byTurnCap bool) error {
a.recordTurnEnd(started, ctx, err, byTurnCap)
// 工作区级收尾挂在同一个出口:四端(TUI/REPL/WebUI/渠道/cron)都经这里,
// 分散到各入口就会有的做了有的没做。子 agent 不回调——它的会话是 ephemeral,
// 留档与进展归父级那一次回合。
if a.Opt.OnTurnEnd != nil && a.Depth == 0 {
a.Opt.OnTurnEnd(TurnReport{
Err: err, ByTurnCap: byTurnCap, Steps: a.curStep,
Ms: time.Since(started).Milliseconds(), GoalIndex: goalIdx, Started: started,
})
}
// 已经动过现实世界:告诉宿主这是一个"可手动重试、不可自动整轮重试"的失败。
// 用户主动停止不算失败,不包这一层——否则 errors.Is(err, context.Canceled)
// 会被破坏,界面会把"停止"误报成错误。
if err != nil && a.turnHadSideEffect && !errors.Is(err, ErrAfterSideEffects) &&
!errors.Is(err, context.Canceled) {
err = fmt.Errorf("%w: %w", ErrAfterSideEffects, err)
}
return err
}
// Notify 把一句系统说明交给宿主(界面时间线上的一行)。宿主没接就什么都不做——
// 压缩那条路径自己有 stderr 日志,这里不再重复写,否则同一条信息在日志里出现两遍。
// 入口层(工作区收尾、留档)也用它:这些话是"发生过的实事",不该只进日志文件。
func (a *Agent) Notify(text string, failed bool) {
if a.Opt.OnNotice != nil {
a.Opt.OnNotice(text, failed)
}
}
// deliverSteers 把宿主排队的插话按顺序落成普通 user 消息,返回实际插入的条数。
// 一条新输入等于一次新的输出预算:续写计数与"压缩失败本回合放弃"都要重置,
// 否则上一段被长度上限截断过,插话后的第一段会少一次续写机会。
func (a *Agent) deliverSteers() (int, error) {
if a.Opt.Steer == nil {
return 0, nil
}
delivered := 0
for _, m := range a.Opt.Steer() {
if strings.TrimSpace(m.Text) == "" && len(m.Images) == 0 {
continue
}
msg := llm.Message{Role: llm.RoleUser, Content: m.Text}
for _, name := range m.Images {
msg.Images = append(msg.Images, llm.Image{Filename: name})
}
if err := a.Session.Append(msg); err != nil {
return delivered, err
}
delivered++
a.outputContinues = 0
a.compactGaveUp = false
if a.Opt.OnSteer != nil {
a.Opt.OnSteer(m)
}
}
return delivered, nil
}
// reportCompact 把一次压缩报出去:结构化结果给要算数字的宿主,同一句人话给
// 只渲染文本的宿主(TUI 的系统块、WebUI 时间线上的一行)。措辞只有一份。
func (a *Agent) reportCompact(res CompactionResult) {
if a.Opt.OnCompact != nil {
a.Opt.OnCompact(res)
}
a.Notify(res.Text(), res.Err != "")
}
// recordTurnEnd 往会话文件追加一行回合结束记录。
func (a *Agent) recordTurnEnd(started time.Time, ctx context.Context, err error, byTurnCap bool) {
if a.Session == nil || a.Depth > 0 {
return // 子 agent 的会话是 ephemeral,记了只是噪声
}
reason := OutcomeCompleted
switch {
case byTurnCap:
reason = OutcomeMaxTurns
case err != nil && (errors.Is(err, context.Canceled) || errors.Is(context.Cause(ctx), context.Canceled) || ctx.Err() != nil):
reason = OutcomeStopped
case err != nil:
reason = OutcomeFailed
}
note := ""
if err != nil {
// 按字截,不按字节截:错误信息里通常有中文,字节切片会切出半个字。
note = session.TruncateRunes(err.Error(), 160)
}
a.Session.AppendTurnEnd(session.TurnEnd{
Reason: reason, Error: note, Step: a.curStep, Ms: time.Since(started).Milliseconds(),
})
}
// turnTransition 是 step 的返回转移。错误一律通过 error 返回;出错时转移值
// 为 transitionFailed,Run 只需区分 completed / 其他。
type turnTransition string
const (
transitionCompleted turnTransition = "completed" // 模型给出最终回复,无工具调用
transitionNextTurn turnTransition = "next_turn" // 工具结果已回填,进入下一轮
transitionFailed turnTransition = "failed" // 出错退出(原因经 error 返回)
)
// sysNow 返回本轮实际下发的 system 提示:System + SystemSuffix(若设置)。
// 每轮重算,供 token 估算与子 agent 的冻结快照使用,保证两者看到的长度一致。
// 注意:发请求时这两段是**分开传**的(见 step),动态段不进入缓存断点。
func (a *Agent) sysNow() string {
return a.System + a.systemSuffix()
}
// systemSuffix 返回每轮动态段;nil 时为空串。
func (a *Agent) systemSuffix() string {
if a.SystemSuffix == nil {
return ""
}
return a.SystemSuffix()
}
// DynamicSections 组合所有「每轮注入、不进 transcript」的动态段。作为 SystemSuffix
// 挂上后,这些段既躲过上下文压缩,也能立刻反映本轮的修改(如刚写入的记忆)。
// 段为空时返回空串,不占 token。
func (a *Agent) DynamicSections() string {
extra := ""
if a.ExtraSections != nil {
extra = a.ExtraSections()
}
return extra + a.AbandonedSection() + a.InstructionsSection()
}
// discoveredInstruction 是一份读取时发现的子树 AGENTS.md。
type discoveredInstruction struct {
path string
text string
}
const (
maxDiscoveredFiles = 8
maxDiscoveredBytes = 64 * 1024
)
// AddInstructions 记下一份嵌套 AGENTS.md。工具层在 read 成功时调用;按绝对路径
// 去重并限制总量——这些内容每轮都会进 system,不能无限膨胀。并发安全:
// 并行的只读工具可能同时发现不同子树。
func (a *Agent) AddInstructions(path, text string) {
text = strings.TrimSpace(text)
if path == "" || text == "" {
return
}
a.instructionsMu.Lock()
defer a.instructionsMu.Unlock()
if a.discoveredSeen == nil {
a.discoveredSeen = map[string]bool{}
}
if a.discoveredSeen[path] {
return
}
if len(a.discoveredInstructions) >= maxDiscoveredFiles || a.discoveredBytes+len(text) > maxDiscoveredBytes {
return
}
a.discoveredSeen[path] = true
a.discoveredBytes += len(text)
a.discoveredInstructions = append(a.discoveredInstructions, discoveredInstruction{path: path, text: text})
}
// InstructionsSection 渲染读取时发现的子树指令;没有则返回空串。
func (a *Agent) InstructionsSection() string {
a.instructionsMu.Lock()
defer a.instructionsMu.Unlock()
if len(a.discoveredInstructions) == 0 {
return ""
}
cwd, _ := os.Getwd()
var b strings.Builder
b.WriteString("\n\n# Project instructions (discovered for the subtree you read)\n")
for _, item := range a.discoveredInstructions {
name := item.path
if rel, err := filepath.Rel(cwd, item.path); err == nil && rel != ".." &&
!strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
name = rel
}
b.WriteString("\n## " + name + "\n" + item.text + "\n")
}
return b.String()
}
// step 执行一轮:上下文视图 → 流式调模型 → 停止分派 → 工具执行回填。
func (a *Agent) step(ctx context.Context) (turnTransition, error) {
msgs, err := a.contextView(ctx)
if err != nil {
return transitionFailed, fmt.Errorf("context: %w", err)
}
a.curStep++
a.stepHadSideEffect = false
started := time.Now()
// 提前执行只读工具:provider 在某个工具参数完整时回调,onDelta 里把它
// 丢到独立 goroutine。每次模型尝试都有自己的一组(重试时旧的一律取消)。
earlyCtx, cancelEarly := context.WithCancel(ctx)
defer func() { cancelEarly() }()
early := map[string]chan earlyOutcome{}
resetEarly := func() {
cancelEarly()
earlyCtx, cancelEarly = context.WithCancel(ctx)
early = map[string]chan earlyOutcome{}
}
streamed := false
// partial 攒的是**已经流到屏幕上的字**。provider 出错时返回的是零值 Message
// (半截内容不在里面),所以只能自己攒——否则一次中断就把用户已经看到的
// 那段话整个丢掉(ZCode 的 cancelled-stream-persistence 治的就是这个)。
var partialText, partialReasoning strings.Builder
call := func() (llm.Message, error) {
partialText.Reset()
partialReasoning.Reset()
return a.Provider.Stream(ctx, a.System, msgs, a.toolDefs(),
llm.StreamOptions{Thinking: a.Thinking, SystemDynamic: a.systemSuffix()},
func(d llm.StreamDelta) {
if d.ToolCall != nil {
a.startEarlyTool(earlyCtx, *d.ToolCall, early)
}
if d.Text != "" {
streamed = true
partialText.WriteString(d.Text)
fmt.Fprint(a.Opt.Stdout, d.Text)
}
if d.Reasoning != "" {
partialReasoning.WriteString(d.Reasoning)
if a.Opt.OnReasoning != nil {
a.Opt.OnReasoning(d.Reasoning)
}
}
})
}
final, err := call()
// 确定性失败(400、鉴权、余额)当场换人——重试救不回来。偶发的坏流反过来:
// 先在**同一个模型**上重试几轮,真熬不过去再切备选(下面那条环),否则一次抖动
// 就把整轮从用户选的模型上拽走。
// 副作用门控:一旦本 step 已经跑过会改变现实的工具,就绝不再重发同一个
// 请求——重发等于让模型可能把同一笔写/命令做第二遍。正常路径下工具在
// 调用成功之后才执行,所以这里通常是 false;它是为"工具提前启动"与
// 工具执行中途失败留的硬约束。
if err != nil && !a.stepHadSideEffect && !llm.IsTransientStream(err) && a.fallbackTo(ctx, err, streamed) {
a.abandonStream("已切到备选模型重跑本步", &partialText, &partialReasoning)
resetEarly()
final, err = call() // 已切到备选模型,重试本步
}
// 交付才停:坏流不重试,等于把一次网关抖动变成一次人工中断。provider 现在对
// "整轮空 / 流被截断 / 流卡死"都报错而不是返回一条空消息——因为下面那句"没有
// tool_calls ⇒ 模型说完了"会把它们当成**成功收尾**(取证见 llm/completion.go);
// 但"报错了就结束回合"仍然是要人打「继续」。所以按时间预算退避重发本步。
budget := a.StreamRetryBudget
if budget == 0 {
budget = streamRetryBudget
}
unlimited := budget < 0
deadline := time.Now().Add(budget)
wait := streamRetryFirst
for attempt := 1; llm.IsTransientStream(err) && !a.stepHadSideEffect; attempt++ {
if ctx.Err() != nil {
break // 用户已经喊停
}
if a.requestLooksOversized(err, msgs) {
break // 上下文大到可疑:该走压缩,不是继续重发同一个大请求(下面那条自救)
}
if !unlimited && time.Now().Add(wait).After(deadline) {
break // 预算见底:把失败如实交出去,不再多打一次请求
}
if attempt == 1 {
span := budget.String()
if unlimited {
span = "不限时长"
}
a.Notify(fmt.Sprintf("模型没答上(%v),正在重发这一步,最多再试 %s", err, span), true)
} else if attempt%5 == 0 {
a.Notify(fmt.Sprintf("仍在重试:第 %d 次,本步已耗 %s",
attempt, time.Since(started).Round(time.Second)), true)
}
fmt.Fprintf(a.Opt.Stderr, "[stream] %v,第 %d 次重试(退避 %s,预算 %s)\n",
err, attempt, wait, budget)
if !sleepCtx(ctx, wait) {
break // 叫停:保留原错误交给上层按"停止"分派,不伪装成模型失败
}
wait *= 2
if wait > streamRetryCeil {
wait = streamRetryCeil
}
if attempt >= streamRetrySwitchAfter && a.fallbackTo(ctx, err, streamed) {
a.Notify(fmt.Sprintf("连试 %d 次没答上,切到备选模型 %s 继续", attempt, a.FallbackModel), true)
}
a.abandonStream("上一次回复没流完,重跑本步", &partialText, &partialReasoning)
streamed = false
resetEarly()
final, err = call()
}
// 请求过大自救:网关的 token 计数和估算必然有偏差,真撞墙时先压一次再重发。
// 一次不够就按 0.7 / 0.5 / 0.35 收缩目标再压——每次重发前都重取视图,不能拿
// 压缩前的那一份原样再撞一次墙。最多三次,压完还失败就如实交出原错误。
if err != nil && !a.stepHadSideEffect && a.requestLooksOversized(err, msgs) {
for _, shrink := range []float64{0.7, 0.5, 0.35} {
if cerr := a.forceCompactForSize(ctx, msgs, shrink); cerr != nil {
break
}
if fresh, verr := a.contextView(ctx); verr == nil {
msgs = fresh
}
streamed = false
a.abandonStream("上下文已压缩,重发本步", &partialText, &partialReasoning)
resetEarly()
final, err = call()
if err == nil || !a.requestLooksOversized(err, msgs) {
break
}
}
}
fmt.Fprintln(a.Opt.Stdout)
if err != nil {
// 中断时把**已经流出来的那半截**落盘。丢掉它的代价是双份的:用户屏幕上
// 看到的内容在会话里不存在(刷新/重进就没了),模型下一轮也看不到自己
// 说到哪了,只能从头再说一遍。
//
// 只落正文与思考,**绝不落 tool_calls**:半截的工具参数是没写完的 JSON,
// 落了就会有一条永远配不上对的 tool_use(Anthropic 会拒绝整条会话)。
a.persistPartial(partialText.String(), partialReasoning.String(), started)
return transitionFailed, err
}
// 本地 trace:仅供度量与展示,不会进 API 报文(见 llm.Message 注释)。
final.StartedAt = started.UnixMilli()
final.DurationMs = time.Since(started).Milliseconds()
final.Step = a.curStep
a.stampIdentity(&final)
if final.Usage != nil {
a.usedIn += final.Usage.InputTokens
a.usedOut += final.Usage.OutputTokens
// 缓存明细值得单独露出来:它证明 system+tools 前缀的 cache_control 真的
// 命中了(命中时 in 只报未缓存那一段,看不到 cache 会误判成"上下文很小")。
cache := ""
if u := final.Usage; u.CacheReadTokens > 0 || u.CacheCreationTokens > 0 || u.ReasoningTokens > 0 {
cache = fmt.Sprintf(" | cache %d/%d write, reasoning %d",
u.CacheReadTokens, u.CacheCreationTokens, u.ReasoningTokens)
}
fmt.Fprintf(a.Opt.Stderr, "[tokens] +%d in / +%d out%s | total %d in / %d out\n",
final.Usage.InputTokens, final.Usage.OutputTokens, cache, a.usedIn, a.usedOut)
// 记下真实 prompt 规模:下一轮压缩判据以它为基准(+新增消息估算)。
a.noteRequestUsage(final.Usage, msgs)
}
// 输出上限切断 → 自动续写(见 continue.go)。放在落盘之前:几段输出要合并成
// **一条** assistant 消息,会话里才不会出现两条相邻 assistant(那会让"哪条是
// 本回合最终回复"和回合号都多一套特例)。
if final.OutputLimited && len(final.ToolCalls) == 0 {
final = a.continueOutput(ctx, final, msgs)
}
if err := a.Session.Append(final); err != nil {
return transitionFailed, err
}
if len(final.ToolCalls) == 0 {
// 防御:provider 提前通知过工具、最终消息却没有 tool_calls。取消提前任务,
// 结果一律丢弃(只读工具,无副作用)。
cancelEarly()
return transitionCompleted, nil // 模型给出最终回复,turn 结束
}
// 消息已落盘,现在才收提前执行的结果:tool_result 必须排在 tool_use 之后。
// 失败尝试走不到这里——那些结果随 ctx 取消一起丢弃(全是只读工具),暂存的
// 工具事件也一并不发,界面不会出现"没真正发生"的工具行。
earlyRaw := make(map[string]earlyOutcome, len(early))
for id, ch := range early {
earlyRaw[id] = <-ch
}
earlyResults := make(map[string]toolOutcome, len(final.ToolCalls))
for _, tc := range final.ToolCalls {
e, ok := earlyRaw[tc.ID]
if !ok {
continue
}
for _, ev := range e.events {
a.emitTool(ev)
}
earlyResults[tc.ID] = e.outcome
}
// 本轮挂起了 tool_use:无论 step 以何路径退出,都要保证 tool_result
// 配对(借鉴 ccb query.ts:149),否则续跑时 API 报文不配对。
pendingTools := true
defer func() {
if pendingTools {
a.synthMissingToolResults()
}
}()
if err := a.execToolsWithEarly(ctx, final.ToolCalls, earlyResults); err != nil {
return transitionFailed, err
}
pendingTools = false
// Deferred 收尾(见 ask.go):ask_user 在没有人可答的入口把问题登记成「本轮
// 以这个问题结束」。不能只往 tool_result 里写一句"请把问题转达给用户"——
// 模型未必听话,更常见的是它自己猜一个方向继续跑,那正是这个功能要省掉的浪费。
// 这里直接合成一条 assistant 消息并结束回合:tool_result 已在上面配对完整,
// 所以报文仍然满足 Anthropic 的 tool_use/tool_result 相邻约束。
if askText := a.takePendingAsk(); askText != "" {
fmt.Fprintln(a.Opt.Stdout, askText)
if err := a.Session.Append(llm.Message{
Role: llm.RoleAssistant, Content: askText, Step: a.curStep,
}); err != nil {
return transitionFailed, err
}
return transitionCompleted, nil
}
return transitionNextTurn, nil
}
// InvalidateContext 丢掉内存里的压缩摘要,让下一次 contextView 从完整历史重建。
//
// 会话被截短(回退 / 编辑重发)之后必须调用:摘要描述的是"截断之前"的历史,
// 继续拿它当上下文,等于让模型基于一份已经不存在的对话作答——比不压缩更糟。
func (a *Agent) InvalidateContext() {
a.compactMu.Lock()
a.compacted, a.compactFrom = nil, 0
a.compactMu.Unlock()
}
// beginTurn 把回合号交给留档层:回合号 = 第几个用户 prompt,留档条目据此归档,
// `a2go rewind 3` 这种按 prompt 粒度回退才有意义。续跑(userMsg 为空)沿用上一个
// 编号——它是同一个 prompt 的继续,不是新的一次改动批次。
func (a *Agent) beginTurn(userMsg string) {
if a.TurnMarker == nil {
return
}
turns := 0
for _, m := range a.Session.Messages {
if m.Role == llm.RoleUser && !m.Synthetic {
turns++
}
}
switch {
case userMsg != "" && !a.syntheticTurn:
turns++
case turns == 0:
turns = 1
}
a.TurnMarker.BeginTurn(turns)
}
// stampIdentity 给 assistant 消息盖上「协议格式 + 模型」的归因戳。
// provider 没实现 Identifier 时静默跳过(自定义 provider 的测试替身常见)。
func (a *Agent) stampIdentity(m *llm.Message) {
if id, ok := a.Provider.(llm.Identifier); ok {
m.Format, m.Model = id.Identity()
}
}
// applyDefaults 补齐零值选项的默认值。
func (a *Agent) applyDefaults() {
if a.ContextWindow <= 0 {
a.ContextWindow = 128000
}
if a.CompactAt <= 0 || a.CompactAt > 1 {
a.CompactAt = 0.85
}
if a.MinCompactTokens <= 0 {
a.MinCompactTokens = 10000
}
if a.MaxImages <= 0 {
a.MaxImages = 6
}
if a.ImageBudgetBytes <= 0 {
a.ImageBudgetBytes = defaultImageBudgetBytes
}
if a.MaxToolResultChars <= 0 {
a.MaxToolResultChars = 20000
}
// 子 agent 并行上限:默认 5(参考 picoclaw SubTurn),并钳到工具并发上限。
// 注意**不给 MaxDepth 默认值**——零值即禁止委派(fail-closed),由入口显式配置。
if a.SubAgentConcurrency <= 0 {
a.SubAgentConcurrency = 5
}
if a.SubAgentConcurrency > maxToolConcurrency {
a.SubAgentConcurrency = maxToolConcurrency
}
if a.MaxTurns <= 0 {
a.MaxTurns = DefaultMaxTurns
}
}
// CanSpawnSubAgent 报告当前 agent 是否还能派发子 agent(深度未达上限)。
func (a *Agent) CanSpawnSubAgent() bool { return a.Depth < a.MaxDepth }
// startEarlyTool 在流式阶段提前执行一个只读工具。只接受 ReadOnly、非副作用、
// ConcurrencySafe、且不需要审批/不命中危险门的调用;其余等消息完整后走正常编排。
// 提前执行的结果会在 assistant 消息落盘后才回填,保证 tool_result 仍排在 tool_use 之后。
func (a *Agent) startEarlyTool(ctx context.Context, tc llm.ToolCall, early map[string]chan earlyOutcome) {
if tc.ID == "" || tc.Name == "" {
return
}
if _, exists := early[tc.ID]; exists {
return
}
t, ok := a.Tools.Get(tc.Name)
if !ok || !t.ReadOnly || t.SideEffect || !t.ConcurrencySafe {
return
}
if a.needsApproval(tc.Name, t) {
return
}
if _, danger := detectDanger(tc); danger {
return
}
ch := make(chan earlyOutcome, 1)
early[tc.ID] = ch
go func() {
var events []ToolEvent
out := a.execResultWith(ctx, tc, func(ev ToolEvent) { events = append(events, ev) })
ch <- earlyOutcome{outcome: out, events: events}
}()
}
// execTools 执行一批工具调用并按原顺序回填结果。
// 编排策略借鉴 ccb toolOrchestration.ts:106:连续的 ConcurrencySafe 调用并发
// 执行(上限 10),其余(写操作、bash)严格串行;回填顺序始终与调用顺序一致。
//
// 串行路径(所有副作用工具都在这里)每完成一个就立刻落盘,不等整批结束:
// 副作用已经发生之后,结果只留在内存里的窗口越短,崩溃/自动重试造成重复执行
// 的机会越小。
func (a *Agent) execTools(ctx context.Context, calls []llm.ToolCall) error {
return a.execToolsWithEarly(ctx, calls, nil)
}
// execToolsWithEarly 是带提前执行结果(流式阶段已经跑完的只读调用)的完整版本。
func (a *Agent) execToolsWithEarly(ctx context.Context, calls []llm.ToolCall, early map[string]toolOutcome) error {
for i := 0; i < len(calls); {
// 已经在流式阶段跑完的调用:直接按位置回填,绝不重跑。
if res, ok := early[calls[i].ID]; ok {
if err := a.appendToolResult(calls[i], res); err != nil {
return err
}
i++
continue
}
if n := a.safeRunLenExcept(calls, i, early); n > 1 {
batch := make([]toolOutcome, n)
a.execConcurrent(ctx, calls[i:i+n], batch)
for j := range batch {
if err := a.appendToolResult(calls[i+j], batch[j]); err != nil {
return err
}
}
i += n
continue
}
res := a.execResult(ctx, calls[i])
if err := a.appendToolResult(calls[i], res); err != nil {
return err
}
i++
}
return nil
}
// appendToolResult 把一次工具结果按 trace 字段写成 tool 消息并落盘。
func (a *Agent) appendToolResult(tc llm.ToolCall, res toolOutcome) error {
fmt.Fprintf(a.Opt.Stderr, "[tool] %s(%s) -> %s\n", tc.Name, compact(tc.Input), preview(res.res.Text))
msg := llm.Message{
Role: llm.RoleTool, Content: res.res.Text, ToolCallID: tc.ID,
// 本地 trace:工具耗时与该调用所属的模型轮次(不出网)。
StartedAt: res.startedAt,
DurationMs: res.durMs,
Step: a.curStep,
// 结构化的失败类别 / 工具名 / 退出码 / 截断标记:消费方(stats、retro、
// 前端)从此读字段,不再解析失败块的文字。
Tool: tc.Name,
Fail: res.kind,
Exit: res.res.Exit,
Truncated: res.res.Truncated,
}
if len(res.res.Images) > 0 {
msg.Images = res.res.Images
}
return a.Session.Append(msg)
}
// safeRunLen 返回从 calls[i] 开始连续 ConcurrencySafe 的调用数(0 表示 calls[i]
// 本身不可并发)。契约强制:有副作用的工具永远不进并发批次——即使被错误地
// 标注了 ConcurrencySafe(零值 fail-closed 的运行时兜底)。
func (a *Agent) safeRunLen(calls []llm.ToolCall, i int) int {
return a.safeRunLenExcept(calls, i, nil)
}
// safeRunLenExcept 与 safeRunLen 相同,但遇到已经提前完成的调用就断开批次:
// 那些调用只回填、不重跑。
func (a *Agent) safeRunLenExcept(calls []llm.ToolCall, i int, early map[string]toolOutcome) int {
n := 0
for ; i+n < len(calls); n++ {
if early != nil {
if _, done := early[calls[i+n].ID]; done {
break
}
}
t, ok := a.Tools.Get(calls[i+n].Name)
if !ok || !t.ConcurrencySafe || t.SideEffect {
break
}
}
return n
}
const maxToolConcurrency = 10
// execConcurrent 并发执行一段 ConcurrencySafe 调用,结果按索引写回。
func (a *Agent) execConcurrent(ctx context.Context, calls []llm.ToolCall, out []toolOutcome) {
for _, tc := range calls {
fmt.Fprintf(a.Opt.Stderr, "[tool] %s(%s) (concurrent)\n", tc.Name, compact(tc.Input))
}
sem := make(chan struct{}, maxToolConcurrency)
var wg sync.WaitGroup
for i, tc := range calls {
wg.Add(1)
go func(i int, tc llm.ToolCall) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
out[i] = a.execResult(ctx, tc)
}(i, tc)
}
wg.Wait()
}
// earlyOutcome 是提前执行的只读工具结果 + 暂存的事件:assistant 消息落盘后
// 才把 start/done 发给界面,失败的尝试不会留下幽灵工具行。
type earlyOutcome struct {
outcome toolOutcome
events []ToolEvent
}
// toolOutcome 是一次工具执行的结果 + 本地 trace(耗时/起始时刻/失败类别)。
// 单独包一层,避免把 agent 的度量字段塞进 tools.Result 污染工具契约。
type toolOutcome struct {
res tools.Result
startedAt int64 // unix ms
durMs int64
// kind 是 classifyFailure 归一出的失败类别(空 = 成功)。随消息落盘,
// stats/retro 从此不必解析 "[TOOL FAILED] …(kind)" 文本前缀。
kind string
}
// execResult 执行单个工具并返回结果(错误也作为结果回给模型,不中断批次)。
// 串行与并发两条路径都经此,故工具事件回调与计时只需挂在这里。
func (a *Agent) execResult(ctx context.Context, tc llm.ToolCall) toolOutcome {
return a.execResultWith(ctx, tc, a.emitTool)
}
// execResultWith 是 execResult 的可替换事件出口版本:提前执行的工具先把
// start/done 事件缓存起来,等 assistant 消息落盘后再一次性发出,避免失败的
// 流式尝试在界面上留下不存在的工具行。
func (a *Agent) execResultWith(ctx context.Context, tc llm.ToolCall, emit func(ToolEvent)) toolOutcome {
started := time.Now()
startedAt := started.UnixMilli()
if emit != nil {
emit(ToolEvent{ID: tc.ID, Name: tc.Name, Input: tc.Input, StartedAt: startedAt, SideEffect: a.sideEffectTool(tc.Name)})
}
result, err := a.execOne(ctx, tc)
kind := ""
// 自有密钥脱敏:路径/命令匹配能被绕过(变量间接、脚本里 open()、分块 base64),
// 但绕过读到的密钥值不该进 transcript——transcript 会发给模型、写进会话文件、
// 出现在 bot 聊天与 WebUI 里。放在失败格式化之前,两条分支都覆盖。
if result.Text != "" {
result.Text = a.redactSecrets(result.Text)
}
// 归一:工具也可以通过 Result.Err 报告失败(如 bash 的非零退出码),
// 这样它能保留已有 stdout 的同时表明自己失败了。
if err == nil {
err = result.Err
}
if err != nil {
// 结构化失败块 + 连续失败提示,替代原先裸的 "\nerror: ..."。
// 判定必须带上下文:真实耗时(提示里不许出现编造的数字)、调用入参
// (pkill 自匹配只有看命令才认得出)、以及是不是用户停止——它和超时报的是
// 同一个 "signal: killed",归错类就等于指着一条没问题的命令说"它太慢了"。
streak := a.noteToolFailure(tc.Name)
block, k := formatToolFailure(failInfo{
Tool: tc.Name,
Input: tc.Input,
Elapsed: time.Since(started),
Cancelled: errors.Is(ctx.Err(), context.Canceled),
}, err, result.Text, streak)
result.Text, kind = block, k
} else {
a.noteToolSuccess(tc.Name)
}
// 重复调用提醒(同名同参,成功空转那一类,见 anomaly.go)。放在失败格式化之后:
// 两条提示可以共存("这次又失败了"和"你已经在原地转了三圈"是不同的信息),
// 但顺序反过来会被失败块的 TrimRight 吃掉。
if n := a.noteToolRepeat(tc.Name, tc.Input); n > 0 {
result.Text = strings.TrimRight(result.Text, "\n") + "\n" + repeatNote(tc.Name, n)
}
out := toolOutcome{res: result, startedAt: startedAt, durMs: time.Since(started).Milliseconds(), kind: kind}
// 工具结果里的截图:落盘到图片缓存,拿到可服务文件名,供前端工具行内联渲染。
// 文件名同时回填 result.Images,使会话落盘后 Filename 已就绪(collectToolImages
// 会跳过已有 Filename 的项,重进对话也能直接加载)。
var toolImages []string
if a.ImgCache != nil && len(result.Images) > 0 {
for i := range result.Images {
img := &result.Images[i]
if img.Path == "" || img.Filename != "" {
if img.Filename != "" {
toolImages = append(toolImages, img.Filename)
}
continue
}
data, err := os.ReadFile(img.Path)
if err != nil {
continue
}
ext := filepath.Ext(img.Path)
if ext == "" {
ext = ".png"
}
name, err := a.ImgCache.Store(data, ext)
if err != nil {
continue
}
img.Filename = name
toolImages = append(toolImages, name)
}
}
if emit != nil {
emit(ToolEvent{
ID: tc.ID, Name: tc.Name, Input: tc.Input, Result: result.Text, Done: true,
SideEffect: a.sideEffectTool(tc.Name),
StartedAt: startedAt, DurationMs: out.durMs, Images: toolImages,
})
}
return out
}
// emitTool 安全地触发工具事件回调。
func (a *Agent) emitTool(ev ToolEvent) {
if a.Opt.OnTool != nil {
a.Opt.OnTool(ev)
}
}
// execOne 执行单个工具,副作用工具先过信任门控;skill 白名单生效时
// 拒绝白名单之外的调用。
func (a *Agent) execOne(ctx context.Context, tc llm.ToolCall) (tools.Result, error) {
if err := ctx.Err(); err != nil {
// 用户已经停止这一回合:这一批里还没开跑的工具必须一个都不碰现实世界。
// 不能指望各工具自己看 ctx——read/write/edit/ls/grep 完全忽略 ctx,
// 停止之后照样会落盘(此前"停止"只拦得住网络和 exec 类工具)。
return tools.Result{}, err
}
t, ok := a.Tools.Get(tc.Name)
if !ok {
return tools.Result{}, fmt.Errorf("unknown tool %q", tc.Name)
}
if a.skillFilter != nil && !a.skillFilter[tc.Name] {
return tools.Result{}, fmt.Errorf("tool %q is not allowed by the active skill (allowed: %v)", tc.Name, a.skillFilter)
}
// ask_user 直接执行:它不改现实世界,只是等一句话,所以不过审批门
// (ApprovalPolicy=always 下非副作用工具也会被问一次,那会变成「为了问一个
// 问题,先批准问这个问题」)。放在危险门**之前**:危险判定看的是这条调用要跑
// 什么命令,而提问没有命令(commandOf 只认 bash);今天不会被误判,但这个顺序
// 保证将来判定扩到别的工具时,「删掉多少文件」这类选项描述也不会被当成动作。
if t.EffectiveKind() == tools.KindAsk {
return a.runTool(ctx, t, tc)
}
// 危险动作门。两种语义要分清:
// - ModeBypass(WebUI「完全访问」,前端文案就是"全部自动放行,不再确认"):
// 人已经把话说到这份上了,这里既不该问也不该暗中拒绝——放行。这一档此前
// 既不装审批回调又留着这道门,结果是 push/rm 在有人盯着屏幕时被静默拒掉,
// 档位越开放人越没有表态的机会(实测日志:`allow tool "bash" ? [y/N]` 后面
// 直接 deny,因为 WebUI 的 stdin 是 nil)。
// - 其余档位(ask / accept-edits / 零值):**先于信任缓存与 --yes**。顺序放在
// needsApproval 里或其后,「本会话总是允许 bash」就会把 `rm -rf` 一起放行,
// 功能归零。没人可问(-p / cron / 渠道)时 fail-closed 拒绝。
// - 例外:凭据读取(alwaysAskRules)任何档位都要问——"完全访问"授权的是干活,
// 不是把 ~/.ssh 抄进回复,那是提示注入的兑现方式。
// 判定本身是启发式减速带,不是安全边界(见 dangerous.go)。
hit, danger := detectDanger(tc)
if danger && a.DangerousPolicy != DangerousAllow && (hit.alwaysAsk() || a.Mode != ModeBypass) {
ok, err := a.requestApproval(tc, hit)
if err != nil {
return tools.Result{}, err
}
if !ok {
a.logDangerDenied(tc, hit)
return tools.Result{}, dangerDeniedErr(tc, hit)
}
return a.runTool(ctx, t, tc)
}
// 常规审批门控:策略与档位共同决定是否询问(见 needsApproval)。
if a.needsApproval(tc.Name, t) {
ok, err := a.requestApproval(tc, dangerHit{})
if err != nil {
return tools.Result{}, err
}
if !ok {
return tools.Result{}, fmt.Errorf("user denied tool %q", tc.Name)
}
}
return a.runTool(ctx, t, tc)
}
// sideEffectTool 报告该工具是否会改变现实世界(ask_user 不算:它只等一句话)。
func (a *Agent) sideEffectTool(name string) bool {
t, ok := a.Tools.Get(name)
return ok && t.SideEffect && t.EffectiveKind() != tools.KindAsk
}
// historyHasSideEffects 报告当前未完结的回合(最后一条真实用户消息之后)是否
// 已经有副作用工具的结果。续跑时据此继续禁止自动整轮重试。
func (a *Agent) historyHasSideEffects() bool {
if a.Session == nil {
return false
}
for i := len(a.Session.Messages) - 1; i >= 0; i-- {
m := a.Session.Messages[i]
if m.Role == llm.RoleUser && !m.Synthetic {
return false
}
if m.Role == llm.RoleTool && a.sideEffectTool(m.Tool) {
return true
}
}
return false
}
// runTool 是工具真正碰现实世界的唯一入口。副作用工具在这里先立"已执行"标记,
// 再开始跑:哪怕它中途失败或被进程杀掉,本轮也不会再自动重试。
func (a *Agent) runTool(ctx context.Context, t *tools.Tool, tc llm.ToolCall) (tools.Result, error) {
if t.SideEffect && t.EffectiveKind() != tools.KindAsk {
a.stepHadSideEffect = true
a.turnHadSideEffect = true
}
return t.Run(ctx, tc.Input)
}
// dangerDeniedErr 构造**给模型看**的拒绝文本:只说被拦下了、以及不许绕。
//
// 旧文案末尾带着 "Set `dangerous=allow` (or AUTOGO_DANGEROUS=allow) to disable
// this guard"——那是给人看的运维提示,却顺着 transcript 发给了模型,而模型手里
// 有 bash 和 write:等于把关掉安全门的办法摆在它面前(实测有会话连吃 4 次拦截,
// 每次读到这句)。现在分两条通道:模型只收到拒绝,逃生口提示进日志
// (见 logDangerDenied)。
func dangerDeniedErr(tc llm.ToolCall, hit dangerHit) error {
return fmt.Errorf("dangerous action blocked (%s): %s. This needs an explicit human decision and "+
"there is no approver in this session. Do not work around it — no config edit, no env var, no "+
"alternate tool or shell trick. Tell the user what you were about to do and stop there",
hit.Rule, hit.Reason)
}
// logDangerDenied 把"确需自动化怎么办"写给**人**看的日志,不进 transcript。
func (a *Agent) logDangerDenied(tc llm.ToolCall, hit dangerHit) {
if a.Opt.Stderr == nil {
return
}
preview, cut := execx.Truncate([]byte(tc.Input), 300)
if cut {
preview = append(preview, "…"...)
}
fmt.Fprintf(a.Opt.Stderr,
"[danger] 已拦下 %s(规则 %s:%s):%s\n"+
"[danger] 无人值守确需放行时设 dangerous=allow 或 AUTOGO_DANGEROUS=allow——"+
"这会关闭不可逆动作门,请知情\n",
tc.Name, hit.Rule, hit.Reason, preview)
}
// needsApproval 判定一次工具调用是否需要征得用户同意。
//
// 档位(Mode)× 类别(ToolKind)的映射:
// - ""/ask 所有副作用工具都要问
// - accept-edits 只放行「改文件」(write/edit),执行/网络/未分类仍要问
// - bypass 全部放行
//
// 更优先的短路:ApproveAll(无人值守入口)与会话内信任缓存。
// 非副作用工具仅当旧策略为 always 且挂了交互回调时才问(保留历史语义)。
func (a *Agent) needsApproval(name string, t *tools.Tool) bool {
if a.Opt.ApproveAll || a.trusted[name] {
return false
}
if a.Mode == ModeBypass {
return false
}
if !t.SideEffect {
return a.ApprovalPolicy == "always" && a.Opt.Approval != nil
}
if a.Mode == ModeAcceptEdits {
return t.EffectiveKind() != tools.KindWrite
}
return true // ModeAsk 与零值 ""(≡ 旧的 dangerous-only)
}
// requestApproval 走交互回调(可缓存信任)或 stdin 询问。返回是否放行。
// hit 非空表示这是危险动作:preview 会带上人类可读原因(服务端预缩进 JSON,
// 这样 TUI 与前端「解析失败原样回退」时仍是可读的多行文本),且**不写信任缓存**
// ——「本会话总是允许 bash」不该等于「以后 rm -rf 也不用问」。
func (a *Agent) requestApproval(tc llm.ToolCall, hit dangerHit) (bool, error) {
if a.Opt.Approval != nil {
switch a.Opt.Approval(ApprovalRequest{
Tool: tc.Name,
Preview: approvalPreview(tc, hit),
Danger: hit.Rule != "",
}) {
case ApprovalAlways:
if hit.Rule == "" { // 危险动作不缓存
if a.trusted == nil {
a.trusted = map[string]bool{}
}
a.trusted[tc.Name] = true // 会话内记忆授权(借鉴 pigo trust)
}
case ApprovalOnce:
default:
return false, nil
}
return true, nil
}
return a.confirm(fmt.Sprintf("allow tool %q ?", tc.Name))
}
// approvalPreview 组装审批面板里展示的内容。非危险调用保持历史行为(原始入参),
// 危险调用在最前面加一行醒目原因。
func approvalPreview(tc llm.ToolCall, hit dangerHit) string {
if hit.Rule == "" {
return tc.Input
}
pretty := tc.Input
var buf bytes.Buffer
if err := json.Indent(&buf, []byte(tc.Input), "", " "); err == nil {
pretty = buf.String()
}
return "⚠ " + hit.Reason + "\n" + pretty
}
// confirm 通过 stdin 询问用户。无 stdin(无人值守入口)时视为拒绝。
func (a *Agent) confirm(prompt string) (bool, error) {
if a.Opt.Stderr != nil {
fmt.Fprintf(a.Opt.Stderr, "%s [y/N] ", prompt)
}
if a.Opt.Stdin == nil {
return false, nil
}
var ans string
if _, err := fmt.Fscanln(a.Opt.Stdin, &ans); err != nil {
return false, nil
}
ans = strings.ToLower(strings.TrimSpace(ans))
return ans == "y" || ans == "yes", nil
}
// synthMissingToolResults 为最后一个含 tool_use 的 assistant 消息中尚未得到
// tool_result 的调用合成错误结果(幂等:已配对的调用不动)。
func (a *Agent) synthMissingToolResults() {
msgs := a.Session.Messages
last := -1
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == llm.RoleAssistant && len(msgs[i].ToolCalls) > 0 {
last = i
break
}
}
if last < 0 {
return
}
answered := map[string]bool{}
for _, m := range msgs[last+1:] {
if m.Role == llm.RoleTool {
answered[m.ToolCallID] = true
}
}
for _, tc := range msgs[last].ToolCalls {
if answered[tc.ID] {
continue
}
err := a.Session.Append(llm.Message{
Role: llm.RoleTool,
Content: "error: tool execution was interrupted before a result was produced",
ToolCallID: tc.ID,
})
if err != nil {
fmt.Fprintf(a.Opt.Stderr, "[warn] failed to persist synthesized tool_result: %v\n", err)
}
}
}
// preview 截短工具结果用于终端展示。
func preview(result string) string {
result = strings.TrimSpace(result)
if i := strings.IndexByte(result, '\n'); i >= 0 {
result = result[:i] + "…"
}
if len(result) > 60 {
// 工具输出里中文与 emoji 都常见:60 是"给人看的字数",按字节切会劈开字符。
result = textx.ClipRunes(result, 60) + "…"
}
return result
}
// compact 截短工具参数用于终端展示。
func compact(input string) string {
var m map[string]any
if err := json.Unmarshal([]byte(input), &m); err == nil {
parts := []string{}
for k, v := range m {
s := fmt.Sprint(v)
if len(s) > 60 {
s = textx.ClipRunes(s, 60) + "…"
}
parts = append(parts, k+"="+s)
}
input = strings.Join(parts, ", ")
}
if len(input) > 120 {
input = textx.ClipRunes(input, 120) + "…"
}
return input
}
// wrapUpNote 是收尾回合前置在回复正文前的一行标记。写成正文的一部分而不是
// 单独的告警通道,是为了让它同时满足两件事:用户一眼知道这轮被截断了;它也会
// 作为上下文留在会话里,下一句「继续」时模型知道上一轮结束在哪里。
const wrapUpNote = "⏸ 已达本轮步数上限(%d)——以下是基于当前进度的收尾说明,回复「继续」可接着做。"
// wrapUpInstruction 是收尾回合临时追加到 system 末尾的指令。
// 挂在 system 而不是往会话里塞一条 user 消息:后者会作为真实用户输入落盘,
// 污染历史(用户从没说过这句话)。
const wrapUpInstruction = `
## 本轮步数已用尽
你已经用完本轮的步数预算,无法再调用任何工具。请**不要**声称完成了未验证的事情。
基于已有进度直接给出收尾说明:
1. 已经完成了什么(具体到文件/命令);
2. 还剩什么没做、下一步该做什么;
3. 如果有任何未验证的假设,明确指出。`
// wrapUp 在步数用尽后做一次收尾:禁用工具再要一轮,让用户拿到可用的结论,
// 而不是一句 "reached max turns"。收尾失败(网络/取消)仍然如实返回错误。
func (a *Agent) wrapUp(ctx context.Context) error {
msgs, err := a.contextView(ctx)
if err != nil {
return fmt.Errorf("wrap-up context: %w", err)
}
a.curStep++
started := time.Now()
// 保留 tools 定义(只禁用调用):与上一步相同的工具数组,缓存前缀才不被打碎。
final, err := a.Provider.Stream(ctx, a.System, msgs, a.toolDefs(),
llm.StreamOptions{Thinking: a.Thinking, SystemDynamic: a.systemSuffix() + wrapUpInstruction, DisableTools: true},
func(d llm.StreamDelta) {
if d.Text != "" {
fmt.Fprint(a.Opt.Stdout, d.Text)
}
if d.Reasoning != "" && a.Opt.OnReasoning != nil {
a.Opt.OnReasoning(d.Reasoning)
}
})
fmt.Fprintln(a.Opt.Stdout)
if err != nil {
return err
}
final.StartedAt = started.UnixMilli()
final.DurationMs = time.Since(started).Milliseconds()
final.Step = a.curStep
a.stampIdentity(&final)
// 去掉模型可能仍然发出的工具调用:本轮已明确禁用工具,留着会破坏
// tool_use / tool_result 配对。
final.ToolCalls = nil
final.Content = fmt.Sprintf(wrapUpNote, a.MaxTurns) + "\n\n" + final.Content
if final.Usage != nil {
a.usedIn += final.Usage.InputTokens
a.usedOut += final.Usage.OutputTokens
}
return a.Session.Append(final)
}