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
+445
@@ -0,0 +1,445 @@
|
||||
// ask_user 通道(对标 Claude Code 的 AskUserQuestion、ZCode 的 Elicitation):
|
||||
// 把「只有用户能拍板」的分叉收敛成结构化提问,而不是让模型猜一个方向跑几十步。
|
||||
//
|
||||
// 为什么不复用审批链路(见 agent.go 的 Approval):形状值得照抄——同步阻塞回调、
|
||||
// 由宿主呈现、宿主负责不挂死;但类型不能复用,ApprovalVerdict 是三态整数,装不下
|
||||
// 「选了第几项 / 自由文本 / 几个问题」,而 needsApproval 是按 SideEffect 与档位驱动的
|
||||
// **类别门**:在 ModeBypass 与无人值守入口(ApproveAll=true)下会被整条门控短路,
|
||||
// ask_user 永远问不出来。所以这里是一套独立的 request/answer,只借它验证过的时序。
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AnswerMode 是一次提问的最终落点。五种里只有 Answered 真的来自人。
|
||||
type AnswerMode string
|
||||
|
||||
const (
|
||||
Answered AnswerMode = "answered" // 用户答了
|
||||
TimedOut AnswerMode = "timed_out" // 倒计时到点(自动采纳或收尾,取决于 AskAutoContinue)
|
||||
Unattended AnswerMode = "unattended" // 根本没人可问(headless / cron)
|
||||
Deferred AnswerMode = "deferred" // 退化:问题作为本轮结尾发给用户,回合结束
|
||||
Declined AnswerMode = "declined" // 用户取消/关掉面板
|
||||
)
|
||||
|
||||
// 无人可答时的两种退化方式(Agent.AskDegradation)。
|
||||
const (
|
||||
// AskDegradeDeferred 把问题与编号选项渲染成一段文字,作为本轮最终回复并发给
|
||||
// 用户,然后结束回合。这是**默认**:绝大多数入口(TUI、REPL、聊天渠道、WebUI)
|
||||
// 屏幕后面是个人,只是暂时没有按钮面板可弹——把问题问出来总比替人决定好。
|
||||
AskDegradeDeferred = "deferred"
|
||||
// AskDegradeUnattended 自动采纳推荐项继续干活。给真正没人值守的入口
|
||||
// (-p headless、定时任务):那里结束回合等于任务原地不动。
|
||||
AskDegradeUnattended = "unattended"
|
||||
)
|
||||
|
||||
// askDegradeLimit 是「连续几次没人可答之后停用 ask_user」。护栏放在代码里而不是
|
||||
// 只写在提示词里:无人值守的长任务里,一个爱提问的模型能连着问十几次,每次都自动
|
||||
// 采纳推荐项——那和没有这个工具一样贵,还多了几十次往返。
|
||||
var askDegradeLimit = 2
|
||||
|
||||
// AskOption 是一个候选项。
|
||||
type AskOption struct {
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// AskQuestion 是一道题。
|
||||
type AskQuestion struct {
|
||||
// Header 是界面标签(≤12 字),Question 是完整问题。两者都给人看,分工不同:
|
||||
// 面板上标签挤在一行,问题在标题下方展开。
|
||||
Header string `json:"header"`
|
||||
Question string `json:"question"`
|
||||
MultiSelect bool `json:"multi_select,omitempty"`
|
||||
Options []AskOption `json:"options"`
|
||||
// Recommended 是推荐项下标;-1 = 没有推荐项(无人值守时不能替你决定)。
|
||||
Recommended int `json:"recommended"`
|
||||
}
|
||||
|
||||
// AskRequest 是一次提问。
|
||||
type AskRequest struct {
|
||||
// ID 用于异步界面把答案配回这次询问(WebUI 断线重连回放、TUI 面板回执)。
|
||||
ID string
|
||||
// SessionID 让宿主知道问题属于哪个会话(多会话并发服务时必需)。
|
||||
SessionID string
|
||||
Questions []AskQuestion
|
||||
// Clock 是这次询问的倒计时。宿主拿它算显示与超时,并在用户第一次操作时 Pause。
|
||||
Clock *AskClock
|
||||
}
|
||||
|
||||
// AskAnswer 是一次提问的答案。
|
||||
type AskAnswer struct {
|
||||
// Selected[i] 是第 i 题选中的 label(自由文本由界面作为额外一项塞进来)。
|
||||
Selected [][]string
|
||||
// Notes[i] 是第 i 题的补充说明,可空。
|
||||
Notes []string
|
||||
Mode AnswerMode
|
||||
}
|
||||
|
||||
// AskClock 是一次询问的倒计时状态机(ZCode 式三段:宽限期不显秒 → 可见倒计时 → 到点)。
|
||||
//
|
||||
// 明确偏离 ZCode 的一处:ZCode 把 deadline 落盘,因为它的询问可以跨 CLI 重启继续等;
|
||||
// a2go 的回合跑在进程内,进程一死回合本身就没了(下一轮 synthMissingToolResults 会
|
||||
// 补上 tool_result,会话已有 interrupted 状态),落盘没有保护对象。所以倒计时只在
|
||||
// 存活进程内有效——这不是偷懒,是少一套没有收益的状态机。
|
||||
type AskClock struct {
|
||||
grace time.Duration
|
||||
timeout time.Duration // <=0 = 永不自动结束
|
||||
started time.Time
|
||||
mu sync.Mutex
|
||||
paused bool
|
||||
}
|
||||
|
||||
// AskPhase 是倒计时的显示阶段。
|
||||
type AskPhase string
|
||||
|
||||
const (
|
||||
AskPhaseHidden AskPhase = "hidden" // 宽限期内:界面不显秒(别在用户打字旁边跳数字)
|
||||
AskPhaseCountdown AskPhase = "countdown" // 可见倒计时
|
||||
AskPhaseExpired AskPhase = "expired" // 到点
|
||||
AskPhaseWaiting AskPhase = "waiting" // 已暂停或永不超时:安静地等
|
||||
)
|
||||
|
||||
// NewAskClock 造一次询问的倒计时。导出是因为宿主(TUI 的面板、WebUI 的弹框、
|
||||
// 两边的测试)需要能构造出同一个状态机来验证到点与暂停的行为——宿主自己
|
||||
// 不决定"到点了怎么办"(那在 settleAsk),只决定"什么时候算到点"。
|
||||
func NewAskClock(grace, timeout time.Duration) *AskClock {
|
||||
return &AskClock{grace: grace, timeout: timeout, started: time.Now()}
|
||||
}
|
||||
|
||||
// Pause 永久取消这次询问的自动结束(幂等)。语义与 ZCode 的 first-action pause 一致:
|
||||
// 用户一动就说明他在线,此后不该再有东西替他做决定。
|
||||
func (c *AskClock) Pause() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.paused = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Paused 报告是否已被用户暂停。
|
||||
func (c *AskClock) Paused() bool {
|
||||
if c == nil {
|
||||
return true // 没有时钟就没有自动结束
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.paused
|
||||
}
|
||||
|
||||
// Grace 与 Timeout 供宿主呈现(Timeout<=0 表示永不自动结束)。
|
||||
func (c *AskClock) Grace() time.Duration {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.grace
|
||||
}
|
||||
func (c *AskClock) Timeout() time.Duration {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.timeout
|
||||
}
|
||||
|
||||
// Started 是询问发出的时刻——截止与兜底都按它算,而不是按"面板弹出来的时刻"算:
|
||||
// 面板可能排在审批面板后面,白送几分钟等于把宽限期当等待期烧掉。
|
||||
func (c *AskClock) Started() time.Time {
|
||||
if c == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return c.started
|
||||
}
|
||||
|
||||
// Phase 返回 now 时刻处于哪个显示阶段。
|
||||
func (c *AskClock) Phase(now time.Time) AskPhase {
|
||||
if c == nil || c.Paused() || c.timeout <= 0 {
|
||||
return AskPhaseWaiting
|
||||
}
|
||||
elapsed := now.Sub(c.started)
|
||||
switch {
|
||||
case elapsed >= c.timeout:
|
||||
return AskPhaseExpired
|
||||
case elapsed >= c.grace:
|
||||
return AskPhaseCountdown
|
||||
default:
|
||||
return AskPhaseHidden
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining 返回距自动结束还剩多久;永不超时或已被用户暂停返回 0
|
||||
// (宿主据此连"剩余几秒"都不该显示)。
|
||||
func (c *AskClock) Remaining(now time.Time) time.Duration {
|
||||
if c == nil || c.timeout <= 0 || c.Paused() {
|
||||
return 0
|
||||
}
|
||||
left := c.timeout - now.Sub(c.started)
|
||||
if left < 0 {
|
||||
left = 0
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
var askSeq atomic.Int64
|
||||
|
||||
// prepareAsk 填好一次询问的身份与时钟,返回**调用方要继续用的那一份**。
|
||||
// 必须返回而不是原地改:AskRequest 按值传递,宿主看到的、渲染文案看到的、
|
||||
// 时钟所在的就是这一个值。("No answer within 0s"是这里的返回值被忽略后
|
||||
// 渲染到一份 Clock==nil 的 req 上产生的。)
|
||||
func (a *Agent) prepareAsk(req AskRequest) AskRequest {
|
||||
if req.ID == "" {
|
||||
req.ID = fmt.Sprintf("ask-%d-%d", time.Now().UnixMilli(), askSeq.Add(1))
|
||||
}
|
||||
if req.SessionID == "" {
|
||||
req.SessionID = a.sessionID()
|
||||
}
|
||||
if req.Clock == nil {
|
||||
req.Clock = a.newAskClock()
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// requestAsk 发起一次提问:交给宿主呈现,再把答案归一。
|
||||
//
|
||||
// Opt.Ask 为空 = 这个入口没有到人的交互通道,走退化路径。与 Approval 一样,
|
||||
// 回调**没有 ctx、没有超时**:宿主不返回就会一直阻塞那条工具 goroutine,
|
||||
// 所以接入方必须自己保证会结束(TUI 用时钟、WebUI 用 ctx 与订阅者计数)。
|
||||
func (a *Agent) requestAsk(req AskRequest) AskAnswer {
|
||||
req = a.prepareAsk(req)
|
||||
if a.Opt.Ask == nil {
|
||||
return a.settleAsk(req, a.degradeAsk(req))
|
||||
}
|
||||
return a.settleAsk(req, a.Opt.Ask(req))
|
||||
}
|
||||
|
||||
// degradeAsk 是没有人可答时的落点。推荐项不在这里挑——统一交给 settleAsk,
|
||||
// 否则"宿主报回 unattended"和"agent 自己退化"两条路会对推荐项有不同处理。
|
||||
func (a *Agent) degradeAsk(req AskRequest) AskAnswer {
|
||||
if a.AskDegradation != AskDegradeUnattended {
|
||||
return AskAnswer{Mode: Deferred}
|
||||
}
|
||||
return AskAnswer{Mode: Unattended}
|
||||
}
|
||||
|
||||
// settleAsk 归一宿主返回的答案:补全到点后自动采纳、安排 Deferred 的收尾、记账。
|
||||
func (a *Agent) settleAsk(req AskRequest, ans AskAnswer) AskAnswer {
|
||||
if ans.Mode == "" {
|
||||
ans.Mode = Answered
|
||||
}
|
||||
if !hasPicks(ans.Selected) {
|
||||
// 「到点了/没人可答」到底怎么办,是配置与 agent 的事,不是宿主的事:
|
||||
// TUI 与 WebUI 各自实现一遍,两个界面的自动行为迟早分叉。
|
||||
switch ans.Mode {
|
||||
case TimedOut:
|
||||
// AskAutoContinue=false 时不替用户决定,改为按 Deferred 收尾。
|
||||
if a.AskAutoContinue {
|
||||
picks, _ := recommendedPicks(req)
|
||||
ans.Selected, ans.Notes = picks, nil
|
||||
} else {
|
||||
ans.Mode = Deferred
|
||||
}
|
||||
case Unattended:
|
||||
// 无人值守(-p / 定时任务)不看 AskAutoContinue:那里结束回合等于任务
|
||||
// 原地不动,而自动采纳至少会把假设写进回复里,用户事后能推翻。
|
||||
picks, _ := recommendedPicks(req)
|
||||
ans.Selected = picks
|
||||
}
|
||||
}
|
||||
if ans.Mode == Deferred {
|
||||
a.setPendingAsk(renderAskQuestion(req))
|
||||
}
|
||||
a.noteAsk(ans.Mode)
|
||||
return ans
|
||||
}
|
||||
|
||||
// newAskClock 按本 agent 的配置造一次询问的时钟。
|
||||
func (a *Agent) newAskClock() *AskClock {
|
||||
return NewAskClock(
|
||||
time.Duration(a.AskGraceSeconds)*time.Second,
|
||||
time.Duration(a.AskTimeoutSeconds)*time.Second,
|
||||
)
|
||||
}
|
||||
|
||||
// recommendedPicks 取每题的推荐项:Recommended<0 的题返回空选择(没人可答时
|
||||
// 不能替用户决定他没有给出推荐的事)。any 报告是否至少有一题能自动落。
|
||||
func recommendedPicks(req AskRequest) (picks [][]string, any bool) {
|
||||
picks = make([][]string, len(req.Questions))
|
||||
for i, q := range req.Questions {
|
||||
if q.Recommended >= 0 && q.Recommended < len(q.Options) {
|
||||
picks[i] = []string{q.Options[q.Recommended].Label}
|
||||
any = true
|
||||
}
|
||||
}
|
||||
return picks, any
|
||||
}
|
||||
|
||||
func hasPicks(sel [][]string) bool {
|
||||
for _, s := range sel {
|
||||
if len(s) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// askTimeoutSeconds 供文案里报出「等了多久」。时钟缺席(手工构造的 AskRequest,
|
||||
// 测试与宿主回执常见)返回 0,句子仍成立。
|
||||
func askTimeoutSeconds(req AskRequest) int {
|
||||
if req.Clock == nil {
|
||||
return 0
|
||||
}
|
||||
return int(req.Clock.Timeout() / time.Second)
|
||||
}
|
||||
|
||||
// noteAsk 记本回合的提问账:问过几次、其中几次没等到人回答却继续干了、连续几次没人可答。
|
||||
// 口径注意:askAuto 数的是"没人回答但活继续了"(超时采纳推荐项、超时后自行判断、
|
||||
// 无人值守两种都算),不是"代码替人挑了一项"——从用户视角看这两者都是"它没等我就往下走了"。
|
||||
// deferred 不计,因为它把回合停在问题上了;declined 也不计,那来自一个在场的人。
|
||||
func (a *Agent) noteAsk(mode AnswerMode) {
|
||||
a.askMu.Lock()
|
||||
defer a.askMu.Unlock()
|
||||
a.asked++
|
||||
switch mode {
|
||||
case TimedOut, Unattended:
|
||||
a.askAuto++
|
||||
a.askDegraded++
|
||||
case Deferred:
|
||||
a.askDegraded++
|
||||
default:
|
||||
a.askDegraded = 0
|
||||
}
|
||||
}
|
||||
|
||||
// askBlocked 报告 ask_user 是否已被停用(本回合连续退化到上限)。
|
||||
func (a *Agent) askBlocked() bool {
|
||||
a.askMu.Lock()
|
||||
defer a.askMu.Unlock()
|
||||
return a.askDegraded >= askDegradeLimit
|
||||
}
|
||||
|
||||
// askCounts 返回本回合的提问数与其中自动落定的次数(复盘记账用)。
|
||||
func (a *Agent) askCounts() (asked, auto int) {
|
||||
a.askMu.Lock()
|
||||
defer a.askMu.Unlock()
|
||||
return a.asked, a.askAuto
|
||||
}
|
||||
|
||||
// resetAskTurn 在每个回合开始时清零(Run 里调)。按回合而不是按 agent 清零是刻意的:
|
||||
// 无人值守的长跑里「别再问了」应当生效于这一次任务,而不是一劳永逸地把工具关掉——
|
||||
// 用户回来之后(新回合)提问能力就该恢复。
|
||||
func (a *Agent) resetAskTurn() {
|
||||
a.askMu.Lock()
|
||||
defer a.askMu.Unlock()
|
||||
a.asked, a.askAuto, a.askDegraded = 0, 0, 0
|
||||
a.pendingAsk = ""
|
||||
}
|
||||
|
||||
// setPendingAsk / takePendingAsk 传递「这一回合该以问题收尾」。
|
||||
// 工具在 execOne 里跑(step 的下游),step 回填完 tool_result 后取用它。
|
||||
func (a *Agent) setPendingAsk(text string) {
|
||||
a.askMu.Lock()
|
||||
a.pendingAsk = text
|
||||
a.askMu.Unlock()
|
||||
}
|
||||
|
||||
func (a *Agent) takePendingAsk() string {
|
||||
a.askMu.Lock()
|
||||
defer a.askMu.Unlock()
|
||||
t := a.pendingAsk
|
||||
a.pendingAsk = ""
|
||||
return t
|
||||
}
|
||||
|
||||
func (a *Agent) sessionID() string {
|
||||
if a.Session == nil || a.Session.Path == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(filepath.Base(a.Session.Path), ".jsonl")
|
||||
}
|
||||
|
||||
// renderAskAnswer 是回给模型的 tool_result。脚手架用英文(与本仓库其余工具结果
|
||||
// 一致),选项与问题原文是用户语言,照原样回。
|
||||
func renderAskAnswer(req AskRequest, ans AskAnswer) string {
|
||||
var b strings.Builder
|
||||
switch ans.Mode {
|
||||
case Answered:
|
||||
b.WriteString("The user answered:\n")
|
||||
case TimedOut:
|
||||
if hasPicks(ans.Selected) {
|
||||
fmt.Fprintf(&b, "No answer within %ds, so this was auto-continued with the recommended option(s). "+
|
||||
"Say in your reply which choice was made without asking, and that the user can override it.\n",
|
||||
askTimeoutSeconds(req))
|
||||
} else {
|
||||
b.WriteString("Nobody answered in time and no option was marked as recommended. ")
|
||||
b.WriteString("Decide for yourself, state the assumption you adopted, and do not call ask_user again for this decision.\n")
|
||||
}
|
||||
case Unattended:
|
||||
if hasPicks(ans.Selected) {
|
||||
b.WriteString("There is no one to ask in this run (headless or scheduled), so the recommended option(s) ")
|
||||
b.WriteString("were adopted automatically. State that assumption in your reply; the user can override it later.\n")
|
||||
} else {
|
||||
b.WriteString("There is no one to ask in this run (headless or scheduled) and no option was marked as ")
|
||||
b.WriteString("recommended. Decide for yourself, state the assumption you adopted, and do not ask again.\n")
|
||||
}
|
||||
case Deferred:
|
||||
b.WriteString("The question was delivered to the user as this turn's reply, and the turn ends here. ")
|
||||
b.WriteString("Do not continue working or pick a direction — wait for their answer in the next turn.\n")
|
||||
case Declined:
|
||||
b.WriteString("The user closed the question without answering. Do not ask again about this decision. ")
|
||||
b.WriteString("Choose a direction yourself and say which assumption you adopted.\n")
|
||||
}
|
||||
for i, q := range req.Questions {
|
||||
var sel []string
|
||||
if i < len(ans.Selected) {
|
||||
sel = ans.Selected[i]
|
||||
}
|
||||
fmt.Fprintf(&b, "- [%s] %s => ", q.Header, q.Question)
|
||||
if len(sel) == 0 {
|
||||
b.WriteString("(no choice)")
|
||||
} else {
|
||||
b.WriteString(strings.Join(sel, " + "))
|
||||
}
|
||||
if i < len(ans.Notes) && strings.TrimSpace(ans.Notes[i]) != "" {
|
||||
fmt.Fprintf(&b, " | note: %s", strings.TrimSpace(ans.Notes[i]))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// renderAskQuestion 把提问渲染成给人看的文字(退化到渠道/TUI 无面板时用)。
|
||||
// 这段是**本轮最终回复**,直接面向用户,所以用用户的界面语言(中文脚手架)。
|
||||
func renderAskQuestion(req AskRequest) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("需要你定一下(回复序号,或直接说你的想法):\n")
|
||||
for i, q := range req.Questions {
|
||||
fmt.Fprintf(&b, "\n%d. %s", i+1, q.Question)
|
||||
if q.MultiSelect {
|
||||
b.WriteString("(可多选,如 1,3)")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
for j, o := range q.Options {
|
||||
if o.Description == "" {
|
||||
fmt.Fprintf(&b, " %d) %s\n", j+1, o.Label)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, " %d) %s — %s\n", j+1, o.Label, o.Description)
|
||||
}
|
||||
if q.Recommended >= 0 && q.Recommended < len(q.Options) {
|
||||
fmt.Fprintf(&b, " 我的倾向:%d) %s\n", q.Recommended+1, q.Options[q.Recommended].Label)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// askBlockedNotice 是停用后的回执:不报错(那会算一次工具失败并触发换思路提示),
|
||||
// 只把事实讲清楚,让模型自己接着干。
|
||||
const askBlockedNotice = "ask_user is disabled for this turn: nobody answered the last " +
|
||||
"questions (this run has no interactive UI, or it is unattended). Decide yourself, " +
|
||||
"state the assumption you adopted in your reply, and keep going."
|
||||
Reference in new issue
Block a user