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]>
325 lines
15 KiB
Go
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>"))
|
|
}
|