1package tools
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "strings"
11 "unicode/utf8"
12
13 "github.com/bnema/zut/packages/core"
14 "github.com/bnema/zut/packages/provider"
15)
16
17const AskUserQuestionToolName = "ask_user_question"
18
19type Question struct {
20 Header string `json:"header"`
21 Question string `json:"question"`
22 Options []QuestionOption `json:"options"`
23 MultiSelect bool `json:"multi_select"`
24}
25type QuestionOption struct {
26 Label string `json:"label"`
27 Description string `json:"description"`
28}
29type QuestionAnswer struct {
30 Selected []string
31 Custom string
32}
33
34var ErrQuestionsDeclined = errors.New("user declined to answer")
35
36type QuestionAsker interface {
37 AskQuestions(context.Context, []Question) ([]QuestionAnswer, error)
38}
39type AskUserQuestionTool struct{ Asker func() QuestionAsker }
40
41func (*AskUserQuestionTool) Name() string { return AskUserQuestionToolName }
42func (*AskUserQuestionTool) Interactive() bool { return true }
43func (*AskUserQuestionTool) Description() string {
44 return "Ask the user up to 10 structured questions in an interactive terminal. Offer up to 9 options per question, or omit options for free text. The user can always provide a custom answer or decline."
45}
46func (*AskUserQuestionTool) Schema() json.RawMessage {
47 return json.RawMessage(`{"type":"object","properties":{"questions":{"type":"array","minItems":1,"maxItems":10,"items":{"type":"object","properties":{"header":{"type":"string","maxLength":16},"question":{"type":"string","minLength":1},"options":{"type":"array","maxItems":9,"items":{"type":"object","properties":{"label":{"type":"string","minLength":1},"description":{"type":"string"}},"required":["label"],"additionalProperties":false}},"multi_select":{"type":"boolean"}},"required":["question"],"additionalProperties":false}}},"required":["questions"],"additionalProperties":false}`)
48}
49func (t *AskUserQuestionTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
50 fail := func(s string) (core.ToolResult, error) {
51 return core.ToolResult{IsError: true, Content: []provider.Content{provider.TextBlock{Text: s}}}, nil
52 }
53 // Null is not a valid value for any field in this schema. encoding/json
54 // otherwise silently accepts null for strings and booleans.
55 var shape any
56 if err := json.Unmarshal(raw, &shape); err == nil && questionContainsNull(shape) {
57 return fail("Invalid questions: null fields are not allowed")
58 }
59 var args struct {
60 Questions []Question `json:"questions"`
61 }
62 d := json.NewDecoder(bytes.NewReader(raw))
63 d.DisallowUnknownFields()
64 if err := d.Decode(&args); err != nil {
65 return fail("Invalid questions: " + err.Error())
66 }
67 if d.Decode(&struct{}{}) != io.EOF {
68 return fail("Invalid questions: expected one JSON object")
69 }
70 if len(args.Questions) < 1 || len(args.Questions) > 10 {
71 return fail("questions must contain 1 to 10 questions")
72 }
73 for _, q := range args.Questions {
74 if strings.TrimSpace(q.Question) == "" || utf8.RuneCountInString(q.Header) > 16 || len(q.Options) > 9 {
75 return fail("Each question needs text, a header of at most 16 characters, and at most 9 options")
76 }
77 seen := map[string]bool{}
78 for _, o := range q.Options {
79 label := strings.TrimSpace(o.Label)
80 if label == "" || seen[label] {
81 return fail("Option labels must be non-empty and unique")
82 }
83 seen[label] = true
84 }
85 }
86 if err := ctx.Err(); err != nil {
87 return core.ToolResult{}, err
88 }
89 var asker QuestionAsker
90 if t.Asker != nil {
91 asker = t.Asker()
92 }
93 if asker == nil {
94 return fail("ask_user_question needs an interactive terminal session")
95 }
96 answers, err := asker.AskQuestions(ctx, args.Questions)
97 if ctx.Err() != nil {
98 return core.ToolResult{}, ctx.Err()
99 }
100 if errors.Is(err, ErrQuestionsDeclined) {
101 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "The user declined to answer the questions."}}}, nil
102 }
103 if err != nil {
104 return fail(err.Error())
105 }
106 if len(answers) != len(args.Questions) {
107 return fail("Interactive host returned an incomplete answer set")
108 }
109 var b strings.Builder
110 for n, q := range args.Questions {
111 a := answers[n]
112 text := strings.Join(a.Selected, ", ")
113 if a.Custom != "" {
114 text = a.Custom + " (custom)"
115 }
116 fmt.Fprintf(&b, "Q: %s\nA: %s\n\n", q.Question, text)
117 }
118 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: strings.TrimSpace(b.String())}}}, nil
119}
120
121func questionContainsNull(v any) bool {
122 if v == nil {
123 return true
124 }
125 switch x := v.(type) {
126 case map[string]any:
127 for _, child := range x {
128 if questionContainsNull(child) {
129 return true
130 }
131 }
132 case []any:
133 for _, child := range x {
134 if questionContainsNull(child) {
135 return true
136 }
137 }
138 }
139 return false
140}