// 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"
)
// 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
}
// 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")
}
return p(ctx, req)
}
// 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")
}