// 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."