Vectrum
Sign In
118 lines · 5.2 KB · Go
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)
	}
}