Twenty-one prompts against the live assistant found one fabricated success,
a model that believed it was 2025, and a describe_garden that was ~450 plop
entries per turn. This is the set of fixes, each traceable to a finding:
- The gardener's LOCAL day travels with the turn (`today` on POST /agent/chat,
sent by the UI like plantedAt) into the system prompt and every dated tool
default. Left to guess, the model dated journal entries a year back; left to
the server, a 9 pm fill landed on UTC's tomorrow.
- describe_garden groups plops by plant — count, where, planted date, days to
maturity — and lists ids only for groups of ≤ 8; list_plantings spells a big
group out on demand and remove_plantings acts on one plant in a bed ("take
the beets out, leave the garlic"), which used to mean 116 single removals.
- New tools: move_planting (keeps the planting date; across beds via the new
MovePlanting, which is why the store's UPDATE now writes object_id),
update_plant, read_history, copy_garden (the "<garden> — <year>" plan
convention). fill_region takes an explicit local rectangle and a seedLotId;
place_planting's radius defaults to one plant (spacing/2) instead of a guess.
- The system prompt states the date and the gardener's units, forbids claiming
a change no tool made, says it cannot undo and points at the Undo button,
asks before clearing beds on an ambiguous sentence, and stops narrating its
own plantings into the journal.
- A mutation aimed at ANOTHER garden inside a turn is recorded under that
garden as its own change set, not filed into the open scope.
- UI: the thread scrolls inside the Assistant panel so the composer stays
put; every tool has a step label; wide tables stay inside the bubble.
Co-Authored-By: Claude Fable 5 <[email protected]>
285 lines
12 KiB
Go
285 lines
12 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
|
)
|
|
|
|
// Loop bounds. These exist so a stuck run terminates, NOT to control spend —
|
|
// pansy is a personal tool and multi-tenant cost control is explicitly not a
|
|
// concern here. A model that gets wedged calling describe_garden forever should
|
|
// stop on its own, and a hung upstream shouldn't hold a connection open all day.
|
|
const (
|
|
maxSteps = 24
|
|
// A turn that legitimately fills several beds does a lot of round trips, so
|
|
// this is generous; it is a backstop, not a budget.
|
|
runTimeout = 4 * time.Minute
|
|
// Successive all-error steps, and identical repeated calls, that end a run.
|
|
maxConsecutiveToolErrors = 4
|
|
maxSameCallRepeats = 3
|
|
)
|
|
|
|
// dateLayout is the YYYY-MM-DD form every date crosses the tool boundary in.
|
|
const dateLayout = "2006-01-02"
|
|
|
|
// Runner drives a model over pansy's toolbox. One per process; Run is safe to
|
|
// call concurrently.
|
|
type Runner struct {
|
|
svc *service.Service
|
|
model llm.Model
|
|
}
|
|
|
|
// NewRunner resolves modelSpec against pansy's registry and returns a Runner, or
|
|
// an error if the assistant can't be offered. Callers should treat an error as
|
|
// "no assistant" rather than a startup failure — an instance with no key must
|
|
// still serve the app.
|
|
//
|
|
// It takes the key and spec explicitly rather than a *config.Config so the same
|
|
// constructor serves both boot (from env) and a runtime settings change (from
|
|
// the DB) — the Runner has no idea which one configured it.
|
|
func NewRunner(svc *service.Service, apiKey, modelSpec string) (*Runner, error) {
|
|
if apiKey == "" {
|
|
return nil, errors.New("agent: not configured")
|
|
}
|
|
// agentmodel.Resolve already rejects an empty/blank spec, so don't duplicate
|
|
// that guard here — one place decides what a valid spec is.
|
|
model, err := agentmodel.Resolve(apiKey, modelSpec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Runner{svc: svc, model: model}, nil
|
|
}
|
|
|
|
// Turn is the outcome of one exchange.
|
|
type Turn struct {
|
|
// Reply is what to show the user.
|
|
Reply string `json:"reply"`
|
|
// ChangeSetID is the change set this turn produced, if it changed anything —
|
|
// the handle the UI needs to offer Undo.
|
|
ChangeSetID *int64 `json:"changeSetId,omitempty"`
|
|
// Steps is how many model round trips it took.
|
|
Steps int `json:"steps"`
|
|
// Truncated is set when the run hit its step cap rather than finishing.
|
|
Truncated bool `json:"truncated,omitempty"`
|
|
}
|
|
|
|
// Run executes one turn against a garden, as actorID, on the day it is where
|
|
// they are.
|
|
//
|
|
// today is the gardener's local date (YYYY-MM-DD) as the client reports it; it
|
|
// goes into the prompt, so the model knows what day it is, and to every tool, so
|
|
// what the turn plants, removes or journals is dated the day the person did it.
|
|
// Empty means "the service's UTC today" — the best a caller with no local clock
|
|
// (a bare API client) can do. The model itself must never be the source of the
|
|
// date: left to guess, the live one stamped a year it remembered from training.
|
|
//
|
|
// The whole turn runs inside ONE change set, so everything the model did undoes
|
|
// together. That is what makes acting without a confirmation prompt defensible.
|
|
// The scope is opened even for a turn that turns out to be a question — a change
|
|
// set with no revisions is never written, so asking costs nothing.
|
|
func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, today string, history []llm.Message, onStep func(agent.Step)) (*Turn, error) {
|
|
message = strings.TrimSpace(message)
|
|
if message == "" {
|
|
return nil, domain.ErrInvalidInput
|
|
}
|
|
today = strings.TrimSpace(today)
|
|
if today == "" {
|
|
today = time.Now().UTC().Format(dateLayout)
|
|
} else if _, err := time.Parse(dateLayout, today); err != nil {
|
|
return nil, fmt.Errorf("%w: today must be a YYYY-MM-DD date", domain.ErrInvalidInput)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, runTimeout)
|
|
defer cancel()
|
|
|
|
garden, err := r.svc.GetGarden(ctx, actorID, gardenID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// An id for this run, stamped on the change set so a row in the history list
|
|
// can be matched to the log lines that produced it. Without it, "the agent
|
|
// did something odd on Tuesday" has no thread back to what it was thinking.
|
|
runID := newRunID()
|
|
slog.Info("agent: run start", "run", runID, "garden", gardenID, "actor", actorID)
|
|
|
|
var (
|
|
result *agent.Result
|
|
runErr error
|
|
truncErr bool
|
|
)
|
|
changeSet, err := r.svc.WithChangeSet(ctx, actorID, gardenID, service.ChangeSetOptions{
|
|
Source: domain.SourceAgent,
|
|
Summary: turnSummary(message),
|
|
AgentRunID: &runID,
|
|
}, func(ctx context.Context) error {
|
|
box := NewToolbox(r.svc, actorID, today)
|
|
a := agent.New(r.model, systemPrompt(garden, today),
|
|
agent.WithMaxSteps(maxSteps),
|
|
agent.WithToolErrorLimits(maxConsecutiveToolErrors, maxSameCallRepeats),
|
|
)
|
|
a.AddToolbox(box)
|
|
|
|
opts := []agent.RunOption{agent.WithHistory(history)}
|
|
if onStep != nil {
|
|
opts = append(opts, agent.OnStep(onStep))
|
|
}
|
|
result, runErr = a.Run(ctx, message, opts...)
|
|
// A run that ran out of steps still DID things, and those things must be
|
|
// recorded and undoable. So the loop-guard errors don't fail the scope —
|
|
// they're reported to the user instead.
|
|
if runErr != nil && isLoopLimit(runErr) {
|
|
truncErr = true
|
|
return nil
|
|
}
|
|
return runErr
|
|
})
|
|
if err != nil {
|
|
// WithChangeSet records whatever committed before failing, so the partial
|
|
// work is undoable even though the turn errored.
|
|
return nil, err
|
|
}
|
|
|
|
turn := &Turn{Truncated: truncErr}
|
|
if changeSet != nil {
|
|
turn.ChangeSetID = &changeSet.ID
|
|
}
|
|
if result != nil {
|
|
turn.Reply = result.Output
|
|
turn.Steps = len(result.Steps)
|
|
}
|
|
if turn.Reply == "" {
|
|
turn.Reply = fallbackReply(turn)
|
|
}
|
|
return turn, nil
|
|
}
|
|
|
|
// isLoopLimit reports whether an error is one of majordomo's loop guards firing
|
|
// rather than a genuine failure. Those runs have a partial result worth keeping.
|
|
func isLoopLimit(err error) bool {
|
|
return errors.Is(err, agent.ErrMaxSteps) || errors.Is(err, agent.ErrToolLoop)
|
|
}
|
|
|
|
// fallbackReply covers a run that finished with no text — a model that made its
|
|
// last tool call and then stopped. Silence reads as a failure, so say what
|
|
// happened.
|
|
func fallbackReply(t *Turn) string {
|
|
switch {
|
|
case t.Truncated:
|
|
return "I stopped partway through — that turned into more steps than I should take in one go. " +
|
|
"Have a look at what changed, and tell me what to do next."
|
|
case t.ChangeSetID != nil:
|
|
return "Done — have a look at the canvas."
|
|
default:
|
|
return "I didn't change anything."
|
|
}
|
|
}
|
|
|
|
// newRunID returns a short random identifier for one run.
|
|
func newRunID() string {
|
|
var b [8]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
// The id is for correlating logs, not for security. A clock-based
|
|
// fallback is worse than random and better than an empty string.
|
|
return fmt.Sprintf("t%d", time.Now().UnixNano())
|
|
}
|
|
return hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// turnSummary is what the history list shows for this turn. The user's own words
|
|
// are the most useful label available, trimmed to fit a list row.
|
|
//
|
|
// Trimmed by RUNES, not bytes: slicing a byte offset would cut a multibyte
|
|
// character in half and store invalid UTF-8 in the summary — which is not a
|
|
// hypothetical for text people type.
|
|
func turnSummary(message string) string {
|
|
const max = 120
|
|
s := strings.Join(strings.Fields(message), " ")
|
|
runes := []rune(s)
|
|
if len(runes) > max {
|
|
s = strings.TrimSpace(string(runes[:max])) + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// systemPrompt gives the model the conventions it cannot infer, the day it is,
|
|
// and the rules of conduct the live instance showed it needs.
|
|
//
|
|
// The compass convention in particular is not guessable: -y is north because
|
|
// screen y grows downward, and a model that assumes otherwise plants the south
|
|
// half when asked for the north one. The date is not guessable either — a model
|
|
// asked to backdate nothing still wrote the year it remembered from training —
|
|
// and the conduct rules each answer a thing the assistant actually did in live
|
|
// testing: reported a change it never made, narrated every planting into the
|
|
// journal, swapped four beds on an ambiguous sentence, and answered an imperial
|
|
// gardener in centimeters.
|
|
func systemPrompt(g *domain.Garden, today string) string {
|
|
units := "The gardener works in meters and centimeters; answer in those."
|
|
size := fmt.Sprintf("%.0f x %.0f cm", g.WidthCM, g.HeightCM)
|
|
if g.UnitPref == domain.UnitImperial {
|
|
units = "The gardener thinks in feet and inches. Convert what they say before calling a tool " +
|
|
"(1 ft = 30.48 cm, 1 in = 2.54 cm) and answer in feet and inches, never in centimeters."
|
|
size = fmt.Sprintf("%.1f x %.1f ft (%.0f x %.0f cm)", g.WidthCM/30.48, g.HeightCM/30.48, g.WidthCM, g.HeightCM)
|
|
}
|
|
return fmt.Sprintf(`You are pansy's garden assistant. You help plan and edit a real garden by calling tools.
|
|
|
|
The garden you are working on is %q (id %d), %s. Today is %s — the gardener's local date.
|
|
%s
|
|
|
|
Conventions you cannot guess and must not assume:
|
|
- Every measurement a tool takes or returns is in CENTIMETERS.
|
|
- Positions in a garden are centimeters from its top-left corner: x grows east, y grows SOUTH.
|
|
- Inside an object (a bed), positions are relative to that object's CENTER, and -y is NORTH.
|
|
So the north half of a bed is negative y. Getting this backwards plants the wrong end.
|
|
- Objects and plantings are version-guarded. Use the version from describe_garden when editing.
|
|
- Dates are YYYY-MM-DD. Tools date what they plant, remove or journal as today unless you pass
|
|
a date; pass one only when the gardener says it happened on another day.
|
|
|
|
How to work:
|
|
- Start from describe_garden to see what is actually there. Do not guess ids. It groups each
|
|
bed's plantings by plant, with a count, a rough location and the planting date; a group lists
|
|
its plops one by one only when it is small. For the ids of a large group use list_plantings,
|
|
or act on the whole group at once with remove_plantings.
|
|
- Use find_plant to turn a plant name into an id. If it returns several candidates, pick the one
|
|
that matches what the user said, or ask them which they meant.
|
|
- To replant a bed with something else: clear_object, then fill_region with region "all". To take
|
|
one plant out of a mixed bed: remove_plantings. To relocate plants: move_planting, which keeps
|
|
their planting date — do not remove and replant them.
|
|
- fill_region in grid mode lays out individual plants at true spacing, which is what "so I can
|
|
plant from it" means; clump mode is a quick sketch. For an area no compass name describes (a
|
|
middle third, a strip along one edge) give fill_region a rectangle instead of placing plops by hand.
|
|
- A garden named "%s — <year>" is this garden's plan for that year; copy_garden with that name
|
|
makes one. Never use a different real garden as a scratch space.
|
|
- When a tool refuses (for example, the user only has view access to this garden), explain what
|
|
happened in plain words. Do not retry it.
|
|
|
|
How to behave:
|
|
- Only claim what a tool actually did. If a tool failed, or there is no tool for what was asked,
|
|
say so plainly — never describe a change you did not make.
|
|
- You cannot undo. Every reply of yours that changed the garden has an "Undo this" button under
|
|
it, and the History panel can revert any change; point the gardener there, or offer to reverse
|
|
the change by hand with tools.
|
|
- When a request could mean materially different things — "swap the cucumbers and the melons"
|
|
with two beds of each — say what you would do and ask, rather than clearing beds on a guess.
|
|
When it is clear, just do it.
|
|
- The plan already records what was planted where and when. Write a journal entry only when the
|
|
gardener asks for one or tells you something that happened — weather, pests, a harvest, an
|
|
observation — not to narrate your own planting.
|
|
- The gardener is watching the canvas. When you are done, say briefly what you changed and where
|
|
to look; if you changed nothing, say that too.`,
|
|
g.Name, g.ID, size, today, units, g.Name)
|
|
}
|