Files
pansy/internal/agent/tools.go
T
steveandClaude Fable 5 8b161c5f6d
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 4m29s
Adversarial Review (Gadfly) / review (pull_request) Successful in 4m29s
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 <[email protected]>
2026-08-23 00:37:23 -04:00

510 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, 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, 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.",
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 (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 "+
"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) == "":
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
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) {
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 {
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"`
// 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 {
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, UndoOf: 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.RemovePlanting(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)
}