Agent: what a day of live use asked for
Build image / build-and-push (push) Successful in 19s
Gadfly review (reusable) / review (pull_request) Successful in 10m2s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m2s

Twenty-one prompts against the live assistant found one fabricated success,
a model that believed it was 2025, and a describe_garden that was ~450 plop
entries per turn. This is the set of fixes, each traceable to a finding:

- The gardener's LOCAL day travels with the turn (`today` on POST /agent/chat,
  sent by the UI like plantedAt) into the system prompt and every dated tool
  default. Left to guess, the model dated journal entries a year back; left to
  the server, a 9 pm fill landed on UTC's tomorrow.
- describe_garden groups plops by plant — count, where, planted date, days to
  maturity — and lists ids only for groups of ≤ 8; list_plantings spells a big
  group out on demand and remove_plantings acts on one plant in a bed ("take
  the beets out, leave the garlic"), which used to mean 116 single removals.
- New tools: move_planting (keeps the planting date; across beds via the new
  MovePlanting, which is why the store's UPDATE now writes object_id),
  update_plant, read_history, copy_garden (the "<garden> — <year>" plan
  convention). fill_region takes an explicit local rectangle and a seedLotId;
  place_planting's radius defaults to one plant (spacing/2) instead of a guess.
- The system prompt states the date and the gardener's units, forbids claiming
  a change no tool made, says it cannot undo and points at the Undo button,
  asks before clearing beds on an ambiguous sentence, and stops narrating its
  own plantings into the journal.
- A mutation aimed at ANOTHER garden inside a turn is recorded under that
  garden as its own change set, not filed into the open scope.
- UI: the thread scrolls inside the Assistant panel so the composer stays
  put; every tool has a step label; wide tables stay inside the bubble.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-08-23 00:14:41 -04:00
co-authored by Claude Fable 5
parent f0aefb5378
commit bc14bbed0d
18 changed files with 1702 additions and 186 deletions
+346 -70
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
@@ -179,39 +180,79 @@ func validFillLayout(l FillLayout) (FillLayout, bool) {
// when nil — the UI always sends its local day, so the default is for API and
// agent callers. Returns the plops it created.
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
return s.Fill(ctx, actorID, objectID, FillSpec{
Region: region, PlantID: plantID, SpacingOverride: spacingOverride, Layout: layout, PlantedAt: plantedAt,
})
}
// FillSpec is everything a fill needs besides the object it fills: where (a
// compass RegionName, or an explicit Region in the object's local frame when the
// name is empty), what, and how.
type FillSpec struct {
// RegionName is a compass name for NamedRegion ("ne", "south half", "all").
// When it is empty, Region is used as given.
RegionName string
Region Region
PlantID int64
// SpacingOverride replaces the plant's own spacing for this fill, in cm.
SpacingOverride *float64
// Layout is clump (the default) or grid; see FillLayout.
Layout FillLayout
// PlantedAt dates every plop the fill makes (YYYY-MM-DD). nil means the
// service's UTC today; a caller that knows the person's local day sends it.
PlantedAt *string
// SeedLotID attributes every plop to one of the actor's seed lots, so the lot
// can report what it has left. Optional.
SeedLotID *int64
}
// Fill plants one plant across part of an object the actor can edit, per spec.
// FillRegion and FillNamedRegion are the two older spellings of it.
func (s *Service) Fill(ctx context.Context, actorID, objectID int64, spec FillSpec) ([]domain.Planting, error) {
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil {
return nil, err
}
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout, plantedAt)
region := spec.Region
if strings.TrimSpace(spec.RegionName) != "" {
if region, err = NamedRegion(o, spec.RegionName); err != nil {
return nil, err
}
}
return s.fillLoaded(ctx, actorID, o, region, spec)
}
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
// already loaded and authorized (roleEditor). It validates the layout, rejects a
// fillLoaded is the body of Fill given an object already loaded and authorized
// (roleEditor) and its region resolved. It validates the layout, rejects a
// non-finite region, clamps the region to the object's bounds, refuses fills over
// maxFillPlops, and inserts the whole batch in one transaction rather than one
// round-trip per plop.
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, spec FillSpec) ([]domain.Planting, error) {
if !o.Plantable {
return nil, domain.ErrInvalidInput
}
if !validDatePtr(plantedAt) {
if !validDatePtr(spec.PlantedAt) {
return nil, fmt.Errorf("%w: plantedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
}
layout, ok := validFillLayout(layout)
layout, ok := validFillLayout(spec.Layout)
if !ok {
return nil, domain.ErrInvalidInput
}
plant, err := s.visiblePlant(ctx, actorID, plantID)
plant, err := s.visiblePlant(ctx, actorID, spec.PlantID)
if err != nil {
return nil, err
}
// Checked before anything is planted, as CreatePlanting does: a lot of the
// wrong variety, or someone else's, refuses the whole fill.
if err := s.checkSeedLotForPlanting(ctx, actorID, spec.SeedLotID, spec.PlantID); err != nil {
return nil, err
}
spacing := plant.SpacingCM
if spacingOverride != nil {
if !isFinite(*spacingOverride) || *spacingOverride < minPlantSpacingCM || *spacingOverride > maxPlantSpacingCM {
if spec.SpacingOverride != nil {
if !isFinite(*spec.SpacingOverride) || *spec.SpacingOverride < minPlantSpacingCM || *spec.SpacingOverride > maxPlantSpacingCM {
return nil, domain.ErrInvalidInput
}
spacing = *spacingOverride
spacing = *spec.SpacingOverride
}
radius := plopRadiusFor(spacing, layout)
if !isFinite(radius) || radius <= 0 {
@@ -241,8 +282,8 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
return nil, err
}
plantedOn := s.now().UTC().Format(dateLayout)
if plantedAt != nil {
plantedOn = *plantedAt
if spec.PlantedAt != nil {
plantedOn = *spec.PlantedAt
}
batch := make([]*domain.Planting, 0, len(centers))
// Only the plops that were ALREADY here can cover a candidate: every plop this
@@ -255,7 +296,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
if coveredByExisting(c.x, c.y, radius, existing) {
continue
}
batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &plantedOn})
batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: spec.PlantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &plantedOn, SeedLotID: spec.SeedLotID})
}
created, err := s.store.CreatePlantings(ctx, batch)
if err != nil {
@@ -385,15 +426,14 @@ func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
// instead of a resolved Region — the ergonomic form for agent tools, which don't
// hold the object's geometry. It resolves the name against the object, then fills.
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil {
return nil, err
if strings.TrimSpace(regionName) == "" {
// Fill would read a blank name as "use the (zero) Region" and plant
// nothing; here a blank name is the caller's mistake, as it always was.
return nil, domain.ErrInvalidInput
}
region, err := NamedRegion(o, regionName)
if err != nil {
return nil, err
}
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout, plantedAt)
return s.Fill(ctx, actorID, objectID, FillSpec{
RegionName: regionName, PlantID: plantID, SpacingOverride: spacingOverride, Layout: layout, PlantedAt: plantedAt,
})
}
// ClearObject soft-removes every active plop in an object the actor can edit (one
@@ -402,10 +442,29 @@ func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64,
// non-plantable after it was planted must still be clearable (you can always
// remove existing plops, only not add new ones).
func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int, error) {
return s.ClearPlantings(ctx, actorID, objectID, ClearOptions{})
}
// ClearOptions narrows ClearPlantings.
type ClearOptions struct {
// PlantID limits the clear to one plant — "pull the beets out, leave the
// garlic" — nil clears every plant.
PlantID *int64
// RemovedAt is the removal date (YYYY-MM-DD). nil means the service's UTC
// today; a caller that knows the person's local day sends it.
RemovedAt *string
}
// ClearPlantings is ClearObject with options: all of an object's active plops, or
// only one plant's. The whole clear is one change set either way.
func (s *Service) ClearPlantings(ctx context.Context, actorID, objectID int64, opts ClearOptions) (int, error) {
o, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil {
return 0, err
}
if !validDatePtr(opts.RemovedAt) {
return 0, fmt.Errorf("%w: removedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
}
// Snapshot the rows the bulk UPDATE is about to touch, since it reports only a
// count — then clear exactly those ids. Clearing "every active plop" instead
// would let a plop created between this read and the UPDATE be removed with no
@@ -414,12 +473,32 @@ func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int
if err != nil {
return 0, err
}
what := "" // names the plant in the summary when the clear is for one plant
if opts.PlantID != nil {
only := make([]domain.Planting, 0, len(before))
for i := range before {
if before[i].PlantID == *opts.PlantID {
only = append(only, before[i])
}
}
before = only
// The summary is read by a person, so name the plant, not its id. A plant
// that no longer exists just goes unnamed.
if plant, err := s.store.GetPlant(ctx, *opts.PlantID); err == nil {
what = plant.Name
} else if !errors.Is(err, domain.ErrNotFound) {
return 0, err
}
}
ids := make([]int64, 0, len(before))
for i := range before {
ids = append(ids, before[i].ID)
}
today := s.now().UTC().Format(dateLayout)
n, err := s.store.ClearObjectPlantings(ctx, objectID, today, ids)
removedOn := s.now().UTC().Format(dateLayout)
if opts.RemovedAt != nil {
removedOn = *opts.RemovedAt
}
n, err := s.store.ClearObjectPlantings(ctx, objectID, removedOn, ids)
if err != nil || n == 0 {
return n, err
}
@@ -447,7 +526,14 @@ func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int
}
changes = append(changes, changeUpdate(domain.EntityPlanting, b.ID, &b, a))
}
s.record(ctx, g.ID, actorID, fmt.Sprintf("Cleared %s (%d plantings)", objectLabel(o), n), changes...)
summary := fmt.Sprintf("Cleared %s (%d plantings)", objectLabel(o), n)
if opts.PlantID != nil {
if what == "" {
what = "plantings"
}
summary = fmt.Sprintf("Removed %s from %s (%d plantings)", what, objectLabel(o), n)
}
s.record(ctx, g.ID, actorID, summary, changes...)
return n, nil
}
@@ -461,39 +547,75 @@ type DescribeResult struct {
Objects []DescribeObject `json:"objects"`
}
// DescribeObject is one object plus its active plantings, for DescribeResult.
// Version is included so an agent can move/edit the object (the mutation guard).
// DescribeObject is one object plus its active plantings grouped by plant, for
// DescribeResult. Version is included so an agent can move/edit the object (the
// mutation guard).
type DescribeObject struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
Shape string `json:"shape"`
WidthCM float64 `json:"widthCm"`
HeightCM float64 `json:"heightCm"`
XCM float64 `json:"xCm"`
YCM float64 `json:"yCm"`
RotationDeg float64 `json:"rotationDeg"`
Plantable bool `json:"plantable"`
Version int64 `json:"version"`
Plantings []DescribePlanting `json:"plantings"`
ID int64 `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
Shape string `json:"shape"`
WidthCM float64 `json:"widthCm"`
HeightCM float64 `json:"heightCm"`
XCM float64 `json:"xCm"`
YCM float64 `json:"yCm"`
RotationDeg float64 `json:"rotationDeg"`
Plantable bool `json:"plantable"`
Version int64 `json:"version"`
Plantings []DescribeGroup `json:"plantings"`
}
// 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.
// maxListedPlops is the largest group DescribeGroup.Each spells out plop by plop.
// Up to it, a group is a handful of placements someone may address one at a time
// ("pull the basil out of the corner"). Past it — a grid-filled bed is hundreds —
// the ids are noise that costs a model more than it informs, and the group is
// addressed as a whole (ClearPlantings) or listed on demand (ListObjectPlantings).
// The live instance's first describe of a grid-filled garden was ~450 plop
// entries, on every turn.
const maxListedPlops = 8
// DescribeGroup summarizes every active plop of one plant in an object — the
// unit a person talks about ("the cucumbers in the west bed") — with the count,
// a rough location, and when it went in.
type DescribeGroup struct {
PlantID int64 `json:"plantId"`
Plant string `json:"plant"`
// Plops is how many placements make up the group; Plants the effective plant
// count across them (explicit counts, else derived from area and spacing).
Plops int `json:"plops"`
Plants int `json:"plants"`
// Where is a rough location: a compass region when the group sits in one
// ("north half", "NE corner"), "throughout" when it spans the object, a short
// list of locations, or — for anything else — its bounding box in local cm.
Where string `json:"where"`
// PlantedAt is the planting date, or "first…last" when the plops differ.
PlantedAt string `json:"plantedAt,omitempty"`
// 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 []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.
type DescribePlanting struct {
ID int64 `json:"id"`
Version int64 `json:"version"`
PlantID int64 `json:"plantId"`
Plant string `json:"plant"`
Count int `json:"count"`
Location string `json:"location"`
RadiusCM float64 `json:"radiusCm"`
ID int64 `json:"id"`
Version int64 `json:"version"`
PlantID int64 `json:"plantId"`
Plant string `json:"plant"`
Count int `json:"count"`
Location string `json:"location"`
RadiusCM float64 `json:"radiusCm"`
PlantedAt string `json:"plantedAt,omitempty"`
}
// DescribeGarden returns a structured summary — dimensions, objects, and each
// object's active plantings (plant, effective count, rough location) — for a
// garden the actor can view. Built on GardenFull so it inherits the ACL check.
// object's active plantings grouped by plant (count, rough location, planting
// date) — for a garden the actor can view. Built on GardenFull so it inherits
// the ACL check.
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) {
full, err := s.GardenFull(ctx, actorID, gardenID, nil)
if err != nil {
@@ -517,33 +639,187 @@ func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (
UnitPref: full.Garden.UnitPref,
Objects: make([]DescribeObject, 0, len(full.Objects)),
}
for _, o := range full.Objects {
do := DescribeObject{
for i := range full.Objects {
o := &full.Objects[i]
res.Objects = append(res.Objects, DescribeObject{
ID: o.ID, Kind: o.Kind, Name: o.Name, Shape: o.Shape,
WidthCM: o.WidthCM, HeightCM: o.HeightCM, XCM: o.XCM, YCM: o.YCM,
RotationDeg: o.RotationDeg, Plantable: o.Plantable, Version: o.Version,
Plantings: []DescribePlanting{},
}
for _, pl := range plopsByObject[o.ID] {
count := pl.DerivedCount
if pl.Count != nil {
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,
Location: describeLocation(pl.XCM, pl.YCM),
RadiusCM: pl.RadiusCM,
})
}
res.Objects = append(res.Objects, do)
Plantings: describeGroups(o, plopsByObject[o.ID], plantByID),
})
}
return res, nil
}
// ListObjectPlantings lists an object's active plops one by one — the ids that
// DescribeGarden summarizes away for a large group. plantID narrows it to one
// plant. Viewer role, like DescribeGarden.
func (s *Service) ListObjectPlantings(ctx context.Context, actorID, objectID int64, plantID *int64) ([]DescribePlanting, error) {
if _, _, err := s.objectForRole(ctx, actorID, objectID, roleViewer); err != nil {
return nil, err
}
plops, err := s.store.ListActivePlantingsForObject(ctx, objectID)
if err != nil {
return nil, err
}
// Plants looked up by id, not through the actor's catalog: a plop in a shared
// garden may be of the owner's private variety, and it still has a name.
plants := map[int64]domain.Plant{}
out := make([]DescribePlanting, 0, len(plops))
for _, pl := range plops {
if plantID != nil && pl.PlantID != *plantID {
continue
}
plant, ok := plants[pl.PlantID]
if !ok {
p, err := s.store.GetPlant(ctx, pl.PlantID)
if err != nil {
return nil, err
}
plant = *p
plants[pl.PlantID] = plant
}
pl.DerivedCount = derivedCount(pl.RadiusCM, plant.SpacingCM)
out = append(out, describePlanting(pl, plant.Name))
}
return out, nil
}
// describeGroups groups an object's active plops by plant, in the order the
// plants first appear, so the same garden always describes the same way.
func describeGroups(o *domain.GardenObject, plops []domain.Planting, plantByID map[int64]domain.Plant) []DescribeGroup {
byPlant := map[int64][]domain.Planting{}
var order []int64
for _, pl := range plops {
if _, seen := byPlant[pl.PlantID]; !seen {
order = append(order, pl.PlantID)
}
byPlant[pl.PlantID] = append(byPlant[pl.PlantID], pl)
}
groups := make([]DescribeGroup, 0, len(order))
for _, pid := range order {
members := byPlant[pid]
plant := plantByID[pid]
g := DescribeGroup{
PlantID: pid, Plant: plant.Name, Plops: len(members),
Where: summarizeWhere(o, members), PlantedAt: dateRange(members),
DaysToMaturity: plant.DaysToMaturity,
}
for _, pl := range members {
g.Plants += effectiveCount(pl)
}
if len(members) <= maxListedPlops {
g.Each = make([]DescribePlanting, 0, len(members))
for _, pl := range members {
g.Each = append(g.Each, describePlanting(pl, plant.Name))
}
}
groups = append(groups, g)
}
return groups
}
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,
}
if pl.PlantedAt != nil {
d.PlantedAt = *pl.PlantedAt
}
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 {
if pl.Count != nil {
return *pl.Count
}
return pl.DerivedCount
}
// dateRange is the planting date shared by a group's plops, "first…last" when
// they were planted on different days, or "" when none is dated. ISO dates
// order as strings, so min/max need no parsing.
func dateRange(plops []domain.Planting) string {
first, last := "", ""
for _, pl := range plops {
if pl.PlantedAt == nil || *pl.PlantedAt == "" {
continue
}
if first == "" || *pl.PlantedAt < first {
first = *pl.PlantedAt
}
if *pl.PlantedAt > last {
last = *pl.PlantedAt
}
}
if first == last {
return first
}
return first + "…" + last
}
// summarizeWhere names where a group of plops sits in its object, in the words
// NamedRegion understands when that is exact ("north half", "NE corner"), and
// otherwise as honestly as it can: "throughout" for a group spanning most of the
// object, a short list of rough locations, or the bounding box of the plop
// centres in local cm — which is what a fill needs to put something back there.
func summarizeWhere(o *domain.GardenObject, plops []domain.Planting) string {
if len(plops) == 1 {
return describeLocation(plops[0].XCM, plops[0].YCM)
}
minX, maxX := plops[0].XCM, plops[0].XCM
minY, maxY := plops[0].YCM, plops[0].YCM
for _, pl := range plops[1:] {
minX, maxX = math.Min(minX, pl.XCM), math.Max(maxX, pl.XCM)
minY, maxY = math.Min(minY, pl.YCM), math.Max(maxY, pl.YCM)
}
const eps = 1e-6
// A half is "everything on one side of the centre line, and not just ON it":
// a column of plops down the middle is neither the west half nor the east.
north := maxY <= eps && minY < -eps
south := minY >= -eps && maxY > eps
west := maxX <= eps && minX < -eps
east := minX >= -eps && maxX > eps
switch {
case north && west:
return "NW corner"
case north && east:
return "NE corner"
case south && west:
return "SW corner"
case south && east:
return "SE corner"
case north:
return "north half"
case south:
return "south half"
case west:
return "west half"
case east:
return "east half"
}
// Centres spanning at least 60% of both dimensions is a whole-object fill
// (the outer row sits half a spacing in from each edge).
if hw, hh := o.WidthCM/2, o.HeightCM/2; hw > 0 && hh > 0 && maxX-minX >= 1.2*hw && maxY-minY >= 1.2*hh {
return "throughout"
}
var locs []string
seen := map[string]bool{}
for _, pl := range plops {
if l := describeLocation(pl.XCM, pl.YCM); !seen[l] {
seen[l] = true
locs = append(locs, l)
}
}
if len(locs) <= 3 {
return strings.Join(locs, ", ")
}
return fmt.Sprintf("x %.0f…%.0f, y %.0f…%.0f cm from the centre", minX, maxX, minY, maxY)
}
// describeLocation reverse-maps a local point to a rough compass location — the
// inverse of NamedRegion's quarters/halves ("NE corner", "south", "center").
func describeLocation(x, y float64) string {