返回 DeepSeek-Reasonix
ask.go
根目录 / internal / agent / ask.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8
9 "reasonix/internal/event"
10 )
11
12 // AskTool lets the model put a structured multiple-choice question (or a few) to
13 // the user mid-task and get the answer back — for genuine forks the model can't
14 // resolve from the request or the code (which library, which approach, …) rather
15 // than guessing or asking in prose. The frontend renders selectable options, the
16 // user picks, and the choices come back as the tool result. It reaches the user
17 // through the Asker carried on the call
18 // context (CallContext); with no asker (headless runs) it returns an explicit
19 // model-assumption fallback so an autonomous run never blocks or pretends a user
20 // answered.
21 type AskTool struct{}
22
23 func NewAskTool() *AskTool { return &AskTool{} }
24
25 func (*AskTool) Name() string { return "ask" }
26
27 func (*AskTool) Description() string {
28 return "Ask the user one or more multiple-choice questions when you hit a decision that is genuinely theirs to make — one you can't resolve from the request, the code, or sensible defaults. The frontend shows the options for the user to pick; their choices are returned to you. Prefer this over asking in prose for any real fork (which approach, which library, scope). Don't use it for decisions with an obvious default — pick the sensible option and proceed. Tool-approval modes such as YOLO do not answer these questions for the user. Each question has a short `header` (a tab label), the `question` text, 2-4 `options` (each a `label` and optional `description`; put any recommended option first), and `multiSelect` when more than one may apply."
29 }
30
31 func (*AskTool) Schema() json.RawMessage {
32 return json.RawMessage(`{
33 "type":"object",
34 "properties":{
35 "questions":{
36 "type":"array",
37 "minItems":1,
38 "maxItems":4,
39 "description":"1-4 questions to ask together.",
40 "items":{
41 "type":"object",
42 "properties":{
43 "header":{"type":"string","description":"Very short label for the question (a tab title), e.g. \"Library\"."},
44 "question":{"type":"string","description":"The full question to ask."},
45 "options":{
46 "type":"array","minItems":2,"maxItems":4,
47 "description":"The choices. Put any recommended option first.",
48 "items":{
49 "type":"object",
50 "properties":{
51 "label":{"type":"string","description":"The choice text (concise)."},
52 "description":{"type":"string","description":"Optional one-line explanation of the choice."}
53 },
54 "required":["label"]
55 }
56 },
57 "multiSelect":{"type":"boolean","description":"Allow selecting more than one option."}
58 },
59 "required":["question","header","options"]
60 }
61 }
62 },
63 "required":["questions"]
64 }`)
65 }
66
67 // ReadOnly is true: asking has no host side effects, so it never needs approval
68 // and stays available in plan mode (clarifying scope while planning is fine).
69 func (*AskTool) ReadOnly() bool { return true }
70
71 func (*AskTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
72 var p struct {
73 Questions []struct {
74 Header string `json:"header"`
75 Question string `json:"question"`
76 MultiSelect bool `json:"multiSelect"`
77 Options []struct {
78 Label string `json:"label"`
79 Description string `json:"description"`
80 } `json:"options"`
81 } `json:"questions"`
82 }
83 if err := json.Unmarshal(args, &p); err != nil {
84 return "", fmt.Errorf("invalid args: %w", err)
85 }
86 if len(p.Questions) == 0 {
87 return "", fmt.Errorf("at least one question is required")
88 }
89
90 qs := make([]event.AskQuestion, 0, len(p.Questions))
91 for i, q := range p.Questions {
92 question := strings.TrimSpace(q.Question)
93 if question == "" || len(q.Options) < 2 {
94 return "", fmt.Errorf("question %d: a question and at least two options are required", i+1)
95 }
96 opts := make([]event.AskOption, len(q.Options))
97 seenLabels := make(map[string]int, len(q.Options))
98 for j, o := range q.Options {
99 label := strings.TrimSpace(o.Label)
100 if label == "" {
101 return "", fmt.Errorf("question %d option %d: label is required", i+1, j+1)
102 }
103 if prev, ok := seenLabels[label]; ok {
104 return "", fmt.Errorf("question %d option %d: duplicate label %q also used by option %d", i+1, j+1, label, prev+1)
105 }
106 seenLabels[label] = j
107 opts[j] = event.AskOption{Label: label, Description: strings.TrimSpace(o.Description)}
108 }
109 qs = append(qs, event.AskQuestion{
110 ID: fmt.Sprintf("q%d", i+1),
111 Header: strings.TrimSpace(q.Header),
112 Prompt: question,
113 Options: opts,
114 Multi: q.MultiSelect,
115 })
116 }
117
118 _, _, asker, ok := CallContext(ctx)
119 if !ok || asker == nil {
120 // Headless / no interactive user: don't block an autonomous run, but make
121 // the provenance explicit so the model doesn't treat this as a user choice.
122 return "No interactive user answered. This is a model-assumption fallback, not a user answer. Proceed with your best judgment, state the assumption you made, and prefer the safest reversible option when choices differ in risk.", nil
123 }
124
125 answers, err := asker.Ask(ctx, qs)
126 if err != nil {
127 return "", fmt.Errorf("ask: %w", err)
128 }
129 return formatAnswers(qs, answers), nil
130 }
131
132 // formatAnswers renders the user's selections as a compact, model-facing summary,
133 // keyed by question header so the model can tell which answer is which. When the
134 // user picked nothing at all (the "just chat" / dismiss path), it returns an
135 // explicit stop signal instead of a per-question "(no answer)" — otherwise the
136 // model reads the empty result as license to proceed and acts unasked.
137 func formatAnswers(qs []event.AskQuestion, answers []event.AskAnswer) string {
138 pick := make(map[string][]string, len(answers))
139 for _, a := range answers {
140 pick[a.QuestionID] = a.Selected
141 }
142 answered := 0
143 for _, q := range qs {
144 if len(pick[q.ID]) > 0 {
145 answered++
146 }
147 }
148 if answered == 0 {
149 return "The user dismissed the question without choosing — read this as \"don't decide for me, let's just talk.\" Do not pick an option, run a tool, or take any further action toward this; stop and wait for the user's next message."
150 }
151 var b strings.Builder
152 b.WriteString("The user answered:\n")
153 for _, q := range qs {
154 sel := pick[q.ID]
155 label := q.Header
156 if label == "" {
157 label = q.Prompt
158 }
159 if len(sel) == 0 {
160 fmt.Fprintf(&b, "- %s: (left unanswered — don't assume a choice)\n", label)
161 continue
162 }
163 fmt.Fprintf(&b, "- %s: %s\n", label, strings.Join(sel, ", "))
164 }
165 return strings.TrimRight(b.String(), "\n")
166 }
167
167 lines GO