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
@@ -0,0 +1,169 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user