Agent: add the corrective tools the toolbox was missing (#85 item 3)
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 10m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m8s

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-22 21:23:46 -04:00
co-authored by Claude Opus 4.8
parent a72ddefc99
commit 887a3c2cc6
3 changed files with 239 additions and 0 deletions
+121
View File
@@ -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()