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
+88 -6
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
@@ -89,12 +90,18 @@ func (s *Service) CreatePlanting(ctx context.Context, actorID, objectID int64, i
return nil, err
}
radius := in.RadiusCM
if radius == 0 {
// Unspecified means ONE plant: the editor's tap-to-place radius, half the
// spacing. A clump (1.5× spacing) is what a fill makes, not a placement.
radius = plant.SpacingCM / 2
}
p := &domain.Planting{
ObjectID: objectID,
PlantID: in.PlantID,
XCM: in.XCM,
YCM: in.YCM,
RadiusCM: in.RadiusCM,
RadiusCM: radius,
Count: in.Count,
Label: trimStringPtr(in.Label),
PlantedAt: in.PlantedAt,
@@ -179,14 +186,89 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
}
// 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
// ClearObject. It stamps removed_at from the service clock (s.now()), the same
// UTC day ClearObject and the fill path default to; RemovePlantingOn is the
// form for a caller that knows the gardener's local day. Both delegate 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.RemovePlantingOn(ctx, actorID, plantingID, version, nil)
}
// RemovePlantingOn is RemovePlanting with the removal date supplied (YYYY-MM-DD)
// by a caller that knows the person's local day. nil keeps the service clock's
// UTC today.
func (s *Service) RemovePlantingOn(ctx context.Context, actorID, plantingID, version int64, removedAt *string) (*domain.Planting, error) {
if !validDatePtr(removedAt) {
return nil, fmt.Errorf("%w: removedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
}
on := s.now().UTC().Format(dateLayout)
if removedAt != nil {
on = *removedAt
}
return s.UpdatePlanting(ctx, actorID, plantingID,
PlantingPatch{SetRemovedAt: true, RemovedAt: &today}, version)
PlantingPatch{SetRemovedAt: true, RemovedAt: &on}, version)
}
// MoveInput says where a plop goes: a position in the local frame of ToObjectID,
// or of the plop's current object when ToObjectID is nil.
type MoveInput struct {
ToObjectID *int64
XCM, YCM float64
}
// MovePlanting relocates one plop — within its object, or into another plantable
// object of the same garden — keeping its plant, size, count and planting date.
// Removing and re-placing is not the same thing: "move the tomatoes to the other
// bed" is not "pull them up and plant new ones today", and the live assistant
// did exactly that for want of this. Version-guarded like UpdatePlanting; a
// within-object move IS an UpdatePlanting of the position.
func (s *Service) MovePlanting(ctx context.Context, actorID, plantingID int64, in MoveInput, version int64) (*domain.Planting, error) {
pl, err := s.store.GetPlanting(ctx, plantingID)
if err != nil {
return nil, err // ErrNotFound
}
if in.ToObjectID == nil || *in.ToObjectID == pl.ObjectID {
return s.UpdatePlanting(ctx, actorID, plantingID, PlantingPatch{XCM: &in.XCM, YCM: &in.YCM}, version)
}
from, g, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor)
if err != nil {
return nil, err
}
to, toGarden, err := s.objectForRole(ctx, actorID, *in.ToObjectID, roleEditor)
if err != nil {
return nil, err
}
if toGarden.ID != g.ID {
return nil, fmt.Errorf("%w: a planting can only move within its own garden", domain.ErrInvalidInput)
}
if !to.Plantable {
return nil, fmt.Errorf("%w: %s can't hold plants", domain.ErrInvalidInput, objectLabel(to))
}
// By id, not through the actor's catalog: the plop may be of a variety the
// actor can't see (a shared editor, the owner's private plant), and moving it
// isn't choosing it.
plant, err := s.store.GetPlant(ctx, pl.PlantID)
if err != nil {
return nil, err
}
before := *pl
pl.ObjectID = to.ID
pl.XCM, pl.YCM = in.XCM, in.YCM
if err := finalizePlanting(pl, to, true); err != nil {
return nil, err
}
pl.Version = version
updated, err := s.store.UpdatePlanting(ctx, pl)
if err != nil {
if errors.Is(err, domain.ErrVersionConflict) && updated != nil {
s.enrichDerived(ctx, updated)
}
return updated, err
}
s.record(ctx, g.ID, actorID, "Moved "+plant.Name+" from "+objectLabel(from)+" to "+objectLabel(to),
changeUpdate(domain.EntityPlanting, updated.ID, &before, updated))
updated.DerivedCount = derivedCount(updated.RadiusCM, plant.SpacingCM)
return updated, nil
}
// plantingEditSummary describes a plop edit for the history list. Soft-removal