// Package ask is the question the agent puts to the user in the middle of a // task, rather than at the end of one. // // It is the other half of approval. An approval asks "may I do this thing I // have decided on"; a question asks "which of these should I decide on at // all". Both suspend the agent on a channel until a human answers, and both // reach the front end through a function the front end supplies — a TUI // picker, a line of stdin, or a test — so nothing below this package knows a // terminal exists. // // The shape is fixed rather than free: a handful of questions, a handful of // labelled options each, and a way to type something that was not offered. // A picker that can render anything renders nothing well, and an agent that // can ask anything asks too much. package ask import ( "context" "errors" "fmt" "strings" "sync" "codey/internal/hooks" ) // The limits are the picker's, not the model's: four options fit on a screen // beside their descriptions and can be reached with one digit, and a fifth // question is a conversation the agent should be having in prose. const ( MaxQuestions = 4 MinOptions = 2 MaxOptions = 4 ) // Option is one answer offered for a question. type Option struct { // Label is the answer itself, short enough to sit in a list. Label string // Description is the line under it: what choosing this one means. It may // be empty, though a choice whose consequence is not stated is usually a // choice the user cannot make. Description string } // Question is one multiple-choice question. type Question struct { // Header is a word or two naming the decision, used as the label on the // box. Long headers are truncated when drawn rather than refused. Header string // Question is the question in full, as a sentence. Question string // Options are what is offered, in the order they are offered. Options []Option // MultiSelect allows any number of them to be picked rather than one. MultiSelect bool } // Request is one call of the ask tool: everything the agent wants to know // before it carries on. type Request struct { // Agent is the subagent asking, empty for the session's own agent. The // same field, for the same reason, as approval.Request.Agent: several // agents share one box, and a question that does not say who is asking // cannot be answered. Agent string Questions []Question } // Answer is what came back for one question. Chosen holds option labels in // the order they were offered; Other is text the user typed instead, which is // how a question gets an answer nobody thought to offer. type Answer struct { Header string Question string Chosen []string Other string } // Response is the answer to a whole request. Dismissed says the user closed // the box without answering, which is not an answer and must not be reported // to the model as one. type Response struct { Answers []Answer Dismissed bool } // Prompt asks a human. Returning an error abandons the call: the tool reports // the error to the model, which is what a cancelled turn looks like from the // inside. type Prompt func(context.Context, Request) (Response, error) // Asker is the indirection between the tool and whatever can reach a human. // The tool is built when the registry is, which is before any front end // exists, so the front end installs itself here afterwards — the same move // approval.Policy makes with its Prompt field. // // The lock is not ceremony: Use runs on the goroutine that starts the program // and Ask runs on the agent's. type Asker struct { mu sync.Mutex prompt Prompt // Hooks publishes the wait itself. A question is the one moment an agent // is neither working nor finished, and from outside the process the two // are indistinguishable — the run has not returned and nothing is being // printed. It lives here rather than around the Prompt a caller installs // because a front end replaces that prompt when it takes over, and a // wrapper put on at startup would be thrown away with it. Hooks *hooks.Hooks } // NewAsker returns an asker with a starting prompt, which may be nil. func NewAsker(p Prompt) *Asker { return &Asker{prompt: p} } // Use installs the prompt in force from now on. func (a *Asker) Use(p Prompt) { a.mu.Lock() defer a.mu.Unlock() a.prompt = p } // Ask implements Prompt by forwarding to whatever is installed. func (a *Asker) Ask(ctx context.Context, req Request) (Response, error) { a.mu.Lock() p := a.prompt a.mu.Unlock() if p == nil { return Response{}, errors.New("nothing in this session can reach the user") } a.Hooks.Fire(ctx, hooks.Payload{ Event: hooks.Blocked, Agent: req.Agent, Reason: hooks.ReasonQuestion, Message: summarize(req), }) // Unblocked on every path out, including the one where the user dismissed // the box or the turn was cancelled underneath it. A watcher told an agent // is waiting and never told otherwise is worse than one never told at all. defer a.Hooks.Fire(ctx, hooks.Payload{ Event: hooks.Unblocked, Agent: req.Agent, Reason: hooks.ReasonQuestion, }) return p(ctx, req) } // summarize is what a bystander is told the agent is waiting to hear about: // the headers of the questions, which name the decisions without carrying the // conversation they came out of. func summarize(req Request) string { headers := make([]string, 0, len(req.Questions)) for _, q := range req.Questions { if h := strings.TrimSpace(q.Header); h != "" { headers = append(headers, h) } } if len(headers) == 0 { return "waiting for an answer" } return "asked about " + strings.Join(headers, ", ") } // Validate checks a request the model built. Every message it returns is read // by the model and has to say what to send instead, because a rejected call // is one the model gets to make again. func Validate(r Request) error { switch { case len(r.Questions) == 0: return fmt.Errorf("no questions: send between 1 and %d", MaxQuestions) case len(r.Questions) > MaxQuestions: return fmt.Errorf("%d questions is too many: ask at most %d in one call, and ask the rest after these are answered", len(r.Questions), MaxQuestions) } for i, q := range r.Questions { where := fmt.Sprintf("question %d", i+1) if strings.TrimSpace(q.Question) == "" { return fmt.Errorf("%s has no text: say what is being decided", where) } if strings.TrimSpace(q.Header) == "" { return fmt.Errorf("%s (%q) has no header: give it a word or two naming the decision", where, q.Question) } switch { case len(q.Options) < MinOptions: return fmt.Errorf("%s (%q) offers %d options: offer at least %d, or do not ask — a question with one answer is a statement", where, q.Header, len(q.Options), MinOptions) case len(q.Options) > MaxOptions: return fmt.Errorf("%s (%q) offers %d options: offer at most %d, and fold the rest into the ones that matter — the user can always type something else", where, q.Header, len(q.Options), MaxOptions) } seen := map[string]bool{} for j, o := range q.Options { label := strings.TrimSpace(o.Label) if label == "" { return fmt.Errorf("%s (%q) option %d has no label", where, q.Header, j+1) } if seen[strings.ToLower(label)] { return fmt.Errorf("%s (%q) offers %q twice: an answer has to name one option", where, q.Header, label) } seen[strings.ToLower(label)] = true } } return nil } // Format renders a response as the tool result. It repeats each question // beside its answer: the model reads this many turns later, by which time // "Postgres" on its own has stopped meaning anything. func (r Response) Format() string { if len(r.Answers) == 0 { return "the user answered nothing" } var b strings.Builder b.WriteString("The user answered:\n") for _, a := range r.Answers { fmt.Fprintf(&b, "\n%s — %s\n", a.Header, a.Question) switch { case a.Other != "": // Their own words are marked as such: the agent offered three // options and none of them is what it is about to act on. fmt.Fprintf(&b, " in their own words: %s\n", a.Other) case len(a.Chosen) == 0: b.WriteString(" (skipped)\n") default: fmt.Fprintf(&b, " %s\n", strings.Join(a.Chosen, ", ")) } } return strings.TrimRight(b.String(), "\n") }