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

170 lines
6.5 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.
// 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
}