The toolbox could create and move but not delete or resize; write the journal but not read it; clear a whole bed but not pull one plant; report seed remaining but not record a purchase. Close those gaps with thin adapters over the SAME service methods the REST API uses, so they inherit the permission checks unchanged: read_journal → ListJournal (the write/read asymmetry, most visible) update_object → UpdateObject (resize / rotate / rename / plantable) delete_object → DeleteObject (counterpart to create_object) remove_planting → UpdatePlanting (soft-remove ONE plop, like clear does) list_seed_lots → ListSeedLots record_seed_lot → CreateSeedLot (record a purchase; "I bought 2 packets") To address a single plop the agent needs its id + version, so DescribePlanting now carries both — the same way DescribeObject.Version already lets it edit an object. remove_planting soft-removes (removed_at = today), mirroring clear_object, so the plant stays in planting history and the change is undoable. Deferred deliberately: an undo/revert tool needs a way to list recent change sets to get a changeSetId, which is a larger addition; noted on the issue for a follow-up. Tested through the tool layer (TestCorrectiveTools): resize, single-plop removal, journal read-back, seed-lot record+list, and delete. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
292 lines
15 KiB
Go
292 lines
15 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"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),
|
|
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("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("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("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\".",
|
|
a.recordSeedLot),
|
|
)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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"`
|
|
}) (any, error) {
|
|
q := service.JournalQuery{ObjectID: args.ObjectID, Limit: 50}
|
|
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
|
|
}
|
|
return map[string]any{"entries": entries, "hasMore": hasMore}, nil
|
|
}
|
|
|
|
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: set removed_at to today, mirroring clear_object, so the plop
|
|
// stays in the planting history and the change is undoable.
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
return a.svc.UpdatePlanting(ctx, a.actor, args.PlantingID,
|
|
service.PlantingPatch{SetRemovedAt: true, RemovedAt: &today}, args.Version)
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|