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 \"\" "+ "(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; \"\" makes it that garden's plan for the year"` }) (any, error) { return a.svc.CopyGarden(ctx, a.actor, args.GardenID, args.Name) }