// ask_user 工具本体。注册在 agent 侧而不是 tools 包:它要的是 agent 的交互通道 // (Options.Ask、退化策略、回合收尾),和 newSubAgentTool 闭包持有 *Agent 同理—— // tools.Registry 里没有到人的路。 package agent import ( "context" "encoding/json" "fmt" "strings" "git.gopher.ink/mirrors2/AutoZero/tools" ) // 提问形状的上限。与 Claude Code 的 AskUserQuestion 对齐(1-4 题、每题 2-4 项、 // 标签 ≤12 字),也和一个界面上能塞下多少东西对齐:超过 4 题人就开始跳过问题看了。 const ( minAskQuestions = 1 maxAskQuestions = 4 minAskOptions = 2 maxAskOptions = 4 maxAskHeaderRunes = 12 // noRecommendation 是模型显式表达「这题没有推荐项」。 noRecommendation = -1 ) type askInput struct { Questions []AskQuestion `json:"questions"` } // AskUserTool 构造 "ask_user"。 func (a *Agent) AskUserTool() *tools.Tool { return newAskUserTool(a) } func newAskUserTool(a *Agent) *tools.Tool { desc := "Ask the user to decide a fork that only they can decide, instead of guessing. " + "Use it for taste/scope/cost/risk tradeoffs (quality vs. size, how far a destructive " + "change reaches, which of several valid architectures), never for 'is my plan OK?' " + "or facts you could probe with another tool. " + "Send 1-4 questions at once, each with 2-4 mutually describable options; every option " + "needs a label plus a one-line description of the tradeoff it implies. Set `recommended` " + "to the option index you would pick (or -1 if there is no good default) — unattended runs " + "adopt it automatically, so leaving it out means the model decides alone. " + "The UI adds a free-text escape hatch itself; do not invent an 'Other' option. " + "In headless/scheduled runs nobody can answer: the tool says so, and after two such " + "answers it stops working for the rest of the turn." return &tools.Tool{ Name: "ask_user", Description: desc, // SideEffect=false:它不改现实世界,只是等一句话。 // Kind=ask 让门控认出它并跳过审批与危险动作判定(见 execOne)。 // ConcurrencySafe 留空(false):提问必须串行——两个并行面板抢一个人回答是幻觉。 SideEffect: false, ReadOnly: true, Kind: tools.KindAsk, Parameters: map[string]any{ "type": "object", "required": []string{"questions"}, "properties": map[string]any{ "questions": map[string]any{ "type": "array", "description": fmt.Sprintf("%d-%d questions to settle in one round", minAskQuestions, maxAskQuestions), "items": map[string]any{ "type": "object", "required": []string{"header", "question", "options", "recommended"}, "properties": map[string]any{ "header": map[string]any{ "type": "string", "description": fmt.Sprintf("Short tag shown beside the question (<= %d characters)", maxAskHeaderRunes), }, "question": map[string]any{ "type": "string", "description": "The full question, specific enough to answer without rereading the code", }, "multi_select": map[string]any{ "type": "boolean", "description": "Allow several options to be chosen (default false = one)", }, "options": map[string]any{ "type": "array", "description": fmt.Sprintf("%d-%d distinct options", minAskOptions, maxAskOptions), "items": map[string]any{ "type": "object", "required": []string{"label"}, "properties": map[string]any{ "label": map[string]any{ "type": "string", "description": "What gets chosen, few words", }, "description": map[string]any{ "type": "string", "description": "The tradeoff this option implies (cost, quality, risk)", }, }, }, }, "recommended": map[string]any{ "type": "integer", "description": "Index of the option to pick by default; -1 when there is no good default", }, }, }, }, }, }, Run: func(_ context.Context, input string) (tools.Result, error) { var in askInput // 参数名由 schema 写死。猜错了就让它报错纠正一次,不在这兜一个没定义的形状。 if err := json.Unmarshal([]byte(input), &in); err != nil { return tools.Result{}, fmt.Errorf("ask_user: invalid input: %w", err) } req, err := validateAsk(in.Questions) if err != nil { return tools.Result{}, err } if a.askBlocked() { return tools.TextResult(askBlockedNotice), nil } req = a.prepareAsk(req) return tools.TextResult(renderAskAnswer(req, a.requestAsk(req))), nil }, } } // validateAsk 硬校验 schema 并归一(首尾空白、超长的 header 截断)。 // 返回的 AskRequest 是渲染与答案回填的唯一依据,所以归一在这里做完。 func validateAsk(qs []AskQuestion) (AskRequest, error) { req := AskRequest{Questions: qs} if len(qs) < minAskQuestions || len(qs) > maxAskQuestions { return req, fmt.Errorf("ask_user: needs %d-%d questions, got %d", minAskQuestions, maxAskQuestions, len(qs)) } for i := range req.Questions { q := &req.Questions[i] q.Header = strings.TrimSpace(q.Header) q.Question = strings.TrimSpace(q.Question) if len([]rune(q.Header)) > maxAskHeaderRunes { q.Header = string([]rune(q.Header)[:maxAskHeaderRunes]) } if q.Header == "" { return req, fmt.Errorf("ask_user: question %d has an empty header", i+1) } if q.Question == "" { return req, fmt.Errorf("ask_user: question %d (%s) has empty text", i+1, q.Header) } if len(q.Options) < minAskOptions || len(q.Options) > maxAskOptions { return req, fmt.Errorf("ask_user: question %d (%s) needs %d-%d options, got %d", i+1, q.Header, minAskOptions, maxAskOptions, len(q.Options)) } seen := map[string]bool{} for j := range q.Options { o := &q.Options[j] o.Label = strings.TrimSpace(o.Label) o.Description = strings.TrimSpace(o.Description) if o.Label == "" { return req, fmt.Errorf("ask_user: question %d option %d has an empty label", i+1, j+1) } if seen[o.Label] { return req, fmt.Errorf("ask_user: question %d has duplicate option %q", i+1, o.Label) } seen[o.Label] = true } if q.Recommended < noRecommendation || q.Recommended >= len(q.Options) { return req, fmt.Errorf("ask_user: question %d (%s) has recommended=%d, valid range is -1 (no default) to %d", i+1, q.Header, q.Recommended, len(q.Options)-1) } } return req, nil }