Files
pansy/internal/agent/tools.go
T
steveandClaude Fable 5 bc14bbed0d
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
Agent: what a day of live use asked for
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]>
2026-08-23 00:14:41 -04:00

501 lines
27 KiB
Go

package agent
import (
"context"
"fmt"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// NewToolbox builds a majordomo toolbox over pansy's service layer, bound to a
// single acting user and to the day it is where they are. Every tool call runs
// as actorID, so pansy's permission checks (requireGardenRole / objectForRole)
// apply unchanged. Construct one per authenticated agent session:
//
// box := agent.NewToolbox(svc, session.UserID, "2026-08-22")
// agent.Run(ctx, model, box, "fill the NE corner with garlic")
//
// today (YYYY-MM-DD) is the date every tool stamps on what it plants, removes or
// journals unless the model passes one — the gardener's local day, from the
// client, because the server's UTC day is tomorrow by nine in the evening in
// Ohio. Empty falls back to the service's UTC today.
func NewToolbox(svc *service.Service, actorID int64, today string) *llm.Toolbox {
a := &adapter{svc: svc, actor: actorID, today: strings.TrimSpace(today)}
return llm.NewToolbox("pansy",
llm.DefineTool("list_gardens",
"List the gardens the user can see (owned and shared), with the user's role on each.",
a.listGardens),
llm.DefineTool("describe_garden",
"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.",
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.",
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.",
a.createObject),
llm.DefineTool("move_object",
"Move an object to a new center position (garden cm). Needs the object's current version from describe_garden.",
a.moveObject),
llm.DefineTool("place_planting",
"Place one plop of a plant inside a plantable object, positioned in the object's LOCAL frame "+
"(0,0 = object center, -y = north). Omit radiusCm for a single plant (it defaults to half "+
"the plant's spacing); a larger radius is a clump, whose plant count is derived from its "+
"area unless you pass count. Dated today unless plantedAt says otherwise.",
a.placePlanting),
llm.DefineTool("fill_region",
"Fill part of a plantable object with one plant, hex-packed at the plant's spacing. "+
"Say where EITHER by region — a compass name, not coordinates: nw|ne|sw|se for the quarter "+
"corners, north|south|east|west (or top|bottom|left|right) for halves, or all for the whole "+
"thing; north is the top of the garden — OR by an explicit rectangle in the object's local "+
"frame (x0Cm,y0Cm,x1Cm,y1Cm; 0,0 = center, -y = north), for a middle third, a strip along "+
"one edge, or any area a compass name can't say. Example: to replant a whole bed, "+
"clear_object then fill_region with region=all. Filling skips spots already covered by an "+
"existing plant, so it is safe to run twice. Dated today unless plantedAt says otherwise.",
a.fillRegion),
llm.DefineTool("move_planting",
"Move ONE plop to a new position — within its object, or into another plantable object of "+
"the same garden with toObjectId — keeping its plant, size, count and planting date. This "+
"is how to relocate plants; removing and re-placing them would lose when they were planted. "+
"Needs the plop's id and version (describe_garden or list_plantings).",
a.movePlanting),
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\".",
a.removePlanting),
llm.DefineTool("remove_plantings",
"Remove every plop of ONE plant from an object, leaving the other plants in it — \"take the "+
"beets out of the south bed\". Soft-removes them (kept for planting history, undoable as "+
"one change). Use this rather than many remove_planting calls.",
a.removePlantings),
llm.DefineTool("clear_object",
"Remove all plants from an object. They are soft-removed, so the planting history for past "+
"seasons is kept and the change can be undone. Use this before replanting a bed with "+
"something else.",
a.clearObject),
llm.DefineTool("find_plant",
"Look up plants in the user's catalog by name or category, to get the plantId that "+
"place_planting and fill_region need. Returns SEVERAL candidates when the query is "+
"ambiguous — \"garlic\" may match both the built-in \"Garlic\" and a custom \"German Red "+
"Garlic\" — so pick the one that fits what the user asked for, or ask them which. Each "+
"result also reports how much seed the user has left of it, when they have recorded any.",
a.findPlant),
llm.DefineTool("create_plant",
"Add a new plant to the user's own catalog, for when they name a variety that isn't in it "+
"yet. Check find_plant first — creating a duplicate of something that already exists is "+
"worse than reusing it. The plant belongs to the user, not to any garden.",
a.createPlant),
llm.DefineTool("update_plant",
"Change a plant in the user's own catalog: its name, category, spacing, color, days to "+
"maturity, vendor, source link or notes. Only the fields you pass change. Needs the "+
"plant's current version from find_plant. Built-in plants can't be edited — create_plant "+
"the user's own variety instead.",
a.updatePlant),
llm.DefineTool("add_journal_entry",
"Write a dated observation into the garden's grow journal — what happened, and when. "+
"Attach it to one bed with objectId when it is about that bed. This is for events "+
"(\"powdery mildew on the west bed\", \"first frost\"), not for descriptions of what a "+
"thing is and not for narrating your own plantings. observedAt defaults to today; set "+
"it to backdate.",
a.addJournalEntry),
llm.DefineTool("read_journal",
"Read back the garden's grow journal — the observations add_journal_entry wrote. "+
"Narrow it with objectId (one bed), or a from/to date range (YYYY-MM-DD). Most "+
"recently observed first. Use this to answer \"what did I note about the west bed?\" "+
"or \"what happened last spring?\".",
a.readJournal),
llm.DefineTool("read_history",
"Read the garden's change history: every change anyone made — by hand in the editor, or "+
"in an earlier conversation with you — newest first, with what it changed and whether it "+
"was undone. Use it to answer \"what changed this week?\" or \"what did you do last time?\" "+
"rather than reciting from memory. You cannot undo from here; the person has an Undo "+
"button on each change.",
a.readHistory),
llm.DefineTool("update_object",
"Change an existing object: resize it (widthCm/heightCm), rotate it (rotationDeg), "+
"rename it (name), or toggle whether it can hold plants (plantable). Only the fields "+
"you pass change. Needs the object's current version from describe_garden. Example: "+
"\"make that bed 60cm wider\" — read its widthCm from describe_garden, add 60, pass the "+
"sum.",
a.updateObject),
llm.DefineTool("delete_object",
"Delete an object from a garden entirely, along with its plantings. This is the "+
"counterpart to create_object — use it for \"remove the old grow bag\". Permanent (not "+
"the same as clearing a bed's plants); prefer clear_object when the bed itself stays.",
a.deleteObject),
llm.DefineTool("list_seed_lots",
"List the seed lots (purchases) the user has recorded — vendor, quantity, and what's "+
"left — optionally for one plant via plantId. This is the detail behind the \"seed "+
"remaining\" number find_plant reports.",
a.listSeedLots),
llm.DefineTool("record_seed_lot",
"Record a seed purchase for a plant the user owns, so pansy can track how much is left. "+
"Get the plantId from find_plant first. quantity + unit is what was bought (e.g. 2 "+
"\"packets\", or 500 \"seeds\"). Use for \"I bought two packets of Cherokee Purple\". To "+
"count seed as used, plant with a seedLotId on place_planting or fill_region.",
a.recordSeedLot),
llm.DefineTool("copy_garden",
"Duplicate a garden the user owns — beds, objects and plantings — as a new garden with the "+
"given name. This is how a season plan is made: a copy named \"<garden name> — <year>\" "+
"(with an em dash) is that garden's plan for the year, and the editor offers it as such. "+
"Use it for \"set up next year's plan\"; never use another real garden as a scratch space.",
a.copyGarden),
)
}
// adapter carries the service, the acting user and their local day for the
// tool handlers.
type adapter struct {
svc *service.Service
actor int64
today string
}
// day is the date a tool stamps: the one the model passed, else the gardener's
// local today, else nil for the service's UTC default.
func (a *adapter) day(explicit string) *string {
if d := strings.TrimSpace(explicit); d != "" {
return &d
}
if a.today != "" {
d := a.today
return &d
}
return nil
}
func (a *adapter) listGardens(ctx context.Context, _ struct{}) (any, error) {
return a.svc.ListGardens(ctx, a.actor)
}
func (a *adapter) describeGarden(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"id of the garden to describe"`
}) (any, error) {
return a.svc.DescribeGarden(ctx, a.actor, args.GardenID)
}
func (a *adapter) listPlantings(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object whose plops to list"`
PlantID *int64 `json:"plantId" description:"optional: only plops of this plant"`
}) (any, error) {
return a.svc.ListObjectPlantings(ctx, a.actor, args.ObjectID, args.PlantID)
}
func (a *adapter) createObject(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden to add the object to"`
Kind string `json:"kind" description:"bed | grow_bag | container | in_ground | tree | path | structure"`
Name string `json:"name" description:"optional label for the object"`
Shape string `json:"shape" description:"rect or circle (default rect)"`
XCM float64 `json:"xCm" description:"center x in garden cm"`
YCM float64 `json:"yCm" description:"center y in garden cm"`
WidthCM float64 `json:"widthCm" description:"width in cm (a circle's diameter)"`
HeightCM float64 `json:"heightCm" description:"height in cm"`
}) (any, error) {
return a.svc.CreateObject(ctx, a.actor, args.GardenID, service.ObjectInput{
Kind: args.Kind, Name: args.Name, Shape: args.Shape,
XCM: args.XCM, YCM: args.YCM, WidthCM: args.WidthCM, HeightCM: args.HeightCM,
})
}
func (a *adapter) moveObject(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to move"`
XCM float64 `json:"xCm" description:"new center x in garden cm"`
YCM float64 `json:"yCm" description:"new center y in garden cm"`
Version int64 `json:"version" description:"the object's current version (from describe_garden)"`
}) (any, error) {
return a.svc.UpdateObject(ctx, a.actor, args.ObjectID,
service.ObjectPatch{XCM: &args.XCM, YCM: &args.YCM}, args.Version)
}
func (a *adapter) placePlanting(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"plantable object to plant in"`
PlantID int64 `json:"plantId" description:"plant to place"`
XCM float64 `json:"xCm" description:"center x in the object's local frame (cm; 0,0 = center, -y = north)"`
YCM float64 `json:"yCm" description:"center y in the object's local frame (cm)"`
RadiusCM float64 `json:"radiusCm" description:"optional plop radius in cm; omit (0) for one plant at half the plant's spacing"`
Count *int `json:"count" description:"optional explicit plant count; omit to derive from area ÷ spacing²"`
PlantedAt string `json:"plantedAt" description:"optional planting date, YYYY-MM-DD; defaults to today"`
SeedLotID *int64 `json:"seedLotId" description:"optional seed lot (from list_seed_lots) this planting uses, so the lot counts it as used"`
}) (any, error) {
return a.svc.CreatePlanting(ctx, a.actor, args.ObjectID, service.PlantingInput{
PlantID: args.PlantID, XCM: args.XCM, YCM: args.YCM, RadiusCM: args.RadiusCM, Count: args.Count,
PlantedAt: a.day(args.PlantedAt), SeedLotID: args.SeedLotID,
})
}
func (a *adapter) fillRegion(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"plantable object to fill"`
Region string `json:"region" description:"nw|ne|sw|se corner, north|south|east|west (or top|bottom|left|right) half, or all; leave empty when giving a rectangle"`
X0CM *float64 `json:"x0Cm" description:"rectangle instead of region: west edge, local cm (0 = center)"`
Y0CM *float64 `json:"y0Cm" description:"rectangle: north edge, local cm (negative is north of center)"`
X1CM *float64 `json:"x1Cm" description:"rectangle: east edge, local cm"`
Y1CM *float64 `json:"y1Cm" description:"rectangle: south edge, local cm"`
PlantID int64 `json:"plantId" description:"plant to fill with"`
SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"`
Mode string `json:"mode" enum:"clump,grid" description:"clump (default) drops a few fat clumps for a quick sketch; grid lays out individual plants in rows at true spacing, a layout you could plant from"`
PlantedAt string `json:"plantedAt" description:"optional planting date for every plop, YYYY-MM-DD; defaults to today"`
SeedLotID *int64 `json:"seedLotId" description:"optional seed lot (from list_seed_lots) this fill uses, so the lot counts it as used"`
}) (any, error) {
spec := service.FillSpec{
RegionName: args.Region, PlantID: args.PlantID, SpacingOverride: args.SpacingOverride,
Layout: service.FillLayout(args.Mode), PlantedAt: a.day(args.PlantedAt), SeedLotID: args.SeedLotID,
}
rect := []*float64{args.X0CM, args.Y0CM, args.X1CM, args.Y1CM}
given := 0
for _, v := range rect {
if v != nil {
given++
}
}
switch {
case given == 4 && strings.TrimSpace(args.Region) == "":
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
case given == 4:
return nil, fmt.Errorf("%w: give either a region name or a rectangle, not both", domain.ErrInvalidInput)
case given > 0:
return nil, fmt.Errorf("%w: a rectangle needs all four of x0Cm, y0Cm, x1Cm, y1Cm", domain.ErrInvalidInput)
default:
return nil, fmt.Errorf("%w: say where to fill — a region name, or a rectangle", domain.ErrInvalidInput)
}
return a.svc.Fill(ctx, a.actor, args.ObjectID, spec)
}
func (a *adapter) movePlanting(ctx context.Context, args struct {
PlantingID int64 `json:"plantingId" description:"plop to move (its id from describe_garden or list_plantings)"`
Version int64 `json:"version" description:"the plop's current version"`
XCM float64 `json:"xCm" description:"new center x in the destination object's local frame (cm; 0,0 = center, -y = north)"`
YCM float64 `json:"yCm" description:"new center y in the destination object's local frame (cm)"`
ToObjectID *int64 `json:"toObjectId" description:"optional: another plantable object in the same garden to move it into; omit to move within its current object"`
}) (any, error) {
return a.svc.MovePlanting(ctx, a.actor, args.PlantingID,
service.MoveInput{ToObjectID: args.ToObjectID, XCM: args.XCM, YCM: args.YCM}, args.Version)
}
func (a *adapter) findPlant(ctx context.Context, args struct {
Query string `json:"query" description:"plant name or category to search for, e.g. \"cucumber\" or \"herb\"; empty lists the catalog"`
}) (any, error) {
return a.svc.FindPlants(ctx, a.actor, args.Query)
}
func (a *adapter) createPlant(ctx context.Context, args struct {
Name string `json:"name" description:"the variety's name, e.g. \"German Red Garlic\""`
Category string `json:"category" description:"vegetable | herb | flower | fruit | tree_shrub | cover"`
SpacingCM float64 `json:"spacingCm" description:"mature in-row spacing in cm; this is what fill_region packs to"`
Color string `json:"color" description:"hex color for the plant on the canvas, e.g. #8a5a8a"`
Icon string `json:"icon" description:"a single emoji to draw it with, e.g. 🧄"`
DaysToMaturity *int `json:"daysToMaturity" description:"optional days from planting to harvest"`
SourceURL string `json:"sourceUrl" description:"optional http(s) link to where the seed came from"`
Vendor string `json:"vendor" description:"optional vendor name, e.g. \"Johnny's Selected Seeds\""`
}) (any, error) {
return a.svc.CreatePlant(ctx, a.actor, service.PlantInput{
Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM,
Color: args.Color, Icon: args.Icon, DaysToMaturity: args.DaysToMaturity,
SourceURL: args.SourceURL, Vendor: args.Vendor,
})
}
func (a *adapter) updatePlant(ctx context.Context, args struct {
PlantID int64 `json:"plantId" description:"plant to change (the user's own, from find_plant)"`
Version int64 `json:"version" description:"the plant's current version (from find_plant)"`
Name *string `json:"name" description:"optional new name"`
Category *string `json:"category" description:"optional: vegetable | herb | flower | fruit | tree_shrub | cover"`
SpacingCM *float64 `json:"spacingCm" description:"optional new mature in-row spacing in cm"`
Color *string `json:"color" description:"optional new hex color"`
DaysToMaturity *int `json:"daysToMaturity" description:"optional days from planting to harvest"`
SourceURL *string `json:"sourceUrl" description:"optional http(s) link to where the seed came from"`
Vendor *string `json:"vendor" description:"optional vendor name"`
Notes *string `json:"notes" description:"optional free-text notes"`
}) (any, error) {
return a.svc.UpdatePlant(ctx, a.actor, args.PlantID, service.PlantPatch{
Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM, Color: args.Color,
SetDays: args.DaysToMaturity != nil, DaysToMaturity: args.DaysToMaturity,
SourceURL: args.SourceURL, Vendor: args.Vendor, Notes: args.Notes,
}, args.Version)
}
func (a *adapter) addJournalEntry(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden the observation is about"`
ObjectID *int64 `json:"objectId" description:"optional bed the observation is about; omit for a garden-level note"`
Body string `json:"body" description:"what happened, in plain words"`
ObservedAt string `json:"observedAt" description:"optional date it happened, YYYY-MM-DD; defaults to today"`
}) (any, error) {
return a.svc.CreateJournalEntry(ctx, a.actor, args.GardenID, service.JournalInput{
ObjectID: args.ObjectID, Body: args.Body, ObservedAt: a.day(args.ObservedAt),
})
}
func (a *adapter) clearObject(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to remove all plants from"`
}) (any, error) {
n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, service.ClearOptions{RemovedAt: a.day("")})
if err != nil {
return nil, err
}
return map[string]int{"cleared": n}, nil
}
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) {
n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID,
service.ClearOptions{PlantID: &args.PlantID, RemovedAt: a.day("")})
if err != nil {
return nil, err
}
return map[string]int{"removed": n}, nil
}
func (a *adapter) readJournal(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden whose journal to read"`
ObjectID *int64 `json:"objectId" description:"optional bed to narrow to; omit for the whole garden"`
From string `json:"from" description:"optional earliest observed date, YYYY-MM-DD"`
To string `json:"to" description:"optional latest observed date, YYYY-MM-DD"`
Offset int `json:"offset" description:"how many entries to skip; pass the count you've already seen to page when hasMore is true"`
}) (any, error) {
q := service.JournalQuery{ObjectID: args.ObjectID, Limit: 50, Offset: args.Offset}
if args.From != "" {
q.From = &args.From
}
if args.To != "" {
q.To = &args.To
}
entries, hasMore, err := a.svc.ListJournal(ctx, a.actor, args.GardenID, q)
if err != nil {
return nil, err
}
// hasMore is actionable now: re-call with offset += len(entries) to page.
return map[string]any{"entries": entries, "hasMore": hasMore}, nil
}
// historyEntry is one change set as read_history reports it: the row a person
// would read in the History panel, not the revision snapshots behind it.
type historyEntry struct {
ID int64 `json:"id"`
When string `json:"when"`
Source string `json:"source"`
Who string `json:"who,omitempty"`
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"`
}
func (a *adapter) readHistory(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden whose history to read"`
Limit int `json:"limit" description:"how many of the newest entries to return (default 20, max 100)"`
Offset int `json:"offset" description:"how many entries to skip; pass the count you've already seen to page when hasMore is true"`
}) (any, error) {
limit := args.Limit
if limit <= 0 {
limit = 20
}
sets, hasMore, err := a.svc.GardenHistory(ctx, a.actor, args.GardenID, limit, args.Offset)
if err != nil {
return nil, err
}
entries := make([]historyEntry, 0, len(sets))
for _, cs := range sets {
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,
})
}
return map[string]any{"entries": entries, "hasMore": hasMore}, nil
}
// describeCounts turns a change set's tallies into words: "12 plantings created,
// 1 object updated".
func describeCounts(counts []domain.ChangeCount) string {
parts := make([]string, 0, len(counts))
for _, c := range counts {
noun := c.EntityType
if c.N != 1 {
noun += "s"
}
parts = append(parts, fmt.Sprintf("%d %s %sd", c.N, noun, c.Op))
}
if len(parts) == 0 {
return "nothing"
}
return strings.Join(parts, ", ")
}
func (a *adapter) updateObject(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to change"`
Version int64 `json:"version" description:"the object's current version (from describe_garden)"`
Name *string `json:"name" description:"optional new label"`
WidthCM *float64 `json:"widthCm" description:"optional new width in cm (a circle's diameter)"`
HeightCM *float64 `json:"heightCm" description:"optional new height in cm"`
RotationDeg *float64 `json:"rotationDeg" description:"optional new rotation in degrees"`
Plantable *bool `json:"plantable" description:"optional: whether the object can hold plants"`
}) (any, error) {
return a.svc.UpdateObject(ctx, a.actor, args.ObjectID, service.ObjectPatch{
Name: args.Name, WidthCM: args.WidthCM, HeightCM: args.HeightCM,
RotationDeg: args.RotationDeg, Plantable: args.Plantable,
}, args.Version)
}
func (a *adapter) deleteObject(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to delete (with its plantings)"`
}) (any, error) {
if err := a.svc.DeleteObject(ctx, a.actor, args.ObjectID); err != nil {
return nil, err
}
return map[string]any{"deleted": args.ObjectID}, nil
}
func (a *adapter) removePlanting(ctx context.Context, args struct {
PlantingID int64 `json:"plantingId" description:"plop to remove (its id from describe_garden)"`
Version int64 `json:"version" description:"the plop's current version (from describe_garden)"`
}) (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(""))
}
func (a *adapter) listSeedLots(ctx context.Context, args struct {
PlantID *int64 `json:"plantId" description:"optional: only lots for this plant"`
}) (any, error) {
return a.svc.ListSeedLots(ctx, a.actor, args.PlantID)
}
func (a *adapter) recordSeedLot(ctx context.Context, args struct {
PlantID int64 `json:"plantId" description:"plant the seed is for (from find_plant); must be the user's own or a built-in"`
Quantity float64 `json:"quantity" description:"how much was bought, in the given unit"`
Unit string `json:"unit" description:"what quantity counts, e.g. packets | seeds | grams"`
Vendor string `json:"vendor" description:"optional vendor name"`
SourceURL string `json:"sourceUrl" description:"optional http(s) link to where it was bought"`
PackedForYear *int `json:"packedForYear" description:"optional 'packed for' year from the packet"`
Notes string `json:"notes" description:"optional free-text notes"`
}) (any, error) {
return a.svc.CreateSeedLot(ctx, a.actor, service.SeedLotInput{
PlantID: args.PlantID, Quantity: args.Quantity, Unit: args.Unit,
Vendor: args.Vendor, SourceURL: args.SourceURL,
PackedForYear: args.PackedForYear, Notes: args.Notes,
})
}
func (a *adapter) copyGarden(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden to duplicate (the user must own it)"`
Name string `json:"name" description:"name for the copy; \"<garden name> — <year>\" makes it that garden's plan for the year"`
}) (any, error) {
return a.svc.CopyGarden(ctx, a.actor, args.GardenID, args.Name)
}