Address #127 review: quote the garden name, validate rectangles, require plantId
Build image / build-and-push (push) Successful in 17s
Build image / build-and-push (push) Successful in 17s
- The plan-name line of the system prompt interpolates the garden's name with %q like the rest of the prompt: any editor can rename a garden, and a name with a newline in it must not read as an instruction. - fill_region refuses an inverted rectangle with its corners named, and a rectangle that misses the bed (or only touches its edge) is an error from the service rather than a successful fill of nothing. - remove_plantings requires plantId; omitted it would remove plant 0 and report success. - historyEntry.Undo → UndoOf (it holds the reverted change set's id). - remove_planting's description names list_plantings as an id source. - RemovePlanting takes the removal date itself; the dateless wrapper had no callers left. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -261,7 +261,7 @@ How to work:
|
||||
- fill_region in grid mode lays out individual plants at true spacing, which is what "so I can
|
||||
plant from it" means; clump mode is a quick sketch. For an area no compass name describes (a
|
||||
middle third, a strip along one edge) give fill_region a rectangle instead of placing plops by hand.
|
||||
- A garden named "%s — <year>" is this garden's plan for that year; copy_garden with that name
|
||||
- A garden named %s is this garden's plan for that year; copy_garden with that name
|
||||
makes one. Never use a different real garden as a scratch space.
|
||||
- When a tool refuses (for example, the user only has view access to this garden), explain what
|
||||
happened in plain words. Do not retry it.
|
||||
@@ -280,5 +280,8 @@ How to behave:
|
||||
observation — not to narrate your own planting.
|
||||
- The gardener is watching the canvas. When you are done, say briefly what you changed and where
|
||||
to look; if you changed nothing, say that too.`,
|
||||
g.Name, g.ID, size, today, units, g.Name)
|
||||
// %q throughout for the garden's name: any editor can rename a garden, and
|
||||
// a name is data, not prompt — quoting keeps a newline or a stray quote
|
||||
// in it from reading as a new instruction.
|
||||
g.Name, g.ID, size, today, units, fmt.Sprintf("%q", g.Name+" — <year>"))
|
||||
}
|
||||
|
||||
+13
-6
@@ -72,8 +72,8 @@ func NewToolbox(svc *service.Service, actorID int64, today string) *llm.Toolbox
|
||||
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\".",
|
||||
"bed does. Needs the plop's id and version (describe_garden or list_plantings). Use "+
|
||||
"for \"pull the basil out of the corner\".",
|
||||
a.removePlanting),
|
||||
llm.DefineTool("remove_plantings",
|
||||
"Remove every plop of ONE plant from an object, leaving the other plants in it — \"take the "+
|
||||
@@ -261,6 +261,9 @@ func (a *adapter) fillRegion(ctx context.Context, args struct {
|
||||
}
|
||||
switch {
|
||||
case given == 4 && strings.TrimSpace(args.Region) == "":
|
||||
if !(*args.X0CM < *args.X1CM && *args.Y0CM < *args.Y1CM) {
|
||||
return nil, fmt.Errorf("%w: x0Cm must be west of x1Cm and y0Cm north of y1Cm (-y is north)", domain.ErrInvalidInput)
|
||||
}
|
||||
spec.Region = service.Region{MinX: *args.X0CM, MinY: *args.Y0CM, MaxX: *args.X1CM, MaxY: *args.Y1CM}
|
||||
case given == 0 && strings.TrimSpace(args.Region) != "":
|
||||
// the named region
|
||||
@@ -352,6 +355,10 @@ func (a *adapter) removePlantings(ctx context.Context, args struct {
|
||||
ObjectID int64 `json:"objectId" description:"object to remove the plant from"`
|
||||
PlantID int64 `json:"plantId" description:"the plant to remove every plop of (from describe_garden)"`
|
||||
}) (any, error) {
|
||||
if args.PlantID == 0 {
|
||||
// Left out, it would "remove" plant 0 — nothing — and report success.
|
||||
return nil, fmt.Errorf("%w: plantId is required — say which plant to remove, or use clear_object for all of them", domain.ErrInvalidInput)
|
||||
}
|
||||
n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID,
|
||||
service.ClearOptions{PlantID: &args.PlantID, RemovedAt: a.day("")})
|
||||
if err != nil {
|
||||
@@ -392,8 +399,8 @@ type historyEntry struct {
|
||||
Summary string `json:"summary"`
|
||||
Changes string `json:"changes"`
|
||||
Undone bool `json:"undone,omitempty"`
|
||||
// Undo is set when this entry is itself an undo of an earlier one.
|
||||
Undo *int64 `json:"undoOf,omitempty"`
|
||||
// UndoOf is the earlier entry this one reverted, when it is itself an undo.
|
||||
UndoOf *int64 `json:"undoOf,omitempty"`
|
||||
}
|
||||
|
||||
func (a *adapter) readHistory(ctx context.Context, args struct {
|
||||
@@ -414,7 +421,7 @@ func (a *adapter) readHistory(ctx context.Context, args struct {
|
||||
entries = append(entries, historyEntry{
|
||||
ID: cs.ID, When: cs.CreatedAt, Source: cs.Source, Who: cs.ActorName,
|
||||
Summary: cs.Summary, Changes: describeCounts(cs.Counts),
|
||||
Undone: cs.RevertedByID != nil, Undo: cs.RevertsID,
|
||||
Undone: cs.RevertedByID != nil, UndoOf: cs.RevertsID,
|
||||
})
|
||||
}
|
||||
return map[string]any{"entries": entries, "hasMore": hasMore}, nil
|
||||
@@ -467,7 +474,7 @@ func (a *adapter) removePlanting(ctx context.Context, args struct {
|
||||
}) (any, error) {
|
||||
// Soft-remove via the service, dated the gardener's local day like every
|
||||
// other tool here (the service clock's UTC day when that isn't known).
|
||||
return a.svc.RemovePlantingOn(ctx, a.actor, args.PlantingID, args.Version, a.day(""))
|
||||
return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version, a.day(""))
|
||||
}
|
||||
|
||||
func (a *adapter) listSeedLots(ctx context.Context, args struct {
|
||||
|
||||
@@ -555,6 +555,15 @@ func TestToolsFromTheLiveSweep(t *testing.T) {
|
||||
if r := call("fill_region", map[string]any{"objectId": bed.ID, "plantId": beet.ID}); !r.IsError {
|
||||
t.Error("fill_region with nowhere to fill succeeded")
|
||||
}
|
||||
// An inverted rectangle is refused with its corners named, before the service
|
||||
// sees it — the mistake a model makes is swapping which way is north.
|
||||
if r := call("fill_region", map[string]any{"objectId": bed.ID, "plantId": beet.ID, "x0Cm": 40.0, "y0Cm": -60.0, "x1Cm": -40.0, "y1Cm": 60.0}); !r.IsError || !strings.Contains(r.Content, "west of") {
|
||||
t.Errorf("inverted rectangle: %+v, want a refusal naming the corners", r)
|
||||
}
|
||||
// remove_plantings without a plant would "remove" plant 0 — nothing.
|
||||
if r := call("remove_plantings", map[string]any{"objectId": bed.ID}); !r.IsError || !strings.Contains(r.Content, "plantId") {
|
||||
t.Errorf("remove_plantings with no plant: %+v, want a refusal asking which plant", r)
|
||||
}
|
||||
// place_planting without a radius → one plant at half the spacing; two garlic
|
||||
// cloves along the north edge, dated today.
|
||||
ok("place_planting", map[string]any{"objectId": bed.ID, "plantId": garlic.ID, "xCm": -100, "yCm": -50})
|
||||
@@ -666,7 +675,7 @@ func TestToolsFromTheLiveSweep(t *testing.T) {
|
||||
t.Fatalf("undo: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
decode(ok("read_history", map[string]any{"gardenId": g.ID, "limit": 3}), &hist)
|
||||
if hist.Entries[0].Undo == nil || !hist.Entries[2].Undone {
|
||||
if hist.Entries[0].UndoOf == nil || !hist.Entries[2].Undone {
|
||||
t.Errorf("after an undo: newest = %+v, undone = %+v; want the revert to point at the removal, and the removal marked undone", hist.Entries[0], hist.Entries[2])
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user