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
+16 -4
View File
@@ -173,10 +173,22 @@ Conventions that follow from it:
garden made the old per-plop describe ~450 entries on every turn. A tool that garden made the old per-plop describe ~450 entries on every turn. A tool that
needs individual ids uses `list_plantings`; bulk work takes (object, plant) needs individual ids uses `list_plantings`; bulk work takes (object, plant)
`remove_plantings`, `ClearPlantings`. Don't add a tool that lists plops. `remove_plantings`, `ClearPlantings`. Don't add a tool that lists plops.
- **The assistant can't undo and must not pretend to.** Asked to "undo the With a `year` it is the season view (`GardenFull(year)`: every plop whose
beets", the live model replied "Done!" and changed nothing. The prompt now time in the ground overlapped the year, pulled ones included, with `removed`
forbids claiming a change no tool made and points at the Undo button; keep / `removedAt` per group) — that is how "what was here last year?" is answered.
both rules when editing `systemPrompt`. - **The assistant undoes through `undo_change`, never by claiming.** Asked to
"undo the beets", the live model once replied "Done!" and changed nothing.
`undo_change` wraps `RevertChangeSet(source=agent)`; the prompt still forbids
claiming a change no tool made. A revert is its own change set (it points at
what it undid), so it never joins the turn's scope — `Run` reports the last
revert as the turn's `ChangeSetID` when the turn made no other change, so the
reply's "Undo this" is a redo. Keep that fallback: without it an undo-only
turn is the one change in the conversation with no undo button.
- **Garden notes are the assistant's memory.** `systemPrompt` quotes
`Garden.Notes` (owner-written, `%q`) as standing context, and `update_garden`
is how the model adds "we're in zone 6a" to them. Notes are replaced whole,
so the tool description tells the model to merge; don't add a second store
for "things the assistant remembers".
- **Request deadlines are extended through `responseController(c)`, never - **Request deadlines are extended through `responseController(c)`, never
`http.NewResponseController(c.Writer)`.** A controller built in a handler `http.NewResponseController(c.Writer)`.** A controller built in a handler
+1 -1
View File
@@ -149,7 +149,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
7. **Sharing** — invite by email, roles, viewer read-only mode. 7. **Sharing** — invite by email, roles, viewer read-only mode.
8. **Polish** — imperial toggle, mobile ergonomics, clear-bed, keyboard nudging. 8. **Polish** — imperial toggle, mobile ergonomics, clear-bed, keyboard nudging.
9. **Agent seam**`ops.go` bulk ops + `internal/agent` DefineTool wrappers. 9. **Agent seam**`ops.go` bulk ops + `internal/agent` DefineTool wrappers.
10. **Garden assistant** — majordomo in-process, Ollama Cloud, streaming chat. Each turn runs inside ONE change set (`source='agent'`), so a turn that clears a bed and replants it undoes as one action; that is what makes acting without a confirmation prompt defensible. Bounded by a step cap and a timeout — loop safety, not spend control. The `majordomo` build tag is gone: a tag that keeps the agent out of the binary only earns its keep if you'd ship a build without it, and the agent is the point. What a day of live use added: the turn carries the gardener's **local day** (`today` in the chat body) into the prompt and every dated tool default, because the model's own idea of the date was a year stale and the server's is UTC; `describe_garden` **groups plops by plant** (count, where, planted date, days to maturity — `DescribeGroup`) and lists ids only for small groups, with `list_plantings` for the rest and `remove_plantings` to act on a whole group; `move_planting` relocates a plop (`MovePlanting`, within or across beds) keeping its planting date; `fill_region` takes an explicit local rectangle and a `seedLotId`; `update_plant`, `read_history` and `copy_garden` (the "<garden> — <year>" plan convention) round out what the model kept reaching for. A mutation on another garden inside a turn is recorded under THAT garden (`record` refuses to file revisions into a scope for a different garden), so undo always finds them where the person is looking. 10. **Garden assistant** — majordomo in-process, Ollama Cloud, streaming chat. Each turn runs inside ONE change set (`source='agent'`), so a turn that clears a bed and replants it undoes as one action; that is what makes acting without a confirmation prompt defensible. Bounded by a step cap and a timeout — loop safety, not spend control. The `majordomo` build tag is gone: a tag that keeps the agent out of the binary only earns its keep if you'd ship a build without it, and the agent is the point. What a day of live use added: the turn carries the gardener's **local day** (`today` in the chat body) into the prompt and every dated tool default, because the model's own idea of the date was a year stale and the server's is UTC; `describe_garden` **groups plops by plant** (count, where, planted date, days to maturity — `DescribeGroup`) and lists ids only for small groups, with `list_plantings` for the rest and `remove_plantings` to act on a whole group; `move_planting` relocates a plop (`MovePlanting`, within or across beds) keeping its planting date; `fill_region` takes an explicit local rectangle and a `seedLotId`; `update_plant`, `read_history` and `copy_garden` (the "<garden> — <year>" plan convention) round out what the model kept reaching for. A mutation on another garden inside a turn is recorded under THAT garden (`record` refuses to file revisions into a scope for a different garden), so undo always finds them where the person is looking. The record-keeping round (2026-08-23): `undo_change` exposes `RevertChangeSet` with `source=agent` — the revert is its own change set, so an undo-only turn reports it as the turn's handle and "Undo this" becomes a redo; `describe_garden` takes a `year` (the season view, pulled plops included, `removed`/`removedAt` per group) with `list_years` beside it, for rotation questions; `update_planting`, `update_journal_entry`/`delete_journal_entry` and `update_garden` correct records in place, and `remove_planting`/`remove_plantings`/`clear_object` take a `removedAt` so a harvest can be backdated; the garden's **notes go into the system prompt** as the gardener's standing facts, and `update_garden` is how the assistant remembers what it is told.
## Deliberate v1 limits ## Deliberate v1 limits
+44 -7
View File
@@ -120,13 +120,15 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, toda
result *agent.Result result *agent.Result
runErr error runErr error
truncErr bool truncErr bool
tools *adapter
) )
changeSet, err := r.svc.WithChangeSet(ctx, actorID, gardenID, service.ChangeSetOptions{ changeSet, err := r.svc.WithChangeSet(ctx, actorID, gardenID, service.ChangeSetOptions{
Source: domain.SourceAgent, Source: domain.SourceAgent,
Summary: turnSummary(message), Summary: turnSummary(message),
AgentRunID: &runID, AgentRunID: &runID,
}, func(ctx context.Context) error { }, 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), a := agent.New(r.model, systemPrompt(garden, today),
agent.WithMaxSteps(maxSteps), agent.WithMaxSteps(maxSteps),
agent.WithToolErrorLimits(maxConsecutiveToolErrors, maxSameCallRepeats), agent.WithToolErrorLimits(maxConsecutiveToolErrors, maxSameCallRepeats),
@@ -156,6 +158,12 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, toda
turn := &Turn{Truncated: truncErr} turn := &Turn{Truncated: truncErr}
if changeSet != nil { if changeSet != nil {
turn.ChangeSetID = &changeSet.ID 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 { if result != nil {
turn.Reply = result.Output 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, // 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 // 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 // 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 // testing: reported a change it never made, narrated every planting into the
// journal, swapped four beds on an ambiguous sentence, and answered an imperial // journal, swapped four beds on an ambiguous sentence, and answered an imperial
// gardener in centimeters. // 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 { func systemPrompt(g *domain.Garden, today string) string {
units := "The gardener works in meters and centimeters; answer in those." units := "The gardener works in meters and centimeters; answer in those."
size := fmt.Sprintf("%.0f x %.0f cm", g.WidthCM, g.HeightCM) 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." "(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) 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. 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. The garden you are working on is %q (id %d), %s. Today is %s — the gardener's local date.
%s %s
%s
Conventions you cannot guess and must not assume: Conventions you cannot guess and must not assume:
- Every measurement a tool takes or returns is in CENTIMETERS. - 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. 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 - 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. 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 - 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. happened in plain words. Do not retry it.
How to behave: How to behave:
- Only claim what a tool actually did. If a tool failed, or there is no tool for what was asked, - 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. say so plainly — never describe a change you did not make, and never say something is undone
- You cannot undo. Every reply of yours that changed the garden has an "Undo this" button under unless undo_change did it.
it, and the History panel can revert any change; point the gardener there, or offer to reverse - Every reply of yours that changed the garden has an "Undo this" button under it, and the
the change by hand with tools. 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" - 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. 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. 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 // %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 // a name is data, not prompt — quoting keeps a newline or a stray quote
// in it from reading as a new instruction. // 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>"))
} }
+91 -3
View File
@@ -335,7 +335,7 @@ func TestPartialWorkSurvivesATimeout(t *testing.T) {
if _, conflicts, rerr := svc.RevertChangeSet(ctx, owner, after[0].ID, domain.SourceUI); rerr != nil || len(conflicts) != 0 { if _, conflicts, rerr := svc.RevertChangeSet(ctx, owner, after[0].ID, domain.SourceUI); rerr != nil || len(conflicts) != 0 {
t.Fatalf("the partial turn should be undoable: err=%v conflicts=%+v", rerr, conflicts) t.Fatalf("the partial turn should be undoable: err=%v conflicts=%+v", rerr, conflicts)
} }
o, _ := svc.DescribeGarden(ctx, owner, g.ID) o, _ := svc.DescribeGarden(ctx, owner, g.ID, nil)
if len(o.Objects) > 0 && o.Objects[0].XCM != bed.XCM { if len(o.Objects) > 0 && o.Objects[0].XCM != bed.XCM {
t.Errorf("undo left the bed at %v, want %v", o.Objects[0].XCM, bed.XCM) t.Errorf("undo left the bed at %v, want %v", o.Objects[0].XCM, bed.XCM)
} }
@@ -368,7 +368,7 @@ func TestSystemPromptKnowsTheDayAndTheGardenersUnits(t *testing.T) {
"feet and inches", "feet and inches",
"24.0 x 24.0 ft", "24.0 x 24.0 ft",
"never describe a change you did not make", "never describe a change you did not make",
"You cannot undo", "undo_change",
"Undo this", "Undo this",
"rather than clearing beds on a guess", "rather than clearing beds on a guess",
"not to narrate your own planting", "not to narrate your own planting",
@@ -495,8 +495,96 @@ func TestTurnOnAnotherGardenFilesHistoryThere(t *testing.T) {
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, afterB[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 { if _, conflicts, err := svc.RevertChangeSet(ctx, owner, afterB[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("undo from B: err=%v conflicts=%+v", err, conflicts) t.Fatalf("undo from B: err=%v conflicts=%+v", err, conflicts)
} }
d, _ := svc.DescribeGarden(ctx, owner, b.ID) d, _ := svc.DescribeGarden(ctx, owner, b.ID, nil)
if len(d.Objects) != 1 || d.Objects[0].Name != "Bed" { if len(d.Objects) != 1 || d.Objects[0].Name != "Bed" {
t.Errorf("after undo B's bed is %+v, want its original name back", d.Objects) t.Errorf("after undo B's bed is %+v, want its original name back", d.Objects)
} }
} }
// TestTurnThatOnlyUndoesIsItselfUndoable — a revert is its own change set,
// outside the turn's scope, so a turn that did nothing but undo would come back
// with no change of its own; the reply would then be the one change in the
// conversation without an "Undo this". It gets the revert instead — a redo.
func TestTurnThatOnlyUndoesIsItselfUndoable(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
beets := mustPlant(t, svc, owner, "Beets", 10, "🫜")
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400})
if err != nil {
t.Fatalf("bed: %v", err)
}
// The beets went in by hand in the editor: the change the person wants undone.
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", beets.ID, nil, service.FillClump, nil); err != nil {
t.Fatalf("plant beets: %v", err)
}
history, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if err != nil || len(history) == 0 {
t.Fatalf("history: %v (%d entries)", err, len(history))
}
planted := history[0]
r := scriptedRunner(t, svc,
toolCall("read_history", map[string]any{"gardenId": g.ID}),
toolCall("undo_change", map[string]any{"changeSetId": planted.ID}),
fake.Reply("Undone — the beets are out of the bed again."),
)
turn, err := r.Run(ctx, owner, g.ID, "undo the beets", "", nil, nil)
if err != nil {
t.Fatalf("Run: %v", err)
}
full, err := svc.GardenFull(ctx, owner, g.ID, nil)
if err != nil {
t.Fatalf("GardenFull: %v", err)
}
if len(full.Plantings) != 0 {
t.Fatalf("%d beets still in the bed after the undo", len(full.Plantings))
}
after, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if len(after) != len(history)+1 {
t.Fatalf("history grew by %d, want exactly the revert", len(after)-len(history))
}
revert := after[0]
if revert.Source != domain.SourceAgent || revert.RevertsID == nil || *revert.RevertsID != planted.ID {
t.Errorf("newest entry = %+v; want the agent's revert of %d", revert, planted.ID)
}
if turn.ChangeSetID == nil || *turn.ChangeSetID != revert.ID {
t.Fatalf("turn.ChangeSetID = %v, want the revert %d so the reply can offer a redo", turn.ChangeSetID, revert.ID)
}
// And "Undo this" on that reply is a redo.
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, *turn.ChangeSetID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("redo: err=%v conflicts=%+v", err, conflicts)
}
full, _ = svc.GardenFull(ctx, owner, g.ID, nil)
if len(full.Plantings) == 0 {
t.Error("redoing the turn did not put the beets back")
}
}
// TestSystemPromptCarriesTheGardenersNotes — the notes are the assistant's
// memory: what the gardener told it about the place comes back on every turn,
// quoted as their words rather than pasted as instructions.
func TestSystemPromptCarriesTheGardenersNotes(t *testing.T) {
with := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitMetric,
Notes: "Zone 6a.\nLast frost \"usually\" May 10."}, "2026-08-23")
for _, want := range []string{
`"Zone 6a.\nLast frost \"usually\" May 10."`,
"update_garden",
"undo_change",
"describe_garden with a year",
} {
if !strings.Contains(with, want) {
t.Errorf("prompt is missing %q", want)
}
}
if strings.Contains(with, "You cannot undo") {
t.Error("the prompt still says the assistant cannot undo")
}
without := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitMetric}, "2026-08-23")
if !strings.Contains(without, "no notes") {
t.Error("a garden without notes doesn't say so")
}
}
+314 -26
View File
@@ -3,7 +3,10 @@ package agent
import ( import (
"context" "context"
"fmt" "fmt"
"strconv"
"strings" "strings"
"sync"
"time"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm" "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
@@ -24,19 +27,35 @@ import (
// client, because the server's UTC day is tomorrow by nine in the evening in // client, because the server's UTC day is tomorrow by nine in the evening in
// Ohio. Empty falls back to the service's UTC today. // Ohio. Empty falls back to the service's UTC today.
func NewToolbox(svc *service.Service, actorID int64, today string) *llm.Toolbox { func NewToolbox(svc *service.Service, actorID int64, today string) *llm.Toolbox {
box, _ := newToolbox(svc, actorID, today)
return box
}
// newToolbox is NewToolbox plus the adapter behind it, which Run keeps hold of:
// the adapter remembers what undo_change reverted, and a turn that only undid
// something has no other handle to offer as its change.
func newToolbox(svc *service.Service, actorID int64, today string) (*llm.Toolbox, *adapter) {
a := &adapter{svc: svc, actor: actorID, today: strings.TrimSpace(today)} a := &adapter{svc: svc, actor: actorID, today: strings.TrimSpace(today)}
return llm.NewToolbox("pansy", return llm.NewToolbox("pansy",
llm.DefineTool("list_gardens", llm.DefineTool("list_gardens",
"List the gardens the user can see (owned and shared), with the user's role on each.", "List the gardens the user can see (owned and shared), with the user's role on each.",
a.listGardens), a.listGardens),
llm.DefineTool("describe_garden", llm.DefineTool("describe_garden",
"Summarize a garden: its dimensions, objects (with sizes/positions/version), and each "+ "Summarize a garden: its dimensions, notes, version, objects (with sizes/positions/version), "+
"object's active plantings grouped by plant — how many, roughly where, when they went in, "+ "and each object's plantings grouped by plant — how many, roughly where, when they went in, "+
"and days to maturity when known. A small group lists its plops individually (id + "+ "and days to maturity when known. A small group lists its plops individually (id + "+
"version for move_planting/remove_planting, and xCm/yCm in the object's local frame so a "+ "version for move_planting/remove_planting/update_planting, and xCm/yCm in the object's "+
"move can keep their layout); a large one (a grid-filled bed) does not — use "+ "local frame so a move can keep their layout); a large one (a grid-filled bed) does not — "+
"list_plantings for those, or act on the whole group with remove_plantings.", "use list_plantings for those, or act on the whole group with remove_plantings. Without a "+
"year it describes what is growing now; with one it is that season's view — every plop "+
"whose time in the ground overlapped the year, pulled ones included, each group saying how "+
"many were removed and when. That is how to answer \"what was in this bed last year?\" and "+
"to check rotation before replanting. list_years says which years have data.",
a.describeGarden), a.describeGarden),
llm.DefineTool("list_years",
"List the years this garden has planting records for, newest first — the years "+
"describe_garden can show as a season view.",
a.listYears),
llm.DefineTool("list_plantings", llm.DefineTool("list_plantings",
"List one object's active plops one by one, each with its id, version, position (xCm/yCm "+ "List one object's active plops one by one, each with its id, version, position (xCm/yCm "+
"in the object's local frame), location, count and planting date — the detail "+ "in the object's local frame), location, count and planting date — the detail "+
@@ -71,21 +90,30 @@ func NewToolbox(svc *service.Service, actorID int64, today string) *llm.Toolbox
"is how to relocate plants; removing and re-placing them would lose when they were planted. "+ "is how to relocate plants; removing and re-placing them would lose when they were planted. "+
"Needs the plop's id and version (describe_garden or list_plantings).", "Needs the plop's id and version (describe_garden or list_plantings).",
a.movePlanting), a.movePlanting),
llm.DefineTool("update_planting",
"Correct ONE plop's record without moving it: the date it was planted (plantedAt), its "+
"plant count (count, or clearCount to go back to deriving it from area and spacing), "+
"its label, its radius, or the seed lot it came from. Use for \"those tomatoes actually "+
"went in on May 20\" or \"that clump is five plants\". Needs the plop's id and version "+
"(describe_garden or list_plantings). Only the fields you pass change.",
a.updatePlanting),
llm.DefineTool("remove_planting", llm.DefineTool("remove_planting",
"Remove ONE plop from a bed, leaving the rest — the single-plant answer to clear_object's "+ "Remove ONE plop from a bed, leaving the rest — the single-plant answer to clear_object's "+
"all-or-nothing. Soft-removes it (kept for planting history, undoable), like clearing a "+ "all-or-nothing. Soft-removes it (kept for planting history, undoable), like clearing a "+
"bed does. Needs the plop's id and version (describe_garden or list_plantings). Use "+ "bed does. Needs the plop's id and version (describe_garden or list_plantings). Use "+
"for \"pull the basil out of the corner\".", "for \"pull the basil out of the corner\". Dated today unless removedAt says when it "+
"actually came out (\"I harvested the garlic on Aug 1\").",
a.removePlanting), a.removePlanting),
llm.DefineTool("remove_plantings", llm.DefineTool("remove_plantings",
"Remove every plop of ONE plant from an object, leaving the other plants in it — \"take the "+ "Remove every plop of ONE plant from an object, leaving the other plants in it — \"take the "+
"beets out of the south bed\". Soft-removes them (kept for planting history, undoable as "+ "beets out of the south bed\". Soft-removes them (kept for planting history, undoable as "+
"one change). Use this rather than many remove_planting calls.", "one change). Use this rather than many remove_planting calls. Dated today unless "+
"removedAt says when they actually came out.",
a.removePlantings), a.removePlantings),
llm.DefineTool("clear_object", llm.DefineTool("clear_object",
"Remove all plants from an object. They are soft-removed, so the planting history for past "+ "Remove all plants from an object. They are soft-removed, so the planting history for past "+
"seasons is kept and the change can be undone. Use this before replanting a bed with "+ "seasons is kept and the change can be undone. Use this before replanting a bed with "+
"something else.", "something else. Dated today unless removedAt says when the bed was actually cleared.",
a.clearObject), a.clearObject),
llm.DefineTool("find_plant", llm.DefineTool("find_plant",
"Look up plants in the user's catalog by name or category, to get the plantId that "+ "Look up plants in the user's catalog by name or category, to get the plantId that "+
@@ -115,16 +143,34 @@ func NewToolbox(svc *service.Service, actorID int64, today string) *llm.Toolbox
llm.DefineTool("read_journal", llm.DefineTool("read_journal",
"Read back the garden's grow journal — the observations add_journal_entry wrote. "+ "Read back the garden's grow journal — the observations add_journal_entry wrote. "+
"Narrow it with objectId (one bed), or a from/to date range (YYYY-MM-DD). Most "+ "Narrow it with objectId (one bed), or a from/to date range (YYYY-MM-DD). Most "+
"recently observed first. Use this to answer \"what did I note about the west bed?\" "+ "recently observed first. Each entry carries the id and version that "+
"or \"what happened last spring?\".", "update_journal_entry and delete_journal_entry need. Use this to answer \"what did I "+
"note about the west bed?\" or \"what happened last spring?\".",
a.readJournal), a.readJournal),
llm.DefineTool("update_journal_entry",
"Correct a journal entry the user wrote — its text, or the date it describes — instead "+
"of adding a second entry that contradicts the first: \"that note was about the "+
"cucumbers, not the cantaloupe\". Needs the entry's id and version from read_journal. "+
"Only the user's own entries can be edited.",
a.updateJournalEntry),
llm.DefineTool("delete_journal_entry",
"Delete a journal entry, by its id from read_journal. This is permanent — the journal is "+
"not in the undo history — so delete only the entry the user pointed at.",
a.deleteJournalEntry),
llm.DefineTool("read_history", llm.DefineTool("read_history",
"Read the garden's change history: every change anyone made — by hand in the editor, or "+ "Read the garden's change history: every change anyone made — by hand in the editor, or "+
"in an earlier conversation with you — newest first, with what it changed and whether it "+ "in an earlier conversation with you — newest first, with its id, what it changed and "+
"was undone. Use it to answer \"what changed this week?\" or \"what did you do last time?\" "+ "whether it was undone. Use it to answer \"what changed this week?\" or \"what did you do "+
"rather than reciting from memory. You cannot undo from here; the person has an Undo "+ "last time?\" rather than reciting from memory, and to find the id undo_change needs.",
"button on each change.",
a.readHistory), a.readHistory),
llm.DefineTool("undo_change",
"Undo one change from the history by its id (from read_history): it reverts everything "+
"that change did, as a new change that can itself be undone. This is how to do \"undo "+
"the beets\" or \"put it back the way it was\" — find the change in read_history, then "+
"undo it; never claim to have undone something without calling this. A change already "+
"marked undone needs no second undo. Anything edited since that change is left alone "+
"and reported under conflicts; tell the user about those.",
a.undoChange),
llm.DefineTool("update_object", llm.DefineTool("update_object",
"Change an existing object: resize it (widthCm/heightCm), rotate it (rotationDeg), "+ "Change an existing object: resize it (widthCm/heightCm), rotate it (rotationDeg), "+
"rename it (name), or toggle whether it can hold plants (plantable). Only the fields "+ "rename it (name), or toggle whether it can hold plants (plantable). Only the fields "+
@@ -154,7 +200,17 @@ func NewToolbox(svc *service.Service, actorID int64, today string) *llm.Toolbox
"(with an em dash) is that garden's plan for the year, and the editor offers it as such. "+ "(with an em dash) is that garden's plan for the year, and the editor offers it as such. "+
"Use it for \"set up next year's plan\"; never use another real garden as a scratch space.", "Use it for \"set up next year's plan\"; never use another real garden as a scratch space.",
a.copyGarden), a.copyGarden),
) llm.DefineTool("update_garden",
"Change a garden the user owns: rename it, resize it (widthCm/heightCm), switch its units "+
"(metric|imperial), set its grid (gridSizeCm, snapToGrid), or rewrite its notes. Only the "+
"fields you pass change. Needs the garden's current version from describe_garden. The "+
"notes are the gardener's standing facts about the place — zone, frost dates, soil, sun, "+
"how they like things done — and you are given them at the start of every conversation, "+
"so when the user tells you something worth remembering (\"we're in zone 6a\", \"last "+
"frost is usually around May 10\"), add it here. notes replaces the WHOLE text: take the "+
"current notes from describe_garden, add the new line, and pass all of it.",
a.updateGarden),
), a
} }
// adapter carries the service, the acting user and their local day for the // adapter carries the service, the acting user and their local day for the
@@ -163,6 +219,25 @@ type adapter struct {
svc *service.Service svc *service.Service
actor int64 actor int64
today string today string
mu sync.Mutex
// reverts is every change set undo_change produced this turn. A revert is
// its own change set (it points back at the one it undid, and the target is
// marked undone), so it never joins the turn's scope — which leaves a turn
// that only undid something with no change of its own. Run reports the last
// revert as that turn's handle, so "Undo this" under the reply can redo it.
reverts []int64
}
// lastRevert is the newest change set undo_change produced this turn, if any.
func (a *adapter) lastRevert() *int64 {
a.mu.Lock()
defer a.mu.Unlock()
if len(a.reverts) == 0 {
return nil
}
id := a.reverts[len(a.reverts)-1]
return &id
} }
// day is the date a tool stamps: the one the model passed, else the gardener's // day is the date a tool stamps: the one the model passed, else the gardener's
@@ -184,8 +259,45 @@ func (a *adapter) listGardens(ctx context.Context, _ struct{}) (any, error) {
func (a *adapter) describeGarden(ctx context.Context, args struct { func (a *adapter) describeGarden(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"id of the garden to describe"` GardenID int64 `json:"gardenId" description:"id of the garden to describe"`
Year *int `json:"year" description:"optional: describe that year's season instead of what is growing now — every plop in the ground at any point in the year, pulled ones included"`
}) (any, error) { }) (any, error) {
return a.svc.DescribeGarden(ctx, a.actor, args.GardenID) return a.svc.DescribeGarden(ctx, a.actor, args.GardenID, args.Year)
}
func (a *adapter) listYears(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden whose planting years to list"`
}) (any, error) {
years, err := a.svc.GardenYears(ctx, a.actor, args.GardenID)
if err != nil {
return nil, err
}
// The service pads the list with ITS current year (UTC); the gardener's may
// differ around New Year. Theirs is the one "this year" means to them.
if y, ok := yearOf(a.today); ok {
present := false
for _, have := range years {
if have == y {
present = true
break
}
}
if !present {
years = append([]int{y}, years...)
}
}
return map[string]any{"years": years}, nil
}
// yearOf is the year of a YYYY-MM-DD date, or false for anything else.
func yearOf(date string) (int, bool) {
if len(date) < 4 {
return 0, false
}
y, err := strconv.Atoi(date[:4])
if err != nil {
return 0, false
}
return y, true
} }
func (a *adapter) listPlantings(ctx context.Context, args struct { func (a *adapter) listPlantings(ctx context.Context, args struct {
@@ -344,9 +456,10 @@ func (a *adapter) addJournalEntry(ctx context.Context, args struct {
} }
func (a *adapter) clearObject(ctx context.Context, args struct { func (a *adapter) clearObject(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to remove all plants from"` ObjectID int64 `json:"objectId" description:"object to remove all plants from"`
RemovedAt string `json:"removedAt" description:"optional date the plants came out, YYYY-MM-DD; defaults to today"`
}) (any, error) { }) (any, error) {
n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, service.ClearOptions{RemovedAt: a.day("")}) n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, service.ClearOptions{RemovedAt: a.day(args.RemovedAt)})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -354,21 +467,75 @@ func (a *adapter) clearObject(ctx context.Context, args struct {
} }
func (a *adapter) removePlantings(ctx context.Context, args struct { func (a *adapter) removePlantings(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to remove the plant from"` ObjectID int64 `json:"objectId" description:"object to remove the plant from"`
PlantID int64 `json:"plantId" description:"the plant to remove every plop of (from describe_garden)"` PlantID int64 `json:"plantId" description:"the plant to remove every plop of (from describe_garden)"`
RemovedAt string `json:"removedAt" description:"optional date they came out, YYYY-MM-DD; defaults to today"`
}) (any, error) { }) (any, error) {
if args.PlantID == 0 { if args.PlantID == 0 {
// Left out, it would "remove" plant 0 — nothing — and report success. // Left out, it would "remove" plant 0 — nothing — and report success.
return nil, fmt.Errorf("%w: plantId is required — say which plant to remove, or use clear_object for all of them", domain.ErrInvalidInput) return nil, fmt.Errorf("%w: plantId is required — say which plant to remove, or use clear_object for all of them", domain.ErrInvalidInput)
} }
n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID,
service.ClearOptions{PlantID: &args.PlantID, RemovedAt: a.day("")}) service.ClearOptions{PlantID: &args.PlantID, RemovedAt: a.day(args.RemovedAt)})
if err != nil { if err != nil {
return nil, err return nil, err
} }
return map[string]int{"removed": n}, nil return map[string]int{"removed": n}, nil
} }
func (a *adapter) updatePlanting(ctx context.Context, args struct {
PlantingID int64 `json:"plantingId" description:"plop to correct (its id from describe_garden or list_plantings)"`
Version int64 `json:"version" description:"the plop's current version"`
PlantedAt *string `json:"plantedAt" description:"optional corrected planting date, YYYY-MM-DD"`
Count *int `json:"count" description:"optional explicit plant count for the plop"`
ClearCount bool `json:"clearCount" description:"optional: drop an explicit count and derive it from area and spacing again"`
Label *string `json:"label" description:"optional label for the plop; empty clears it"`
RadiusCM *float64 `json:"radiusCm" description:"optional new radius in cm"`
SeedLotID *int64 `json:"seedLotId" description:"optional seed lot (from list_seed_lots) to attribute it to"`
ClearLot bool `json:"clearSeedLot" description:"optional: detach it from its seed lot"`
}) (any, error) {
patch := service.PlantingPatch{RadiusCM: args.RadiusCM}
if args.PlantedAt != nil {
if _, err := parseDay(*args.PlantedAt); err != nil {
return nil, err
}
patch.SetPlantedAt, patch.PlantedAt = true, args.PlantedAt
}
switch {
case args.ClearCount && args.Count != nil:
return nil, fmt.Errorf("%w: give a count or clearCount, not both", domain.ErrInvalidInput)
case args.ClearCount:
patch.SetCount = true
case args.Count != nil:
patch.SetCount, patch.Count = true, args.Count
}
if args.Label != nil {
patch.SetLabel = true
if strings.TrimSpace(*args.Label) != "" {
patch.Label = args.Label
}
}
switch {
case args.ClearLot && args.SeedLotID != nil:
return nil, fmt.Errorf("%w: give a seedLotId or clearSeedLot, not both", domain.ErrInvalidInput)
case args.ClearLot:
patch.SetSeedLotID = true
case args.SeedLotID != nil:
patch.SetSeedLotID, patch.SeedLotID = true, args.SeedLotID
}
return a.svc.UpdatePlanting(ctx, a.actor, args.PlantingID, patch, args.Version)
}
// parseDay checks a date the model typed, so a malformed one fails with a
// message about the date rather than as a bare "invalid input" from the store.
func parseDay(s string) (string, error) {
s = strings.TrimSpace(s)
if _, err := time.Parse(dateLayout, s); err != nil {
return "", fmt.Errorf("%w: %q is not a YYYY-MM-DD date", domain.ErrInvalidInput, s)
}
return s, nil
}
func (a *adapter) readJournal(ctx context.Context, args struct { func (a *adapter) readJournal(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden whose journal to read"` GardenID int64 `json:"gardenId" description:"garden whose journal to read"`
ObjectID *int64 `json:"objectId" description:"optional bed to narrow to; omit for the whole garden"` ObjectID *int64 `json:"objectId" description:"optional bed to narrow to; omit for the whole garden"`
@@ -391,6 +558,75 @@ func (a *adapter) readJournal(ctx context.Context, args struct {
return map[string]any{"entries": entries, "hasMore": hasMore}, nil return map[string]any{"entries": entries, "hasMore": hasMore}, nil
} }
func (a *adapter) updateJournalEntry(ctx context.Context, args struct {
EntryID int64 `json:"entryId" description:"journal entry to correct (its id from read_journal)"`
Version int64 `json:"version" description:"the entry's current version (from read_journal)"`
Body *string `json:"body" description:"optional corrected text"`
ObservedAt *string `json:"observedAt" description:"optional corrected date it happened, YYYY-MM-DD"`
}) (any, error) {
if args.Body == nil && args.ObservedAt == nil {
return nil, fmt.Errorf("%w: say what to change — the body, the date, or both", domain.ErrInvalidInput)
}
if args.ObservedAt != nil {
if _, err := parseDay(*args.ObservedAt); err != nil {
return nil, err
}
}
return a.svc.UpdateJournalEntry(ctx, a.actor, args.EntryID,
service.JournalPatch{Body: args.Body, ObservedAt: args.ObservedAt}, args.Version)
}
func (a *adapter) deleteJournalEntry(ctx context.Context, args struct {
EntryID int64 `json:"entryId" description:"journal entry to delete (its id from read_journal)"`
}) (any, error) {
if err := a.svc.DeleteJournalEntry(ctx, a.actor, args.EntryID); err != nil {
return nil, err
}
return map[string]any{"deleted": args.EntryID}, nil
}
// undoResult is what undo_change reports: the change it reverted, the new
// change set that did so (undoable in turn), and what it had to leave alone.
type undoResult struct {
UndoneID int64 `json:"undoneId"`
// ChangeSet is the revert itself — the history entry that can be undone
// to redo — and Summary its row in the history ("Undid: …").
ChangeSet *int64 `json:"changeSetId,omitempty"`
Summary string `json:"summary,omitempty"`
Changes string `json:"changes"`
Conflicts []domain.RevertConflict `json:"conflicts"`
Note string `json:"note,omitempty"`
}
func (a *adapter) undoChange(ctx context.Context, args struct {
ChangeSetID int64 `json:"changeSetId" description:"the change to undo — its id from read_history"`
}) (any, error) {
if args.ChangeSetID == 0 {
return nil, fmt.Errorf("%w: changeSetId is required — find the change in read_history first", domain.ErrInvalidInput)
}
cs, conflicts, err := a.svc.RevertChangeSet(ctx, a.actor, args.ChangeSetID, domain.SourceAgent)
if err != nil {
return nil, err
}
res := undoResult{UndoneID: args.ChangeSetID, Conflicts: conflicts}
if cs == nil {
// Nothing applied: every revision was a conflict, or the set was empty.
res.Changes = "nothing"
res.Note = "Nothing was reverted — everything that change touched has been edited since, or there was nothing left to undo."
return res, nil
}
res.ChangeSet = &cs.ID
res.Summary = cs.Summary
res.Changes = describeCounts(cs.Counts)
if len(conflicts) > 0 {
res.Note = "Part of the change was left alone because it had been edited since; see conflicts."
}
a.mu.Lock()
a.reverts = append(a.reverts, cs.ID)
a.mu.Unlock()
return res, nil
}
// historyEntry is one change set as read_history reports it: the row a person // historyEntry is one change set as read_history reports it: the row a person
// would read in the History panel, not the revision snapshots behind it. // would read in the History panel, not the revision snapshots behind it.
type historyEntry struct { type historyEntry struct {
@@ -471,12 +707,14 @@ func (a *adapter) deleteObject(ctx context.Context, args struct {
} }
func (a *adapter) removePlanting(ctx context.Context, args struct { func (a *adapter) removePlanting(ctx context.Context, args struct {
PlantingID int64 `json:"plantingId" description:"plop to remove (its id from describe_garden)"` PlantingID int64 `json:"plantingId" description:"plop to remove (its id from describe_garden)"`
Version int64 `json:"version" description:"the plop's current version (from describe_garden)"` Version int64 `json:"version" description:"the plop's current version (from describe_garden)"`
RemovedAt string `json:"removedAt" description:"optional date it came out, YYYY-MM-DD; defaults to today"`
}) (any, error) { }) (any, error) {
// Soft-remove via the service, dated the gardener's local day like every // Soft-remove via the service, dated the day the gardener said, else their
// other tool here (the service clock's UTC day when that isn't known). // local day like every other tool here (the service clock's UTC day when
return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version, a.day("")) // that isn't known).
return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version, a.day(args.RemovedAt))
} }
func (a *adapter) listSeedLots(ctx context.Context, args struct { func (a *adapter) listSeedLots(ctx context.Context, args struct {
@@ -507,3 +745,53 @@ func (a *adapter) copyGarden(ctx context.Context, args struct {
}) (any, error) { }) (any, error) {
return a.svc.CopyGarden(ctx, a.actor, args.GardenID, args.Name) return a.svc.CopyGarden(ctx, a.actor, args.GardenID, args.Name)
} }
func (a *adapter) updateGarden(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden to change (the user must own it)"`
Version int64 `json:"version" description:"the garden's current version (from describe_garden)"`
Name *string `json:"name" description:"optional new name"`
WidthCM *float64 `json:"widthCm" description:"optional new width in cm"`
HeightCM *float64 `json:"heightCm" description:"optional new height in cm"`
UnitPref *string `json:"units" description:"optional: metric | imperial — how the gardener wants lengths shown"`
Notes *string `json:"notes" description:"optional replacement for the WHOLE notes text (merge the current notes in yourself); empty clears them"`
GridSizeCM *float64 `json:"gridSizeCm" description:"optional grid spacing for the editor, in cm"`
SnapToGrid *bool `json:"snapToGrid" description:"optional: whether objects snap to that grid"`
}) (any, error) {
if args.Name == nil && args.WidthCM == nil && args.HeightCM == nil && args.UnitPref == nil &&
args.Notes == nil && args.GridSizeCM == nil && args.SnapToGrid == nil {
return nil, fmt.Errorf("%w: say what to change about the garden", domain.ErrInvalidInput)
}
// UpdateGarden takes the whole row, so start from the current one and
// overlay what the model passed — the same merge the editor's settings
// dialog does, and the only way a one-field change leaves the rest alone.
g, err := a.svc.GetGarden(ctx, a.actor, args.GardenID)
if err != nil {
return nil, err
}
in := service.GardenInput{
Name: g.Name, WidthCM: g.WidthCM, HeightCM: g.HeightCM, UnitPref: g.UnitPref,
Notes: g.Notes, GridSizeCM: g.GridSizeCM, SnapToGrid: g.SnapToGrid,
}
if args.Name != nil {
in.Name = *args.Name
}
if args.WidthCM != nil {
in.WidthCM = *args.WidthCM
}
if args.HeightCM != nil {
in.HeightCM = *args.HeightCM
}
if args.UnitPref != nil {
in.UnitPref = strings.ToLower(strings.TrimSpace(*args.UnitPref))
}
if args.Notes != nil {
in.Notes = *args.Notes
}
if args.GridSizeCM != nil {
in.GridSizeCM = *args.GridSizeCM
}
if args.SnapToGrid != nil {
in.SnapToGrid = *args.SnapToGrid
}
return a.svc.UpdateGarden(ctx, a.actor, args.GardenID, in, args.Version)
}
+170
View File
@@ -735,3 +735,173 @@ func TestToolsDefaultToTheServiceDayWithoutOne(t *testing.T) {
t.Errorf("an explicit day beats the default: %v", d) t.Errorf("an explicit day beats the default: %v", d)
} }
} }
// TestRecordKeepingTools covers the tools that correct the record rather than
// change the garden — and the one that undoes a change for real. Each answers a
// thing the live assistant could not do: fix a planting date, backdate a
// harvest, correct a journal note, remember the gardener's zone, see last
// season, and undo without pretending.
func TestRecordKeepingTools(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
box := NewToolbox(svc, owner, "2026-08-23")
call := func(name string, args any) llm.ToolResult {
t.Helper()
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)})
}
mustCall := func(name string, args any, into any) {
t.Helper()
res := call(name, args)
if res.IsError {
t.Fatalf("%s: %s", name, res.Content)
}
if into != nil {
if err := json.Unmarshal([]byte(res.Content), into); err != nil {
t.Fatalf("decode %s: %v (%s)", name, err, res.Content)
}
}
}
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Home", WidthCM: 1200, HeightCM: 800, Notes: "Zone 6a."})
if err != nil {
t.Fatalf("garden: %v", err)
}
beet := mustPlant(t, svc, owner, "Beet", 10, "🫜")
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "South bed", XCM: 600, YCM: 400, WidthCM: 400, HeightCM: 200})
if err != nil {
t.Fatalf("bed: %v", err)
}
// --- update_garden: one field changes, the rest survive, notes merge by hand.
var desc service.DescribeResult
mustCall("describe_garden", map[string]any{"gardenId": g.ID}, &desc)
if desc.Notes != "Zone 6a." || desc.Version != g.Version {
t.Fatalf("describe carries notes %q v%d, want %q v%d", desc.Notes, desc.Version, "Zone 6a.", g.Version)
}
if r := call("update_garden", map[string]any{"gardenId": g.ID, "version": desc.Version}); !r.IsError || !strings.Contains(r.Content, "what to change") {
t.Errorf("update_garden with nothing to change = %q, want a refusal that says so", r.Content)
}
var updated domain.Garden
mustCall("update_garden", map[string]any{
"gardenId": g.ID, "version": desc.Version, "notes": desc.Notes + "\nLast frost is usually around May 10.",
}, &updated)
if updated.Name != "Home" || updated.WidthCM != 1200 || updated.HeightCM != 800 || updated.UnitPref != domain.UnitMetric {
t.Errorf("a notes-only update changed other fields: %+v", updated)
}
if !strings.HasPrefix(updated.Notes, "Zone 6a.") || !strings.Contains(updated.Notes, "May 10") {
t.Errorf("notes = %q, want the old note kept and the new line added", updated.Notes)
}
if r := call("update_garden", map[string]any{"gardenId": g.ID, "version": desc.Version, "name": "Stale"}); !r.IsError {
t.Error("update_garden with a stale version succeeded")
}
mustCall("update_garden", map[string]any{"gardenId": g.ID, "version": updated.Version, "units": "Imperial"}, &updated)
if updated.UnitPref != domain.UnitImperial {
t.Errorf("units = %q after asking for imperial", updated.UnitPref)
}
// --- update_planting: correct a plop's record without touching its position.
var plop domain.Planting
mustCall("place_planting", map[string]any{"objectId": bed.ID, "plantId": beet.ID, "xCm": 50, "yCm": -30, "radiusCm": 20, "plantedAt": "2026-05-01"}, &plop)
mustCall("update_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "count": 5, "label": "from the market"}, &plop)
if plop.Count == nil || *plop.Count != 5 || plop.Label == nil || *plop.Label != "from the market" || plop.XCM != 50 {
t.Errorf("after count+label: %+v", plop)
}
// Decoded into a fresh value: a field the response omits must read as
// cleared, not as whatever the previous decode left in the pointer.
cleared, version := domain.Planting{}, plop.Version
mustCall("update_planting", map[string]any{"plantingId": plop.ID, "version": version, "clearCount": true, "plantedAt": "2026-05-20", "label": ""}, &cleared)
plop = cleared
if plop.Count != nil || plop.Label != nil || plop.PlantedAt == nil || *plop.PlantedAt != "2026-05-20" {
t.Errorf("after clearCount/plantedAt/empty label: %+v", plop)
}
if r := call("update_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "plantedAt": "May 20"}); !r.IsError || !strings.Contains(r.Content, "YYYY-MM-DD") {
t.Errorf("a prose date = %q, want a refusal naming the format", r.Content)
}
if r := call("update_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "count": 3, "clearCount": true}); !r.IsError {
t.Error("count and clearCount together were accepted")
}
// --- remove_planting on the day the gardener said, not today.
mustCall("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version, "removedAt": "2026-08-01"}, &plop)
if plop.RemovedAt == nil || *plop.RemovedAt != "2026-08-01" {
t.Errorf("removedAt = %v, want the harvest date 2026-08-01", plop.RemovedAt)
}
// --- the season view sees it, the live view does not.
var years struct{ Years []int }
mustCall("list_years", map[string]any{"gardenId": g.ID}, &years)
if len(years.Years) == 0 || years.Years[0] != 2026 {
t.Errorf("years = %v, want 2026 first", years.Years)
}
mustCall("describe_garden", map[string]any{"gardenId": g.ID}, &desc)
if len(desc.Objects[0].Plantings) != 0 {
t.Errorf("the live describe still lists the pulled beet: %+v", desc.Objects[0].Plantings)
}
mustCall("describe_garden", map[string]any{"gardenId": g.ID, "year": 2026}, &desc)
if desc.Year == nil || *desc.Year != 2026 || len(desc.Objects[0].Plantings) != 1 {
t.Fatalf("2026 describe = year %v, %d groups; want the beet group", desc.Year, len(desc.Objects[0].Plantings))
}
if gr := desc.Objects[0].Plantings[0]; gr.Removed != 1 || gr.RemovedAt != "2026-08-01" || gr.PlantedAt != "2026-05-20" {
t.Errorf("2026 beet group = %+v; want 1 removed 2026-08-01, planted 2026-05-20", gr)
}
// --- undo_change: the removal is the newest history entry; undoing it puts
// the beet back, as a change that is itself in the history and undoable.
var hist struct {
Entries []historyEntry `json:"entries"`
}
mustCall("read_history", map[string]any{"gardenId": g.ID, "limit": 5}, &hist)
if len(hist.Entries) == 0 || !strings.HasPrefix(hist.Entries[0].Summary, "Removed Beet") {
t.Fatalf("history[0] = %+v, want the beet's removal", hist.Entries)
}
removal := hist.Entries[0].ID
if r := call("undo_change", map[string]any{}); !r.IsError || !strings.Contains(r.Content, "read_history") {
t.Errorf("undo_change without an id = %q, want a refusal pointing at read_history", r.Content)
}
var undone undoResult
mustCall("undo_change", map[string]any{"changeSetId": removal}, &undone)
if undone.ChangeSet == nil || undone.UndoneID != removal || len(undone.Conflicts) != 0 || !strings.Contains(undone.Changes, "1 planting updated") {
t.Errorf("undo result = %+v; want a new change set, no conflicts, one planting updated", undone)
}
mustCall("describe_garden", map[string]any{"gardenId": g.ID}, &desc)
if len(desc.Objects[0].Plantings) != 1 || desc.Objects[0].Plantings[0].Each[0].ID != plop.ID {
t.Errorf("after the undo the beet is not back: %+v", desc.Objects[0].Plantings)
}
mustCall("read_history", map[string]any{"gardenId": g.ID, "limit": 5}, &hist)
if e := hist.Entries[0]; e.ID != *undone.ChangeSet || e.UndoOf == nil || *e.UndoOf != removal || e.Source != domain.SourceAgent {
t.Errorf("history[0] after undo = %+v; want the agent's revert of %d", e, removal)
}
if !hist.Entries[1].Undone {
t.Error("the removal is not marked undone")
}
if got := (&adapter{}).lastRevert(); got != nil {
t.Errorf("a fresh adapter remembers a revert: %v", *got)
}
// --- the journal: correct an entry in place, then delete it.
var entry domain.JournalEntry
mustCall("add_journal_entry", map[string]any{"gardenId": g.ID, "body": "Aphids on the cantaloupe.", "observedAt": "2026-08-20"}, &entry)
if r := call("update_journal_entry", map[string]any{"entryId": entry.ID, "version": entry.Version}); !r.IsError {
t.Error("update_journal_entry with nothing to change succeeded")
}
if r := call("update_journal_entry", map[string]any{"entryId": entry.ID, "version": entry.Version, "observedAt": "yesterday"}); !r.IsError || !strings.Contains(r.Content, "YYYY-MM-DD") {
t.Errorf("a prose date = %q, want a refusal naming the format", r.Content)
}
mustCall("update_journal_entry", map[string]any{"entryId": entry.ID, "version": entry.Version, "body": "Aphids on the cucumbers.", "observedAt": "2026-08-19"}, &entry)
if entry.Body != "Aphids on the cucumbers." || entry.ObservedAt != "2026-08-19" {
t.Errorf("corrected entry = %+v", entry)
}
var journal struct {
Entries []domain.JournalEntry `json:"entries"`
}
mustCall("read_journal", map[string]any{"gardenId": g.ID}, &journal)
if len(journal.Entries) != 1 || journal.Entries[0].Body != "Aphids on the cucumbers." {
t.Errorf("journal after the correction = %+v, want the one corrected entry", journal.Entries)
}
mustCall("delete_journal_entry", map[string]any{"entryId": entry.ID}, nil)
mustCall("read_journal", map[string]any{"gardenId": g.ID}, &journal)
if len(journal.Entries) != 0 {
t.Errorf("journal after the delete = %+v, want empty", journal.Entries)
}
}
+60 -24
View File
@@ -550,13 +550,22 @@ func (s *Service) ClearPlantings(ctx context.Context, actorID, objectID int64, o
} }
// DescribeResult is a structured summary of a garden for prompting an agent. // DescribeResult is a structured summary of a garden for prompting an agent.
// Version and Notes are here for update_garden: the version is its guard, and
// the notes are the whole text a new note has to be merged into.
type DescribeResult struct { type DescribeResult struct {
GardenID int64 `json:"gardenId"` GardenID int64 `json:"gardenId"`
Name string `json:"name"` Name string `json:"name"`
WidthCM float64 `json:"widthCm"` WidthCM float64 `json:"widthCm"`
HeightCM float64 `json:"heightCm"` HeightCM float64 `json:"heightCm"`
UnitPref string `json:"unitPref"` UnitPref string `json:"unitPref"`
Objects []DescribeObject `json:"objects"` GridSizeCM float64 `json:"gridSizeCm"`
Notes string `json:"notes,omitempty"`
Version int64 `json:"version"`
// Year is set on a season view: the plantings are then every plop whose time
// in the ground overlapped that year, pulled ones included, rather than what
// is growing now.
Year *int `json:"year,omitempty"`
Objects []DescribeObject `json:"objects"`
} }
// DescribeObject is one object plus its active plantings grouped by plant, for // DescribeObject is one object plus its active plantings grouped by plant, for
@@ -605,6 +614,11 @@ type DescribeGroup struct {
// DaysToMaturity is the plant's, when the catalog knows it — with PlantedAt, // DaysToMaturity is the plant's, when the catalog knows it — with PlantedAt,
// enough to say when the harvest is due. // enough to say when the harvest is due.
DaysToMaturity *int `json:"daysToMaturity,omitempty"` DaysToMaturity *int `json:"daysToMaturity,omitempty"`
// Removed counts the plops in the group that have been pulled, and RemovedAt
// is when ("first…last" when they differ). Only a season view lists pulled
// plops, so both are absent from a describe of what is growing now.
Removed int `json:"removed,omitempty"`
RemovedAt string `json:"removedAt,omitempty"`
// Each lists the plops individually (id, version, position, location) only // Each lists the plops individually (id, version, position, location) only
// when the group has at most maxListedPlops of them. // when the group has at most maxListedPlops of them.
Each []DescribePlanting `json:"each,omitempty"` Each []DescribePlanting `json:"each,omitempty"`
@@ -626,14 +640,18 @@ type DescribePlanting struct {
Location string `json:"location"` Location string `json:"location"`
RadiusCM float64 `json:"radiusCm"` RadiusCM float64 `json:"radiusCm"`
PlantedAt string `json:"plantedAt,omitempty"` PlantedAt string `json:"plantedAt,omitempty"`
// RemovedAt is set on a pulled plop, which only a season view lists.
RemovedAt string `json:"removedAt,omitempty"`
} }
// DescribeGarden returns a structured summary — dimensions, objects, and each // DescribeGarden returns a structured summary — dimensions, objects, and each
// object's active plantings grouped by plant (count, rough location, planting // object's plantings grouped by plant (count, rough location, planting date) —
// date) — for a garden the actor can view. Built on GardenFull so it inherits // for a garden the actor can view. year nil describes what is growing now; a
// the ACL check. // year is the season view, every plop whose time in the ground overlapped it,
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) { // pulled ones included — what "what was in this bed last year?" needs. Built
full, err := s.GardenFull(ctx, actorID, gardenID, nil) // on GardenFull so it inherits the ACL check and the year's bounds.
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64, year *int) (*DescribeResult, error) {
full, err := s.GardenFull(ctx, actorID, gardenID, year)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -648,12 +666,16 @@ func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (
} }
res := &DescribeResult{ res := &DescribeResult{
GardenID: full.Garden.ID, GardenID: full.Garden.ID,
Name: full.Garden.Name, Name: full.Garden.Name,
WidthCM: full.Garden.WidthCM, WidthCM: full.Garden.WidthCM,
HeightCM: full.Garden.HeightCM, HeightCM: full.Garden.HeightCM,
UnitPref: full.Garden.UnitPref, UnitPref: full.Garden.UnitPref,
Objects: make([]DescribeObject, 0, len(full.Objects)), GridSizeCM: full.Garden.GridSizeCM,
Notes: full.Garden.Notes,
Version: full.Garden.Version,
Year: year,
Objects: make([]DescribeObject, 0, len(full.Objects)),
} }
for i := range full.Objects { for i := range full.Objects {
o := &full.Objects[i] o := &full.Objects[i]
@@ -722,9 +744,13 @@ func describeGroups(o *domain.GardenObject, plops []domain.Planting, plantByID m
PlantID: pid, Plant: plant.Name, Plops: len(members), PlantID: pid, Plant: plant.Name, Plops: len(members),
Where: summarizeWhere(o, members), PlantedAt: dateRange(members), Where: summarizeWhere(o, members), PlantedAt: dateRange(members),
DaysToMaturity: plant.DaysToMaturity, DaysToMaturity: plant.DaysToMaturity,
RemovedAt: dateRangeOf(members, func(pl domain.Planting) *string { return pl.RemovedAt }),
} }
for _, pl := range members { for _, pl := range members {
g.Plants += effectiveCount(pl) g.Plants += effectiveCount(pl)
if pl.RemovedAt != nil {
g.Removed++
}
} }
if len(members) <= maxListedPlops { if len(members) <= maxListedPlops {
g.Each = make([]DescribePlanting, 0, len(members)) g.Each = make([]DescribePlanting, 0, len(members))
@@ -746,6 +772,9 @@ func describePlanting(pl domain.Planting, plantName string) DescribePlanting {
if pl.PlantedAt != nil { if pl.PlantedAt != nil {
d.PlantedAt = *pl.PlantedAt d.PlantedAt = *pl.PlantedAt
} }
if pl.RemovedAt != nil {
d.RemovedAt = *pl.RemovedAt
}
return d return d
} }
@@ -759,19 +788,26 @@ func effectiveCount(pl domain.Planting) int {
} }
// dateRange is the planting date shared by a group's plops, "first…last" when // dateRange is the planting date shared by a group's plops, "first…last" when
// they were planted on different days, or "" when none is dated. ISO dates // they were planted on different days, or "" when none is dated.
// order as strings, so min/max need no parsing.
func dateRange(plops []domain.Planting) string { func dateRange(plops []domain.Planting) string {
return dateRangeOf(plops, func(pl domain.Planting) *string { return pl.PlantedAt })
}
// dateRangeOf summarizes one date field across a group's plops: the one date
// they share, "first…last" when they differ, or "" when none is set. ISO dates
// order as strings, so min/max need no parsing.
func dateRangeOf(plops []domain.Planting, pick func(domain.Planting) *string) string {
first, last := "", "" first, last := "", ""
for _, pl := range plops { for _, pl := range plops {
if pl.PlantedAt == nil || *pl.PlantedAt == "" { d := pick(pl)
if d == nil || *d == "" {
continue continue
} }
if first == "" || *pl.PlantedAt < first { if first == "" || *d < first {
first = *pl.PlantedAt first = *d
} }
if *pl.PlantedAt > last { if *d > last {
last = *pl.PlantedAt last = *d
} }
} }
if first == last { if first == last {
+98 -3
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"math" "math"
"reflect"
"sort" "sort"
"testing" "testing"
@@ -439,7 +440,7 @@ func TestOpsForbiddenForViewer(t *testing.T) {
t.Errorf("viewer clear = %v, want ErrForbidden", err) t.Errorf("viewer clear = %v, want ErrForbidden", err)
} }
// But a viewer can DescribeGarden (read). // But a viewer can DescribeGarden (read).
if _, err := s.DescribeGarden(ctx, viewer, g.ID); err != nil { if _, err := s.DescribeGarden(ctx, viewer, g.ID, nil); err != nil {
t.Errorf("viewer describe = %v, want ok", err) t.Errorf("viewer describe = %v, want ok", err)
} }
} }
@@ -470,7 +471,7 @@ func TestFillScenario(t *testing.T) {
fill("nw", basil.ID) fill("nw", basil.ID)
fill("south", beans.ID) fill("south", beans.ID)
desc, err := s.DescribeGarden(ctx, owner, g.ID) desc, err := s.DescribeGarden(ctx, owner, g.ID, nil)
if err != nil { if err != nil {
t.Fatalf("DescribeGarden: %v", err) t.Fatalf("DescribeGarden: %v", err)
} }
@@ -585,7 +586,7 @@ func TestDescribeGardenGroupsByPlant(t *testing.T) {
} }
} }
desc, err := s.DescribeGarden(ctx, owner, g.ID) desc, err := s.DescribeGarden(ctx, owner, g.ID, nil)
if err != nil { if err != nil {
t.Fatalf("DescribeGarden: %v", err) t.Fatalf("DescribeGarden: %v", err)
} }
@@ -825,3 +826,97 @@ func TestFillByRectangleAttributesSeed(t *testing.T) {
t.Errorf("overhanging rectangle: %d plops, %v; want some", len(created), err) t.Errorf("overhanging rectangle: %d plops, %v; want some", len(created), err)
} }
} }
// TestDescribeGardenByYear — "what was in this bed last year?" is the question
// rotation advice hangs on, and a describe of what is growing now cannot answer
// it. With a year, describe is the season view: every plop whose time in the
// ground overlapped the year, pulled ones included, each group saying how many
// came out and when.
func TestDescribeGardenByYear(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Seasons", WidthCM: 2000, HeightCM: 2000, Notes: "Zone 6a"})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed := seedFillBed(t, s, owner, g.ID, 400, 400)
garlic := seedNamedPlant(t, s, owner, "Garlic", 15)
beans := seedNamedPlant(t, s, owner, "Beans", 10)
basil := seedNamedPlant(t, s, owner, "Basil", 25)
plantAndPull := func(plantID int64, x float64, planted, pulled string) {
t.Helper()
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plantID, XCM: x, YCM: -100, RadiusCM: 20, PlantedAt: &planted})
if err != nil {
t.Fatalf("plant %d: %v", plantID, err)
}
if pulled != "" {
if _, err := s.RemovePlanting(ctx, owner, pl.ID, pl.Version, &pulled); err != nil {
t.Fatalf("pull %d: %v", pl.ID, err)
}
}
}
plantAndPull(garlic.ID, -100, "2025-10-15", "2026-07-01") // overwintered: in both years
plantAndPull(beans.ID, 0, "2025-05-01", "2025-09-01") // 2025 only
plantAndPull(basil.ID, 100, "2026-06-01", "") // growing now
groupsOf := func(year *int) map[string]DescribeGroup {
t.Helper()
desc, err := s.DescribeGarden(ctx, owner, g.ID, year)
if err != nil {
t.Fatalf("DescribeGarden(%v): %v", year, err)
}
if (year == nil) != (desc.Year == nil) || (year != nil && *desc.Year != *year) {
t.Errorf("describe(%v) reports year %v", year, desc.Year)
}
if desc.Notes != "Zone 6a" || desc.Version != g.Version {
t.Errorf("describe carries notes %q version %d; want the garden's (%q, %d)", desc.Notes, desc.Version, "Zone 6a", g.Version)
}
out := map[string]DescribeGroup{}
for _, gr := range desc.Objects[0].Plantings {
out[gr.Plant] = gr
}
return out
}
names := func(m map[string]DescribeGroup) []string {
var out []string
for n := range m {
out = append(out, n)
}
sort.Strings(out)
return out
}
now := groupsOf(nil)
if got := names(now); !reflect.DeepEqual(got, []string{"Basil"}) {
t.Errorf("now = %v, want only the basil still growing", got)
}
if b := now["Basil"]; b.Removed != 0 || b.RemovedAt != "" || b.Each[0].RemovedAt != "" {
t.Errorf("a live plop reports a removal: %+v", b)
}
y2025 := 2025
last := groupsOf(&y2025)
if got := names(last); !reflect.DeepEqual(got, []string{"Beans", "Garlic"}) {
t.Errorf("2025 = %v, want the beans and the overwintered garlic", got)
}
if b := last["Beans"]; b.Removed != 1 || b.RemovedAt != "2025-09-01" || b.PlantedAt != "2025-05-01" {
t.Errorf("2025 beans = %+v; want 1 removed on 2025-09-01, planted 2025-05-01", b)
}
if gl := last["Garlic"]; gl.Removed != 1 || gl.RemovedAt != "2026-07-01" || len(gl.Each) != 1 || gl.Each[0].RemovedAt != "2026-07-01" {
t.Errorf("2025 garlic = %+v; want its 2026 removal on the group and the plop", gl)
}
y2026 := 2026
this := groupsOf(&y2026)
if got := names(this); !reflect.DeepEqual(got, []string{"Basil", "Garlic"}) {
t.Errorf("2026 = %v, want the basil and the garlic pulled in July", got)
}
// A typo'd year is refused, not an empty garden.
bad := 20026
if _, err := s.DescribeGarden(ctx, owner, g.ID, &bad); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("describe(20026) err = %v, want ErrInvalidInput", err)
}
}
+1 -1
View File
@@ -936,7 +936,7 @@ func TestRecordOutsideTheOpenScopeFilesUnderItsOwnGarden(t *testing.T) {
if _, conflicts, err := s.RevertChangeSet(ctx, owner, got.ID, domain.SourceUI); err != nil || len(conflicts) != 0 { if _, conflicts, err := s.RevertChangeSet(ctx, owner, got.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("undo from B: err=%v conflicts=%+v", err, conflicts) t.Fatalf("undo from B: err=%v conflicts=%+v", err, conflicts)
} }
if d, err := s.DescribeGarden(ctx, owner, b.ID); err != nil || len(d.Objects) != 1 || d.Objects[0].Name != "Bed" { if d, err := s.DescribeGarden(ctx, owner, b.ID, nil); err != nil || len(d.Objects) != 1 || d.Objects[0].Name != "Bed" {
t.Errorf("after undo B is %+v (%v), want the bed's name back", d, err) t.Errorf("after undo B is %+v (%v), want the bed's name back", d, err)
} }
} }
+6
View File
@@ -98,6 +98,7 @@ export interface AgentStep {
const TOOL_LABELS: Record<string, string> = { const TOOL_LABELS: Record<string, string> = {
list_gardens: 'Looking at your gardens', list_gardens: 'Looking at your gardens',
describe_garden: 'Reading the garden', describe_garden: 'Reading the garden',
list_years: 'Checking past seasons',
list_plantings: 'Reading a bed', list_plantings: 'Reading a bed',
create_object: 'Adding a bed', create_object: 'Adding a bed',
move_object: 'Moving a bed', move_object: 'Moving a bed',
@@ -106,6 +107,7 @@ const TOOL_LABELS: Record<string, string> = {
place_planting: 'Planting', place_planting: 'Planting',
fill_region: 'Filling a bed', fill_region: 'Filling a bed',
move_planting: 'Moving a plant', move_planting: 'Moving a plant',
update_planting: 'Correcting a planting',
remove_planting: 'Pulling a plant', remove_planting: 'Pulling a plant',
remove_plantings: 'Pulling plants', remove_plantings: 'Pulling plants',
clear_object: 'Clearing a bed', clear_object: 'Clearing a bed',
@@ -114,10 +116,14 @@ const TOOL_LABELS: Record<string, string> = {
update_plant: 'Updating your catalog', update_plant: 'Updating your catalog',
add_journal_entry: 'Writing a journal note', add_journal_entry: 'Writing a journal note',
read_journal: 'Reading the journal', read_journal: 'Reading the journal',
update_journal_entry: 'Correcting a journal note',
delete_journal_entry: 'Deleting a journal note',
read_history: 'Reading the history', read_history: 'Reading the history',
undo_change: 'Undoing a change',
list_seed_lots: 'Checking your seed', list_seed_lots: 'Checking your seed',
record_seed_lot: 'Recording seed', record_seed_lot: 'Recording seed',
copy_garden: 'Copying the garden', copy_garden: 'Copying the garden',
update_garden: 'Changing the garden',
} }
export function describeStep(step: AgentStep): string { export function describeStep(step: AgentStep): string {