Agent: undo for real, past seasons, and tools that correct the record
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

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]>
This commit is contained in:
2026-08-23 01:50:10 -04:00
co-authored by Claude Fable 5
parent 5317c92683
commit deec7bb917
10 changed files with 801 additions and 69 deletions
+44 -7
View File
@@ -120,13 +120,15 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, toda
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 {
box := NewToolbox(r.svc, actorID, today)
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),
@@ -156,6 +158,12 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, toda
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
@@ -216,7 +224,8 @@ func turnSummary(message string) string {
}
// 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 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
@@ -226,6 +235,11 @@ func turnSummary(message string) string {
// 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)
@@ -234,10 +248,19 @@ func systemPrompt(g *domain.Garden, today string) string {
"(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.
@@ -263,15 +286,29 @@ How to work:
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.
- 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.
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.
@@ -283,5 +320,5 @@ How to behave:
// %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, fmt.Sprintf("%q", g.Name+" — <year>"))
g.Name, g.ID, size, today, units, notes, fmt.Sprintf("%q", g.Name+" — <year>"))
}