package ask import ( "context" "errors" "fmt" "codey/internal/tools" ) // Name is the tool the model calls. It is snake_case like every other tool // here rather than Claude Code's AskUserQuestion, because a model picks a tool // by its description and the rest of this registry is named this way. const Name = "ask_user" // Description is deliberately more prohibition than invitation. A model that // enjoys asking questions turns a five-minute task into a quiz, and the whole // value of the tool is that a question means something when it arrives. const Description = `Ask the user a multiple-choice question and wait for the answer. Use this only when you are blocked on a decision that is genuinely theirs to make: one you cannot settle from the request, from the code in front of you, or from an obvious default. Ambiguity you can resolve by reading another file is not a question — go and read the file. A preference about scope, risk, naming, or which of several defensible designs they want is. Ask at the point the decision is needed, not after you have built one of the answers. Ask everything you need in one call rather than stopping the user three times, and then carry on: do not ask and then ask again about the same thing in different words. Each question needs a short header naming the decision, the question itself, and two to four options. Give every option a description saying what choosing it means — the consequence, not a restatement of the label. Make the options mutually exclusive, and put the one you would pick first with "(recommended)" at the end of its label when you genuinely have a view. Set multi_select when several answers can be true at once. The user can always type an answer you did not offer, so do not add an "Other" option yourself. They can also dismiss the question, which comes back as an error: when it does, pick the most sensible option, say which you picked, and keep going.` // Tool builds the ask tool. prompt is how it reaches a human; a nil prompt // means nothing can answer, and the caller should not register the tool at // all rather than offering the model a tool that always fails. func Tool(prompt Prompt) tools.Tool { return tools.New(Name, Description, func(ctx context.Context, in args) (string, error) { if prompt == nil { return "", errors.New("there is no way to reach the user from this session. Decide it yourself and say what you assumed") } req := in.request() if err := Validate(req); err != nil { return "", err } resp, err := prompt(ctx, req) if err != nil { return "", err } if resp.Dismissed { return "", errors.New("the user dismissed the question without answering. Do not ask it again: pick the option you think best, say which you picked and why, and carry on") } if len(resp.Answers) == 0 { return "", errors.New("the user answered nothing. Pick the option you think best, say so, and carry on") } return resp.Format(), nil }) } // args is the wire shape. The schema is derived from these tags, so the // description a model reads for each field lives here and nowhere else. type args struct { Questions []question `json:"questions" jsonschema_description:"The questions to put to the user, 1 to 4 of them. They are answered one after another in this order."` } type question struct { Header string `json:"header" jsonschema_description:"One or two words naming the decision — 'Storage', 'Error handling'. It labels the box, so keep it under about twelve characters."` Question string `json:"question" jsonschema_description:"The question itself, as a full sentence the user can answer without having read your reasoning."` Options []option `json:"options" jsonschema_description:"The answers offered, 2 to 4 of them. Put the one you recommend first."` MultiSelect bool `json:"multi_select,omitempty" jsonschema_description:"Allow several answers at once. Leave it out for a question with one right answer."` } type option struct { Label string `json:"label" jsonschema_description:"The answer, short enough to read in a list. Add ' (recommended)' to the one you would pick, if you have a view."` Description string `json:"description,omitempty" jsonschema_description:"What choosing this one means: the consequence, not the label again."` } // request converts the wire shape into the domain type. The two are separate // so the JSON field names are free to change without the picker knowing. func (a args) request() Request { req := Request{Questions: make([]Question, 0, len(a.Questions))} for _, q := range a.Questions { out := Question{ Header: q.Header, Question: q.Question, MultiSelect: q.MultiSelect, Options: make([]Option, 0, len(q.Options)), } for _, o := range q.Options { out.Options = append(out.Options, Option{Label: o.Label, Description: o.Description}) } req.Questions = append(req.Questions, out) } return req } // Summarize is the one-line description of a call, for a transcript that has // to say what the agent asked before the answer exists. func Summarize(r Request) string { switch len(r.Questions) { case 0: return "ask the user" case 1: return r.Questions[0].Question default: return fmt.Sprintf("%d questions, starting with %s", len(r.Questions), r.Questions[0].Question) } }