Vectrum
Sign In
189 lines · 6.6 KB · Go
// 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")
}