From b4c8007977ee72dc73d85385c1c20e1de5af77cc Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 23 Aug 2026 01:53:48 -0400 Subject: [PATCH 1/2] Agent: catalog and garden tools, and a ready date on every describe group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update_seed_lot / delete_seed_lot: correct or drop a recorded purchase ("it was three packets, not two"); the plant a lot is for stays fixed. - delete_plant: remove a duplicate from the user's catalog. The service already refuses while plantings (past seasons included) or a lot reference it; the tool turns that sentinel into words the model can pass on, and tells it not to clear those references to get its way. - create_garden: a new place, with the service's defaults; the prompt says a plan is still a copy_garden. - describe_garden groups carry readyAround — planting date plus days to maturity for the plops still in the ground — so "what can I pick this week?" is a lookup rather than arithmetic the model got wrong live. Co-Authored-By: Claude Fable 5 --- DESIGN.md | 2 +- internal/agent/runtime.go | 9 +++- internal/agent/tools.go | 97 ++++++++++++++++++++++++++++++++++++ internal/agent/tools_test.go | 97 ++++++++++++++++++++++++++++++++++++ internal/service/ops.go | 29 +++++++++++ internal/service/ops_test.go | 64 ++++++++++++++++++++++++ web/src/lib/agent.ts | 4 ++ 7 files changed, 299 insertions(+), 3 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index e73136a..5ef87e8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -149,7 +149,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac 7. **Sharing** — invite by email, roles, viewer read-only mode. 8. **Polish** — imperial toggle, mobile ergonomics, clear-bed, keyboard nudging. 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 "" 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. +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 "" 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. Round two added the catalog side — `update_seed_lot`/`delete_seed_lot`, `delete_plant` (refused while anything references the plant), `create_garden` — and `readyAround` on each describe group (planting date + days to maturity, pulled plops excluded), so "what can I pick this week?" is a lookup rather than arithmetic the model gets wrong. ## Deliberate v1 limits diff --git a/internal/agent/runtime.go b/internal/agent/runtime.go index 7453a90..3e1e2bf 100644 --- a/internal/agent/runtime.go +++ b/internal/agent/runtime.go @@ -296,8 +296,13 @@ How to work: 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. + entry's text or date, the garden's notes, a seed lot — use update_planting, update_journal_entry, + update_garden and update_seed_lot instead of removing and re-adding. +- "What can I pick soon?": each group in describe_garden carries readyAround, its planting date + plus the plant's days to maturity. Compare that with today rather than doing the sums yourself; + a group without it is a plant the catalog has no days for. +- A new garden (another place, not a plan) is create_garden; it opens from the gardens list, and + this conversation stays with the garden it started in. - 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 diff --git a/internal/agent/tools.go b/internal/agent/tools.go index 7e41a07..e61657b 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -2,6 +2,7 @@ package agent import ( "context" + "errors" "fmt" "sort" "strconv" @@ -211,6 +212,29 @@ func newToolbox(svc *service.Service, actorID int64, today string) (*llm.Toolbox "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), + llm.DefineTool("create_garden", + "Start a NEW garden for the user — a different place (the front yard, the allotment), "+ + "sized in cm and owned by them. Not for a season plan: a plan is a copy_garden of the "+ + "real garden. The new garden opens from the gardens list; this conversation stays with "+ + "the one it started in.", + a.createGarden), + llm.DefineTool("update_seed_lot", + "Correct a seed lot the user recorded — quantity, unit, vendor, link, purchase date, "+ + "packed-for year, germination rate or notes: \"it was three packets, not two\". Needs "+ + "the lot's id and version from list_seed_lots. Only the fields you pass change; the "+ + "plant a lot is for cannot change (record a new lot instead).", + a.updateSeedLot), + llm.DefineTool("delete_seed_lot", + "Delete a seed lot the user recorded, by its id from list_seed_lots. Plantings attributed "+ + "to it stay in the garden, just no longer linked to a purchase. Permanent — seed lots "+ + "are not in the undo history — so delete only the lot the user pointed at.", + a.deleteSeedLot), + llm.DefineTool("delete_plant", + "Delete a plant from the user's own catalog — a duplicate or a mistake. Refused while "+ + "anything is planted with it (in any garden, past seasons included) or a seed lot "+ + "records it; say so rather than removing those to make it deletable. Built-in plants "+ + "can't be deleted. Permanent — the catalog is not in the undo history.", + a.deletePlant), ), a } @@ -834,3 +858,76 @@ func (a *adapter) updateGarden(ctx context.Context, args struct { } return a.svc.UpdateGarden(ctx, a.actor, args.GardenID, in, args.Version) } + +func (a *adapter) createGarden(ctx context.Context, args struct { + Name string `json:"name" description:"the garden's name"` + WidthCM float64 `json:"widthCm" description:"optional width in cm (default 1000)"` + HeightCM float64 `json:"heightCm" description:"optional height in cm (default 1000)"` + UnitPref string `json:"units" description:"optional: metric (default) | imperial — how the gardener wants lengths shown"` + Notes string `json:"notes" description:"optional standing notes about the place — zone, frost dates, soil"` + GridSizeCM float64 `json:"gridSizeCm" description:"optional editor grid spacing in cm (default 100)"` + SnapToGrid bool `json:"snapToGrid" description:"optional: snap objects to that grid"` +}) (any, error) { + return a.svc.CreateGarden(ctx, a.actor, service.GardenInput{ + Name: args.Name, WidthCM: args.WidthCM, HeightCM: args.HeightCM, + UnitPref: strings.ToLower(strings.TrimSpace(args.UnitPref)), Notes: args.Notes, + GridSizeCM: args.GridSizeCM, SnapToGrid: args.SnapToGrid, + }) +} + +func (a *adapter) updateSeedLot(ctx context.Context, args struct { + LotID int64 `json:"lotId" description:"seed lot to correct (its id from list_seed_lots)"` + Version int64 `json:"version" description:"the lot's current version (from list_seed_lots)"` + Quantity *float64 `json:"quantity" description:"optional corrected quantity, in the lot's unit"` + Unit *string `json:"unit" description:"optional unit: seeds | grams | ounces | packets | bulbs | plants"` + Vendor *string `json:"vendor" description:"optional vendor name"` + SourceURL *string `json:"sourceUrl" description:"optional http(s) link to where it was bought; empty clears it"` + PurchasedAt *string `json:"purchasedAt" description:"optional purchase date, YYYY-MM-DD"` + PackedForYear *int `json:"packedForYear" description:"optional 'packed for' year from the packet"` + GerminationPct *float64 `json:"germinationPct" description:"optional germination rate, 0–100"` + Notes *string `json:"notes" description:"optional replacement notes"` +}) (any, error) { + if args.Quantity == nil && args.Unit == nil && args.Vendor == nil && args.SourceURL == nil && + args.PurchasedAt == nil && args.PackedForYear == nil && args.GerminationPct == nil && args.Notes == nil { + return nil, fmt.Errorf("%w: say what to change about the lot", domain.ErrInvalidInput) + } + purchased := args.PurchasedAt + if purchased != nil { + on, err := parseDay(*purchased) + if err != nil { + return nil, err + } + purchased = &on + } + patch := service.SeedLotPatch{ + Vendor: args.Vendor, SourceURL: args.SourceURL, Quantity: args.Quantity, Unit: args.Unit, Notes: args.Notes, + SetPurchasedAt: purchased != nil, PurchasedAt: purchased, + SetPackedForYear: args.PackedForYear != nil, PackedForYear: args.PackedForYear, + SetGerminationPct: args.GerminationPct != nil, GerminationPct: args.GerminationPct, + } + return a.svc.UpdateSeedLot(ctx, a.actor, args.LotID, patch, args.Version) +} + +func (a *adapter) deleteSeedLot(ctx context.Context, args struct { + LotID int64 `json:"lotId" description:"seed lot to delete (its id from list_seed_lots)"` +}) (any, error) { + if err := a.svc.DeleteSeedLot(ctx, a.actor, args.LotID); err != nil { + return nil, err + } + return map[string]any{"deleted": args.LotID}, nil +} + +func (a *adapter) deletePlant(ctx context.Context, args struct { + PlantID int64 `json:"plantId" description:"plant to delete from the user's catalog (from find_plant)"` +}) (any, error) { + err := a.svc.DeletePlant(ctx, a.actor, args.PlantID) + if errors.Is(err, domain.ErrPlantInUse) { + // The sentinel's text is for a log line; the model needs to know what + // to tell the person, and what not to do about it. + return nil, fmt.Errorf("%w: the plant is still used — by plantings (past seasons count) or a seed lot — so it stays; tell the user rather than removing those", domain.ErrPlantInUse) + } + if err != nil { + return nil, err + } + return map[string]any{"deleted": args.PlantID}, nil +} diff --git a/internal/agent/tools_test.go b/internal/agent/tools_test.go index c800720..6e5cffd 100644 --- a/internal/agent/tools_test.go +++ b/internal/agent/tools_test.go @@ -931,3 +931,100 @@ func TestRecordKeepingTools(t *testing.T) { t.Errorf("journal after the delete = %+v, want empty", journal.Entries) } } + +// TestCatalogAndGardenTools — the catalog side of the record: correct or delete +// a seed lot, delete a duplicate plant (refused while anything references it, +// in words the model can pass on), and start a new garden with sane defaults. +func TestCatalogAndGardenTools(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) + } + } + } + + // --- create_garden: defaults, then an imperial one with notes. + var g domain.Garden + mustCall("create_garden", map[string]any{"name": "Front yard"}, &g) + if g.ID == 0 || g.WidthCM != 1000 || g.HeightCM != 1000 || g.UnitPref != domain.UnitMetric || g.MyRole != domain.RoleOwner { + t.Errorf("default garden = %+v", g) + } + var imperial domain.Garden + mustCall("create_garden", map[string]any{"name": "Allotment", "widthCm": 609.6, "heightCm": 304.8, "units": "Imperial", "notes": "Zone 6a"}, &imperial) + if imperial.UnitPref != domain.UnitImperial || imperial.Notes != "Zone 6a" || imperial.WidthCM != 609.6 { + t.Errorf("imperial garden = %+v", imperial) + } + if r := call("create_garden", map[string]any{"name": " "}); !r.IsError { + t.Error("a garden with a blank name was created") + } + + // --- seed lots: record, correct, delete. + cp := mustPlant(t, svc, owner, "Cherokee Purple", 60, "🍅") + var lot domain.SeedLot + mustCall("record_seed_lot", map[string]any{"plantId": cp.ID, "quantity": 2, "unit": "packets", "vendor": "Baker Creek"}, &lot) + if r := call("update_seed_lot", map[string]any{"lotId": lot.ID, "version": lot.Version}); !r.IsError || !strings.Contains(r.Content, "what to change") { + t.Errorf("update_seed_lot with nothing to change = %q", r.Content) + } + if r := call("update_seed_lot", map[string]any{"lotId": lot.ID, "version": lot.Version, "purchasedAt": "last spring"}); !r.IsError || !strings.Contains(r.Content, "YYYY-MM-DD") { + t.Errorf("a prose purchase date = %q, want a refusal naming the format", r.Content) + } + mustCall("update_seed_lot", map[string]any{"lotId": lot.ID, "version": lot.Version, "quantity": 3, "packedForYear": 2026, "purchasedAt": "2026-02-01"}, &lot) + if lot.Quantity != 3 || lot.Remaining != 3 || lot.Vendor != "Baker Creek" || lot.PackedForYear == nil || *lot.PackedForYear != 2026 || lot.PurchasedAt == nil || *lot.PurchasedAt != "2026-02-01" { + t.Errorf("corrected lot = %+v; want quantity 3 (all remaining), vendor kept, year and date set", lot) + } + + // --- delete_plant: refused while the lot references it, in plain words. + if r := call("delete_plant", map[string]any{"plantId": cp.ID}); !r.IsError || !strings.Contains(r.Content, "seed lot") { + t.Errorf("delete_plant with a lot = %q, want a refusal that names the lot", r.Content) + } + mustCall("delete_seed_lot", map[string]any{"lotId": lot.ID}, nil) + var lots []domain.SeedLot + mustCall("list_seed_lots", map[string]any{"plantId": cp.ID}, &lots) + if len(lots) != 0 { + t.Errorf("lots after delete = %+v, want none", lots) + } + // ...and while a planting (even a pulled one) references it. + bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "Bed", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200}) + if err != nil { + t.Fatalf("bed: %v", err) + } + var plop domain.Planting + mustCall("place_planting", map[string]any{"objectId": bed.ID, "plantId": cp.ID, "xCm": 0, "yCm": 0}, &plop) + mustCall("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version}, nil) + if r := call("delete_plant", map[string]any{"plantId": cp.ID}); !r.IsError || !strings.Contains(r.Content, "past seasons") { + t.Errorf("delete_plant with a pulled planting = %q, want a refusal that says past seasons count", r.Content) + } + if err := svc.DeletePlanting(ctx, owner, plop.ID); err != nil { + t.Fatalf("hard delete: %v", err) + } + mustCall("delete_plant", map[string]any{"plantId": cp.ID}, nil) + var matches []struct{ ID int64 } + mustCall("find_plant", map[string]any{"query": "Cherokee Purple"}, &matches) + for _, m := range matches { + if m.ID == cp.ID { + t.Error("the deleted plant is still in the catalog") + } + } + // Built-ins are not the user's to delete. + mustCall("find_plant", map[string]any{"query": "tomato"}, &matches) + if len(matches) == 0 { + t.Fatal("no built-in tomato to test with") + } + if r := call("delete_plant", map[string]any{"plantId": matches[0].ID}); !r.IsError { + t.Error("a built-in plant was deleted") + } +} diff --git a/internal/service/ops.go b/internal/service/ops.go index 50f952c..df45f15 100644 --- a/internal/service/ops.go +++ b/internal/service/ops.go @@ -7,6 +7,7 @@ import ( "log/slog" "math" "strings" + "time" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) @@ -614,6 +615,11 @@ type DescribeGroup struct { // DaysToMaturity is the plant's, when the catalog knows it — with PlantedAt, // enough to say when the harvest is due. DaysToMaturity *int `json:"daysToMaturity,omitempty"` + // ReadyAround is that arithmetic done: planting date plus days to maturity + // for the plops still in the ground, as one date or "first…last". Absent + // when the catalog has no days for the plant or nothing is dated. The + // model was asked "what can I pick this week?" and got the sums wrong. + ReadyAround string `json:"readyAround,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. @@ -746,6 +752,15 @@ func describeGroups(o *domain.GardenObject, plops []domain.Planting, plantByID m DaysToMaturity: plant.DaysToMaturity, RemovedAt: dateRangeOf(members, func(pl domain.Planting) *string { return pl.RemovedAt }), } + if plant.DaysToMaturity != nil { + days := *plant.DaysToMaturity + g.ReadyAround = dateRangeOf(members, func(pl domain.Planting) *string { + if pl.RemovedAt != nil { + return nil // pulled already; its harvest is not ahead of us + } + return readyDate(pl.PlantedAt, days) + }) + } for _, pl := range members { g.Plants += effectiveCount(pl) if pl.RemovedAt != nil { @@ -778,6 +793,20 @@ func describePlanting(pl domain.Planting, plantName string) DescribePlanting { return d } +// readyDate is plantedAt plus days to maturity, or nil when the plop is undated +// (or its date is not one the store should have accepted). +func readyDate(plantedAt *string, days int) *string { + if plantedAt == nil || *plantedAt == "" { + return nil + } + t, err := time.Parse(dateLayout, *plantedAt) + if err != nil { + return nil + } + d := t.AddDate(0, 0, days).Format(dateLayout) + return &d +} + // effectiveCount is the plant count a plop stands for: its explicit count, else // the one derived from its area and the plant's spacing. func effectiveCount(pl domain.Planting) int { diff --git a/internal/service/ops_test.go b/internal/service/ops_test.go index 81da834..1bede6c 100644 --- a/internal/service/ops_test.go +++ b/internal/service/ops_test.go @@ -920,3 +920,67 @@ func TestDescribeGardenByYear(t *testing.T) { t.Errorf("describe(20026) err = %v, want ErrInvalidInput", err) } } + +// TestDescribeGroupSaysWhenReady — "what can I pick this week?" is a lookup +// when the group carries the date, and a sum the model gets wrong when it +// doesn't. Planting date plus days to maturity, for the plops still in the +// ground; nothing for a plant the catalog has no days for. +func TestDescribeGroupSaysWhenReady(t *testing.T) { + ctx := context.Background() + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Harvest", WidthCM: 2000, HeightCM: 2000}) + if err != nil { + t.Fatalf("garden: %v", err) + } + bed := seedFillBed(t, s, owner, g.ID, 400, 400) + sixty := 60 + radish, err := s.CreatePlant(ctx, owner, PlantInput{Name: "Radish", Category: domain.CategoryVegetable, SpacingCM: 5, Color: "#c33", Icon: "🌱", DaysToMaturity: &sixty}) + if err != nil { + t.Fatalf("radish: %v", err) + } + mint := seedNamedPlant(t, s, owner, "Mint", 30) // no days to maturity + + plant := func(plantID int64, x float64, on string) *domain.Planting { + t.Helper() + pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plantID, XCM: x, YCM: 0, RadiusCM: 10, PlantedAt: &on}) + if err != nil { + t.Fatalf("plant: %v", err) + } + return pl + } + plant(radish.ID, -100, "2026-05-01") + plant(radish.ID, 0, "2026-05-11") + pulled := plant(radish.ID, 100, "2026-03-01") + on := "2026-04-20" + if _, err := s.RemovePlanting(ctx, owner, pulled.ID, pulled.Version, &on); err != nil { + t.Fatalf("pull: %v", err) + } + plant(mint.ID, 150, "2026-05-01") + + groups := func(year *int) map[string]DescribeGroup { + t.Helper() + desc, err := s.DescribeGarden(ctx, owner, g.ID, year) + if err != nil { + t.Fatalf("describe: %v", err) + } + out := map[string]DescribeGroup{} + for _, gr := range desc.Objects[0].Plantings { + out[gr.Plant] = gr + } + return out + } + now := groups(nil) + if got := now["Radish"].ReadyAround; got != "2026-06-30…2026-07-10" { + t.Errorf("radish readyAround = %q, want %q", got, "2026-06-30…2026-07-10") + } + if got := now["Mint"].ReadyAround; got != "" { + t.Errorf("mint has no days to maturity but readyAround = %q", got) + } + // The season view lists the pulled radish too, but its harvest is behind + // us: the range is still the two still growing. + y := 2026 + if got := groups(&y)["Radish"]; got.Removed != 1 || got.ReadyAround != "2026-06-30…2026-07-10" { + t.Errorf("2026 radish = removed %d, readyAround %q; want 1 and the live plops' range", got.Removed, got.ReadyAround) + } +} diff --git a/web/src/lib/agent.ts b/web/src/lib/agent.ts index 508ef1d..0c5d07a 100644 --- a/web/src/lib/agent.ts +++ b/web/src/lib/agent.ts @@ -122,8 +122,12 @@ const TOOL_LABELS: Record = { undo_change: 'Undoing a change', list_seed_lots: 'Checking your seed', record_seed_lot: 'Recording seed', + update_seed_lot: 'Correcting a seed lot', + delete_seed_lot: 'Deleting a seed lot', + delete_plant: 'Removing a plant from your catalog', copy_garden: 'Copying the garden', update_garden: 'Changing the garden', + create_garden: 'Starting a garden', } export function describeStep(step: AgentStep): string { -- 2.54.0 From c9076e84c498fb39c3a0947b23ed6bc439d1931c Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 23 Aug 2026 02:11:27 -0400 Subject: [PATCH 2/2] Address #130 review: one toolCaller helper for the tool tests The call/mustCall closures were copied between TestRecordKeepingTools and TestCatalogAndGardenTools; both now use a file-level toolCaller. The other notes are left as they are: the 'nothing to change' guard enumerates the args on purpose (it is the tool's own contract, next to the struct it checks), and wrapping a sentinel with %w is how every readable refusal in this package is built. Co-Authored-By: Claude Fable 5 --- internal/agent/tools_test.go | 62 +++++++++++++++++------------------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/internal/agent/tools_test.go b/internal/agent/tools_test.go index 6e5cffd..1a8b0b5 100644 --- a/internal/agent/tools_test.go +++ b/internal/agent/tools_test.go @@ -123,6 +123,34 @@ func TestToolboxScenario(t *testing.T) { } } +// toolCaller returns the two ways a test drives a toolbox: call executes a +// tool with JSON-encoded args and hands back the raw result (for asserting on +// refusals), and mustCall fails the test on a tool error and decodes the +// result into `into` when one is given. +func toolCaller(t *testing.T, ctx context.Context, box *llm.Toolbox) ( + call func(name string, args any) llm.ToolResult, + mustCall func(name string, args any, into any), +) { + t.Helper() + 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) + } + } + } + return call, mustCall +} + func mustJSON(t *testing.T, v any) json.RawMessage { t.Helper() b, err := json.Marshal(v) @@ -750,22 +778,7 @@ func TestRecordKeepingTools(t *testing.T) { 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) - } - } - } + call, mustCall := toolCaller(t, ctx, box) g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Home", WidthCM: 1200, HeightCM: 800, Notes: "Zone 6a."}) if err != nil { @@ -940,22 +953,7 @@ func TestCatalogAndGardenTools(t *testing.T) { 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) - } - } - } + call, mustCall := toolCaller(t, ctx, box) // --- create_garden: defaults, then an imperial one with notes. var g domain.Garden -- 2.54.0