From 8b161c5f6d1b319b94cb311aa305b60f92083c8e Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 23 Aug 2026 00:37:23 -0400 Subject: [PATCH 1/2] describe_garden: list each plop's position, so a move can keep the layout Asked to move four tomatoes planted in a column "keeping the same spacing", the live assistant re-laid them as two pairs: the per-plop listing said "north" and "south" and nothing else. Each listed plop (and list_plantings) now carries xCm/yCm in the object's local frame. Co-Authored-By: Claude Fable 5 --- internal/agent/tools.go | 12 +++++++----- internal/service/ops.go | 14 ++++++++++---- internal/service/ops_test.go | 4 ++++ 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/internal/agent/tools.go b/internal/agent/tools.go index 1d907ac..aa631d4 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -33,13 +33,15 @@ func NewToolbox(svc *service.Service, actorID int64, today string) *llm.Toolbox "Summarize a garden: its dimensions, objects (with sizes/positions/version), and each "+ "object's active 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 + "+ - "version, for move_planting/remove_planting); a large one (a grid-filled bed) does not — "+ - "use list_plantings for those ids, or act on the whole group with remove_plantings.", + "version for move_planting/remove_planting, and xCm/yCm in the object's local frame so a "+ + "move can keep their layout); a large one (a grid-filled bed) does not — use "+ + "list_plantings for those, or act on the whole group with remove_plantings.", a.describeGarden), llm.DefineTool("list_plantings", - "List one object's active plops one by one, each with its id, version, location, count and "+ - "planting date — the detail describe_garden leaves out for a large group. Narrow to one "+ - "plant with plantId. Use it only when you need to address individual plops.", + "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 "+ + "describe_garden leaves out for a large group. Narrow to one plant with plantId. Use it "+ + "only when you need to address individual plops.", a.listPlantings), llm.DefineTool("create_object", "Add an object (bed, grow_bag, container, in_ground, tree, path, structure) to a garden, positioned by its center in garden cm.", diff --git a/internal/service/ops.go b/internal/service/ops.go index f53da2c..081b1ab 100644 --- a/internal/service/ops.go +++ b/internal/service/ops.go @@ -610,15 +610,20 @@ type DescribeGroup struct { Each []DescribePlanting `json:"each,omitempty"` } -// DescribePlanting is one plop with a rough compass location. ID + Version let -// an agent address a single plop — remove it or move it — the same way -// DescribeObject.Version lets it edit an object. +// DescribePlanting is one plop with its position and a rough compass location. +// ID + Version let an agent address a single plop — remove it or move it — the +// same way DescribeObject.Version lets it edit an object; XCM/YCM (the object's +// local frame) let a move keep the layout the plops had, which "north, south" +// alone cannot: asked to move four tomatoes in a column "keeping the same +// spacing", the live assistant re-laid them as two pairs for want of these. type DescribePlanting struct { ID int64 `json:"id"` Version int64 `json:"version"` PlantID int64 `json:"plantId"` Plant string `json:"plant"` Count int `json:"count"` + XCM float64 `json:"xCm"` + YCM float64 `json:"yCm"` Location string `json:"location"` RadiusCM float64 `json:"radiusCm"` PlantedAt string `json:"plantedAt,omitempty"` @@ -736,7 +741,8 @@ func describeGroups(o *domain.GardenObject, plops []domain.Planting, plantByID m func describePlanting(pl domain.Planting, plantName string) DescribePlanting { d := DescribePlanting{ ID: pl.ID, Version: pl.Version, PlantID: pl.PlantID, Plant: plantName, - Count: effectiveCount(pl), Location: describeLocation(pl.XCM, pl.YCM), RadiusCM: pl.RadiusCM, + Count: effectiveCount(pl), XCM: pl.XCM, YCM: pl.YCM, + Location: describeLocation(pl.XCM, pl.YCM), RadiusCM: pl.RadiusCM, } if pl.PlantedAt != nil { d.PlantedAt = *pl.PlantedAt diff --git a/internal/service/ops_test.go b/internal/service/ops_test.go index 2787ae0..2a8b2b3 100644 --- a/internal/service/ops_test.go +++ b/internal/service/ops_test.go @@ -632,6 +632,10 @@ func TestDescribeGardenGroupsByPlant(t *testing.T) { if e.PlantedAt == "" || e.Version == 0 || e.ID == 0 { t.Errorf("listed plop %+v is missing id, version or date", e) } + // The position is what lets a move keep the layout; "south" alone can't. + if e.YCM < 100 || (e.XCM != -100 && e.XCM != 0 && e.XCM != 100) { + t.Errorf("listed plop %+v doesn't carry the position it was placed at", e) + } } // The big group's ids are a call away, narrowed to one plant. -- 2.54.0 From 85b7dbbe3ab7299c0b4668004c21c09b0d4dfe7b Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 23 Aug 2026 00:43:15 -0400 Subject: [PATCH 2/2] Address #128 review: doc lines and an exact position assertion Co-Authored-By: Claude Fable 5 --- internal/service/ops.go | 11 +++++------ internal/service/ops_test.go | 12 +++++++++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/internal/service/ops.go b/internal/service/ops.go index 081b1ab..5c7340d 100644 --- a/internal/service/ops.go +++ b/internal/service/ops.go @@ -605,17 +605,16 @@ 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"` - // Each lists the plops individually (id, version, location) only when the - // group has at most maxListedPlops of them. + // Each lists the plops individually (id, version, position, location) only + // when the group has at most maxListedPlops of them. Each []DescribePlanting `json:"each,omitempty"` } // DescribePlanting is one plop with its position and a rough compass location. // ID + Version let an agent address a single plop — remove it or move it — the -// same way DescribeObject.Version lets it edit an object; XCM/YCM (the object's -// local frame) let a move keep the layout the plops had, which "north, south" -// alone cannot: asked to move four tomatoes in a column "keeping the same -// spacing", the live assistant re-laid them as two pairs for want of these. +// same way DescribeObject.Version lets it edit an object. XCM/YCM are in the +// object's local frame: they are what lets a move keep the layout the plops +// had, which the compass word alone ("north", "south") cannot. type DescribePlanting struct { ID int64 `json:"id"` Version int64 `json:"version"` diff --git a/internal/service/ops_test.go b/internal/service/ops_test.go index 2a8b2b3..8ba0982 100644 --- a/internal/service/ops_test.go +++ b/internal/service/ops_test.go @@ -628,14 +628,20 @@ func TestDescribeGardenGroupsByPlant(t *testing.T) { if want := 2*derivedCount(20, 25) + 3; ba.Plants != want { t.Errorf("basil plants = %d, want %d", ba.Plants, want) } + // The position is what lets a move keep the layout; "south" alone can't. The + // three basil plops were placed at exactly these local points. + placedAt := map[[2]float64]bool{{-100, 100}: true, {0, 150}: true, {100, 100}: true} for _, e := range ba.Each { if e.PlantedAt == "" || e.Version == 0 || e.ID == 0 { t.Errorf("listed plop %+v is missing id, version or date", e) } - // The position is what lets a move keep the layout; "south" alone can't. - if e.YCM < 100 || (e.XCM != -100 && e.XCM != 0 && e.XCM != 100) { - t.Errorf("listed plop %+v doesn't carry the position it was placed at", e) + if !placedAt[[2]float64{e.XCM, e.YCM}] { + t.Errorf("listed plop %+v is not at a position a basil was placed at", e) } + delete(placedAt, [2]float64{e.XCM, e.YCM}) + } + if len(placedAt) != 0 { + t.Errorf("positions never listed: %v", placedAt) } // The big group's ids are a call away, narrowed to one plant. -- 2.54.0