From 887a3c2cc644d73ae2a146d572482abe6c82ad6d Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Wed, 22 Jul 2026 21:23:46 -0400 Subject: [PATCH 1/2] Agent: add the corrective tools the toolbox was missing (#85 item 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolbox could create and move but not delete or resize; write the journal but not read it; clear a whole bed but not pull one plant; report seed remaining but not record a purchase. Close those gaps with thin adapters over the SAME service methods the REST API uses, so they inherit the permission checks unchanged: read_journal → ListJournal (the write/read asymmetry, most visible) update_object → UpdateObject (resize / rotate / rename / plantable) delete_object → DeleteObject (counterpart to create_object) remove_planting → UpdatePlanting (soft-remove ONE plop, like clear does) list_seed_lots → ListSeedLots record_seed_lot → CreateSeedLot (record a purchase; "I bought 2 packets") To address a single plop the agent needs its id + version, so DescribePlanting now carries both — the same way DescribeObject.Version already lets it edit an object. remove_planting soft-removes (removed_at = today), mirroring clear_object, so the plant stays in planting history and the change is undoable. Deferred deliberately: an undo/revert tool needs a way to list recent change sets to get a changeSetId, which is a larger addition; noted on the issue for a follow-up. Tested through the tool layer (TestCorrectiveTools): resize, single-plop removal, journal read-back, seed-lot record+list, and delete. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- internal/agent/tools.go | 112 ++++++++++++++++++++++++++++++++ internal/agent/tools_test.go | 121 +++++++++++++++++++++++++++++++++++ internal/service/ops.go | 6 ++ 3 files changed, 239 insertions(+) diff --git a/internal/agent/tools.go b/internal/agent/tools.go index 021a6d9..0dae664 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -2,6 +2,7 @@ package agent import ( "context" + "time" "gitea.stevedudenhoeffer.com/steve/majordomo/llm" @@ -64,6 +65,40 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox { "(\"powdery mildew on the west bed\", \"first frost\"), not for descriptions of what a "+ "thing is. observedAt defaults to today; set it to backdate.", a.addJournalEntry), + llm.DefineTool("read_journal", + "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 "+ + "recently observed first. Use this to answer \"what did I note about the west bed?\" "+ + "or \"what happened last spring?\".", + a.readJournal), + llm.DefineTool("update_object", + "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 "+ + "you pass change. Needs the object's current version from describe_garden. Example: "+ + "\"make that bed 60cm wider\" — read its widthCm from describe_garden, add 60, pass the "+ + "sum.", + a.updateObject), + llm.DefineTool("delete_object", + "Delete an object from a garden entirely, along with its plantings. This is the "+ + "counterpart to create_object — use it for \"remove the old grow bag\". Permanent (not "+ + "the same as clearing a bed's plants); prefer clear_object when the bed itself stays.", + a.deleteObject), + llm.DefineTool("remove_planting", + "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 "+ + "bed does. Needs the plop's id and version from describe_garden. Use for \"pull the "+ + "basil out of the corner\".", + a.removePlanting), + llm.DefineTool("list_seed_lots", + "List the seed lots (purchases) the user has recorded — vendor, quantity, and what's "+ + "left — optionally for one plant via plantId. This is the detail behind the \"seed "+ + "remaining\" number find_plant reports.", + a.listSeedLots), + llm.DefineTool("record_seed_lot", + "Record a seed purchase for a plant the user owns, so pansy can track how much is left. "+ + "Get the plantId from find_plant first. quantity + unit is what was bought (e.g. 2 "+ + "\"packets\", or 500 \"seeds\"). Use for \"I bought two packets of Cherokee Purple\".", + a.recordSeedLot), ) } @@ -177,3 +212,80 @@ func (a *adapter) clearObject(ctx context.Context, args struct { } return map[string]int{"cleared": n}, nil } + +func (a *adapter) readJournal(ctx context.Context, args struct { + 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"` + From string `json:"from" description:"optional earliest observed date, YYYY-MM-DD"` + To string `json:"to" description:"optional latest observed date, YYYY-MM-DD"` +}) (any, error) { + q := service.JournalQuery{ObjectID: args.ObjectID, Limit: 50} + if args.From != "" { + q.From = &args.From + } + if args.To != "" { + q.To = &args.To + } + entries, hasMore, err := a.svc.ListJournal(ctx, a.actor, args.GardenID, q) + if err != nil { + return nil, err + } + return map[string]any{"entries": entries, "hasMore": hasMore}, nil +} + +func (a *adapter) updateObject(ctx context.Context, args struct { + ObjectID int64 `json:"objectId" description:"object to change"` + Version int64 `json:"version" description:"the object's current version (from describe_garden)"` + Name *string `json:"name" description:"optional new label"` + WidthCM *float64 `json:"widthCm" description:"optional new width in cm (a circle's diameter)"` + HeightCM *float64 `json:"heightCm" description:"optional new height in cm"` + RotationDeg *float64 `json:"rotationDeg" description:"optional new rotation in degrees"` + Plantable *bool `json:"plantable" description:"optional: whether the object can hold plants"` +}) (any, error) { + return a.svc.UpdateObject(ctx, a.actor, args.ObjectID, service.ObjectPatch{ + Name: args.Name, WidthCM: args.WidthCM, HeightCM: args.HeightCM, + RotationDeg: args.RotationDeg, Plantable: args.Plantable, + }, args.Version) +} + +func (a *adapter) deleteObject(ctx context.Context, args struct { + ObjectID int64 `json:"objectId" description:"object to delete (with its plantings)"` +}) (any, error) { + if err := a.svc.DeleteObject(ctx, a.actor, args.ObjectID); err != nil { + return nil, err + } + return map[string]any{"deleted": args.ObjectID}, nil +} + +func (a *adapter) removePlanting(ctx context.Context, args struct { + 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)"` +}) (any, error) { + // Soft-remove: set removed_at to today, mirroring clear_object, so the plop + // stays in the planting history and the change is undoable. + today := time.Now().UTC().Format("2006-01-02") + return a.svc.UpdatePlanting(ctx, a.actor, args.PlantingID, + service.PlantingPatch{SetRemovedAt: true, RemovedAt: &today}, args.Version) +} + +func (a *adapter) listSeedLots(ctx context.Context, args struct { + PlantID *int64 `json:"plantId" description:"optional: only lots for this plant"` +}) (any, error) { + return a.svc.ListSeedLots(ctx, a.actor, args.PlantID) +} + +func (a *adapter) recordSeedLot(ctx context.Context, args struct { + PlantID int64 `json:"plantId" description:"plant the seed is for (from find_plant); must be the user's own or a built-in"` + Quantity float64 `json:"quantity" description:"how much was bought, in the given unit"` + Unit string `json:"unit" description:"what quantity counts, e.g. packets | seeds | grams"` + Vendor string `json:"vendor" description:"optional vendor name"` + SourceURL string `json:"sourceUrl" description:"optional http(s) link to where it was bought"` + PackedForYear *int `json:"packedForYear" description:"optional 'packed for' year from the packet"` + Notes string `json:"notes" description:"optional free-text notes"` +}) (any, error) { + return a.svc.CreateSeedLot(ctx, a.actor, service.SeedLotInput{ + PlantID: args.PlantID, Quantity: args.Quantity, Unit: args.Unit, + Vendor: args.Vendor, SourceURL: args.SourceURL, + PackedForYear: args.PackedForYear, Notes: args.Notes, + }) +} diff --git a/internal/agent/tools_test.go b/internal/agent/tools_test.go index 6e06ec8..cf19ae1 100644 --- a/internal/agent/tools_test.go +++ b/internal/agent/tools_test.go @@ -347,6 +347,127 @@ func TestJournalToolWritesADatedObservation(t *testing.T) { } } +// TestCorrectiveTools covers the #85 gaps: the agent can now read the journal it +// could only write, resize and delete an object it could only create and move, +// pull a single plop instead of clearing the whole bed, and record/read seed +// lots. Each is driven through the tool layer the way a model would run it. +func TestCorrectiveTools(t *testing.T) { + ctx := context.Background() + svc, owner := newAgentTestService(t) + box := NewToolbox(svc, owner) + + var gid int64 // set once the garden exists; the describe closure reads it. + call := func(name string, args any) llm.ToolResult { + t.Helper() + return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)}) + } + describe := func() service.DescribeResult { + t.Helper() + res := call("describe_garden", map[string]any{"gardenId": gid}) + if res.IsError { + t.Fatalf("describe_garden: %s", res.Content) + } + var d service.DescribeResult + if err := json.Unmarshal([]byte(res.Content), &d); err != nil { + t.Fatalf("decode describe: %v", err) + } + return d + } + + g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000}) + if err != nil { + t.Fatalf("garden: %v", err) + } + gid = g.ID + basil := mustPlant(t, svc, owner, "Basil", 25, "🌿") + 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) + } + + // update_object: "make that bed 100cm wider" — read the version, pass a new width. + d := describe() + if r := call("update_object", map[string]any{ + "objectId": bed.ID, "version": d.Objects[0].Version, "widthCm": 500.0, + }); r.IsError { + t.Fatalf("update_object: %s", r.Content) + } + if w := describe().Objects[0].WidthCM; w != 500 { + t.Errorf("width = %v after update_object, want 500", w) + } + + // place a plop, then remove_planting it by id+version — one plop, not the bed. + if r := call("place_planting", map[string]any{ + "objectId": bed.ID, "plantId": basil.ID, "xCm": 0, "yCm": 0, "radiusCm": 30, + }); r.IsError { + t.Fatalf("place_planting: %s", r.Content) + } + d = describe() + if len(d.Objects[0].Plantings) != 1 { + t.Fatalf("want 1 plop before removal, got %d", len(d.Objects[0].Plantings)) + } + plop := d.Objects[0].Plantings[0] + if r := call("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version}); r.IsError { + t.Fatalf("remove_planting: %s", r.Content) + } + if n := len(describe().Objects[0].Plantings); n != 0 { + t.Errorf("want 0 active plops after remove_planting, got %d", n) + } + + // add then read the journal — the write/read asymmetry the issue flagged. + if r := call("add_journal_entry", map[string]any{ + "gardenId": g.ID, "objectId": bed.ID, "body": "aphids", "observedAt": "2026-06-01", + }); r.IsError { + t.Fatalf("add_journal_entry: %s", r.Content) + } + res := call("read_journal", map[string]any{"gardenId": g.ID, "objectId": bed.ID}) + if res.IsError { + t.Fatalf("read_journal: %s", res.Content) + } + var jr struct { + Entries []struct { + Body string `json:"body"` + } `json:"entries"` + } + if err := json.Unmarshal([]byte(res.Content), &jr); err != nil { + t.Fatalf("decode read_journal: %v (%s)", err, res.Content) + } + if len(jr.Entries) != 1 || jr.Entries[0].Body != "aphids" { + t.Errorf("read_journal = %+v, want the one aphids entry", jr.Entries) + } + + // record then list a seed lot — the detail behind find_plant's "remaining". + if r := call("record_seed_lot", map[string]any{ + "plantId": basil.ID, "quantity": 2.0, "unit": "packets", "vendor": "Johnny's", + }); r.IsError { + t.Fatalf("record_seed_lot: %s", r.Content) + } + res = call("list_seed_lots", map[string]any{"plantId": basil.ID}) + if res.IsError { + t.Fatalf("list_seed_lots: %s", res.Content) + } + var lots []struct { + Quantity float64 `json:"quantity"` + Unit string `json:"unit"` + } + if err := json.Unmarshal([]byte(res.Content), &lots); err != nil { + t.Fatalf("decode list_seed_lots: %v (%s)", err, res.Content) + } + if len(lots) != 1 || lots[0].Quantity != 2 || lots[0].Unit != "packets" { + t.Errorf("list_seed_lots = %+v, want one lot of 2 packets", lots) + } + + // delete_object: the counterpart to create_object. + if r := call("delete_object", map[string]any{"objectId": bed.ID}); r.IsError { + t.Fatalf("delete_object: %s", r.Content) + } + if n := len(describe().Objects); n != 0 { + t.Errorf("want 0 objects after delete_object, got %d", n) + } +} + // newAgentTestService spins up an in-memory pansy with one registered user. func newAgentTestService(t *testing.T) (*service.Service, int64) { t.Helper() diff --git a/internal/service/ops.go b/internal/service/ops.go index ec6e764..b648fe4 100644 --- a/internal/service/ops.go +++ b/internal/service/ops.go @@ -471,7 +471,11 @@ type DescribeObject struct { } // DescribePlanting is one plop with a rough compass location, for DescribeResult. +// ID + Version are included so an agent can address a single plop — remove it or +// move it — the same way DescribeObject.Version lets it edit an object. type DescribePlanting struct { + ID int64 `json:"id"` + Version int64 `json:"version"` PlantID int64 `json:"plantId"` Plant string `json:"plant"` Count int `json:"count"` @@ -518,6 +522,8 @@ func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) ( count = *pl.Count } do.Plantings = append(do.Plantings, DescribePlanting{ + ID: pl.ID, + Version: pl.Version, PlantID: pl.PlantID, Plant: plantByID[pl.PlantID].Name, Count: count, -- 2.54.0 From 7b150275ae68d259c80aad7b9caa68208846751a Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Wed, 22 Jul 2026 21:39:20 -0400 Subject: [PATCH 2/2] Address #122 review: pageable read_journal, service-clock removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read_journal now takes an offset, so the hasMore it returns is actionable — an agent can page a journal longer than 50 entries. - remove_planting goes through a new service RemovePlanting that stamps removed_at from s.now() (the injectable clock ClearObject and the fill path use), instead of the adapter computing the date off the wall clock. It delegates to UpdatePlanting, so the role check, version guard and history record are unchanged. Drops the now-unused time import. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- internal/agent/tools.go | 13 ++++++------- internal/service/plantings.go | 11 +++++++++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/internal/agent/tools.go b/internal/agent/tools.go index 0dae664..43571dc 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -2,7 +2,6 @@ package agent import ( "context" - "time" "gitea.stevedudenhoeffer.com/steve/majordomo/llm" @@ -218,8 +217,9 @@ func (a *adapter) readJournal(ctx context.Context, args struct { ObjectID *int64 `json:"objectId" description:"optional bed to narrow to; omit for the whole garden"` From string `json:"from" description:"optional earliest observed date, YYYY-MM-DD"` To string `json:"to" description:"optional latest observed date, YYYY-MM-DD"` + Offset int `json:"offset" description:"how many entries to skip; pass the count you've already seen to page when hasMore is true"` }) (any, error) { - q := service.JournalQuery{ObjectID: args.ObjectID, Limit: 50} + q := service.JournalQuery{ObjectID: args.ObjectID, Limit: 50, Offset: args.Offset} if args.From != "" { q.From = &args.From } @@ -230,6 +230,7 @@ func (a *adapter) readJournal(ctx context.Context, args struct { if err != nil { return nil, err } + // hasMore is actionable now: re-call with offset += len(entries) to page. return map[string]any{"entries": entries, "hasMore": hasMore}, nil } @@ -261,11 +262,9 @@ func (a *adapter) removePlanting(ctx context.Context, args struct { 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)"` }) (any, error) { - // Soft-remove: set removed_at to today, mirroring clear_object, so the plop - // stays in the planting history and the change is undoable. - today := time.Now().UTC().Format("2006-01-02") - return a.svc.UpdatePlanting(ctx, a.actor, args.PlantingID, - service.PlantingPatch{SetRemovedAt: true, RemovedAt: &today}, args.Version) + // Soft-remove via the service, so removed_at is stamped from the same + // (injectable) clock clear_object uses rather than the adapter's wall clock. + return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version) } func (a *adapter) listSeedLots(ctx context.Context, args struct { diff --git a/internal/service/plantings.go b/internal/service/plantings.go index 246f749..879feff 100644 --- a/internal/service/plantings.go +++ b/internal/service/plantings.go @@ -178,6 +178,17 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64, return updated, nil } +// RemovePlanting soft-removes a single plop — the one-plop counterpart to +// ClearObject, used by the agent's remove_planting tool. It stamps removed_at +// from the service clock (s.now()), same as ClearObject and the fill path, so the +// removal date can't diverge by which caller set it; then delegates to +// UpdatePlanting for the editor-role check, version guard and history record. +func (s *Service) RemovePlanting(ctx context.Context, actorID, plantingID, version int64) (*domain.Planting, error) { + today := s.now().UTC().Format(dateLayout) + return s.UpdatePlanting(ctx, actorID, plantingID, + PlantingPatch{SetRemovedAt: true, RemovedAt: &today}, version) +} + // plantingEditSummary describes a plop edit for the history list. Soft-removal // ("clear bed", harvested) is the one edit worth naming specifically — it reads // as a removal to the person who did it, not as an edit. -- 2.54.0