Files
pansy/internal/agent/runtime.go
T
steveandClaude Fable 5 deec7bb917
Build image / build-and-push (push) Successful in 11s
Gadfly review (reusable) / review (pull_request) Successful in 10m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m8s
Agent: undo for real, past seasons, and tools that correct the record
Six tools the live assistant kept needing and a prompt that knows about them:

- undo_change wraps RevertChangeSet(source=agent). A revert is its own change
  set, so Run reports the last one as the turn's handle when the turn changed
  nothing else — an undo-only reply keeps its "Undo this", which is now a redo.
- describe_garden takes a year: the season view (GardenFull(year)), pulled
  plops included, with removed/removedAt per group and per plop; list_years
  says which years have records. Rotation questions finally have data.
- update_planting corrects a plop's date, count, label, radius or seed lot in
  place; remove_planting, remove_plantings and clear_object take a removedAt so
  a harvest can be backdated.
- update_journal_entry / delete_journal_entry correct a note instead of
  stacking a contradicting one.
- update_garden renames/resizes/re-units a garden and rewrites its notes — and
  the notes now go into the system prompt as the gardener's standing facts, so
  "remember we're in zone 6a" persists across conversations.

describe_garden also reports the garden's notes, version and grid, which the
new tools need. Prompt, CLAUDE.md and DESIGN.md updated to match; UI step
labels for the new tools.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-23 01:50:10 -04:00

325 lines
15 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
tools *adapter
)
changeSet, err := r.svc.WithChangeSet(ctx, actorID, gardenID, service.ChangeSetOptions{
Source: domain.SourceAgent,
Summary: turnSummary(message),
AgentRunID: &runID,
}, func(ctx context.Context) error {
var box *llm.Toolbox
box, tools = 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
} else if tools != nil {
// An undo is its own change set, outside the turn's scope (it has to
// point back at what it reverted). A turn that did nothing BUT undo
// would otherwise come back with no handle, and the reply would lose
// the "Undo this" that every other change gets — here it is a redo.
turn.ChangeSetID = tools.lastRevert()
}
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,
// the gardener's standing notes, 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.
//
// The garden's notes are the assistant's memory. They are the owner's own text
// (only the owner can edit them), so they are given as background the gardener
// wrote — zone, frost dates, soil, how they like things done — and update_garden
// is how the assistant adds to them when told something worth keeping.
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)
}
notes := "The gardener has written no notes about this garden yet."
if n := strings.TrimSpace(g.Notes); n != "" {
// %q: the notes are the gardener's own words, but they are data, not
// prompt — quoting keeps a line in them from reading as an instruction
// to someone the garden is shared with.
notes = "The gardener's notes about this garden — their standing facts about the place, to use as " +
"context (zone, frost dates, soil, sun, how they like things done): " + fmt.Sprintf("%q", n)
}
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
%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 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.
- Past seasons: describe_garden with a year shows what was in each bed that year, pulled plants
included; list_years says which years have records. Check it before advising on rotation or
answering "what was here last year?" — do not guess from what is growing now.
- To undo something — yours or anyone's — find the change in read_history and call undo_change
with its id. It reverts as a new change that can itself be undone. "Undo the beets" means the
change that planted the beets, not pulling them out today; do not re-create what you can
revert. A change already marked undone stays undone.
- To correct a record rather than change the garden — a planting date, a plant count, a journal
entry's text or date, the garden's notes — use update_planting, update_journal_entry and
update_garden instead of removing and re-adding.
- When the gardener tells you something worth keeping about the place — their zone, usual
frost dates, soil, a standing preference — add it to the garden's notes with update_garden
(keeping what is already there), and say you did. You will see those notes in every later
conversation.
- 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, and never say something is undone
unless undo_change did it.
- Every reply of yours that changed the garden has an "Undo this" button under it, and the
History panel can revert any change; mention that when it helps.
- 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.`,
// %q throughout for the garden's name: any editor can rename a garden, and
// a name is data, not prompt — quoting keeps a newline or a stray quote
// in it from reading as a new instruction.
g.Name, g.ID, size, today, units, notes, fmt.Sprintf("%q", g.Name+" — <year>"))
}