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。
446 lines
16 KiB
Go
446 lines
16 KiB
Go
// 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."
|