Files
pansy/internal/agent/tools.go
T
steveandClaude Opus 4.8 7c1faa1515
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 12m51s
Adversarial Review (Gadfly) / review (pull_request) Successful in 12m51s
Fill mode: a "grid" layout that plants individual plants in rows (#77)
Steve chose option 3: keep clumps as the default primitive, add a grid/rows
fill mode, so sketching and planning are different operations with different
outputs rather than one model forced to be both.

A plop is a CLUMP, not a plant — great for "a few plops of garlic in a corner",
useless for drawing a plantable 8-rows-of-garlic bed (that came out as ~15
blobs, #77). FillLayout selects what a fill packs:
- clump (default, unchanged): radius 1.5×spacing, ~7 plants per plop.
- grid: radius spacing/2, pitch = spacing, ONE plant per plop — rows you could
  actually plant from.

The geometry is the SAME hexCenters lattice and the SAME #75 half-spacing edge
rule; only the radius→spacing relationship differs (plopRadiusFor). Grid keeps
no 15cm floor — its whole point is true spacing — while clump keeps it so a
tiny-spacing plant doesn't make invisible clumps.

Threaded through FillRegion/FillNamedRegion (empty layout = clump, so existing
callers are unchanged; unknown layout = ErrInvalidInput), the REST /fill
endpoint (`layout`), and the agent's fill_region tool (`mode`, enum clump|grid),
so "plant the bed in rows" works.

Tests: grid produces many more, single-plant plops than clump on the same bed
(radius spacing/2, derived count 1); unknown layout is refused at both the
service and the API. maxFillPlops still caps a grid fill of a huge bed.

No frontend fill affordance exists yet (fill is agent-only in the UI; the fill
UI was deferred in #82), so the mode toggle rides along when that's built —
noted. Docs: DESIGN placement-model decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 00:01:27 -04:00

180 lines
9.5 KiB
Go

package agent
import (
"context"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// NewToolbox builds a majordomo toolbox over pansy's service layer, bound to a
// single acting user. 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)
// agent.Run(ctx, model, box, "fill the NE corner with garlic")
func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
a := &adapter{svc: svc, actor: actorID}
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 with a rough compass location.",
a.describeGarden),
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).",
a.placePlanting),
llm.DefineTool("fill_region",
"Fill part of a plantable object with one plant, hex-packed at the plant's spacing. "+
"region is 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. 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.",
a.fillRegion),
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("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. observedAt defaults to today; set it to backdate.",
a.addJournalEntry),
)
}
// adapter carries the service and the acting user for the tool handlers.
type adapter struct {
svc *service.Service
actor int64
}
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) 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:"plop radius in cm"`
Count *int `json:"count" description:"optional explicit plant count; omit to derive from area ÷ spacing²"`
}) (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,
})
}
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"`
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"`
}) (any, error) {
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride, service.FillLayout(args.Mode))
}
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\u0027s 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) 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) {
in := service.JournalInput{ObjectID: args.ObjectID, Body: args.Body}
if args.ObservedAt != "" {
in.ObservedAt = &args.ObservedAt
}
return a.svc.CreateJournalEntry(ctx, a.actor, args.GardenID, in)
}
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.ClearObject(ctx, a.actor, args.ObjectID)
if err != nil {
return nil, err
}
return map[string]int{"cleared": n}, nil
}