Agent: catalog and garden tools, and a ready date on every describe group
Build image / build-and-push (push) Successful in 11s
Gadfly review (reusable) / review (pull_request) Successful in 4m42s
Adversarial Review (Gadfly) / review (pull_request) Successful in 4m42s

- 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 <[email protected]>
This commit is contained in:
2026-08-23 02:05:19 -04:00
co-authored by Claude Fable 5
parent 35b27de8a0
commit b4c8007977
7 changed files with 299 additions and 3 deletions
+7 -2
View File
@@ -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
+97
View File
@@ -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, 0100"`
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
}
+97
View File
@@ -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")
}
}
+29
View File
@@ -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 {
+64
View File
@@ -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, "[email protected]")
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)
}
}