Files
pansy/internal/agent/tools.go
T
steveandClaude Opus 4.8 d3b7dd06c9
Build image / build-and-push (push) Successful in 5s
Gadfly review (reusable) / review (pull_request) Successful in 13m34s
Adversarial Review (Gadfly) / review (pull_request) Successful in 13m35s
Add agent seam: bulk ops + majordomo tool wrappers (#19)
Bulk, natural-language-shaped service operations (ACL-enforced like every other
op, so agent tools inherit permissions for free):
- ops.go: Region + NamedRegion (nw/ne/sw/se corners, north/south/east/west and
  top/bottom/left/right halves, "all"; -y is north in the local frame).
  FillRegion hex-packs plops at 2×radius pitch (radius = #15's max(1.5·spacing,
  15cm)) clipped to the region, skipping any candidate that would sit entirely
  inside an existing active plop. ClearObject soft-removes all active plops in one
  UPDATE. DescribeGarden returns a structured summary (dims, objects+version,
  plantings with plant/effective-count/rough compass location).
- store/plantings.go: ListActivePlantingsForObject + ClearObjectPlantings.

Agent toolbox (internal/agent), deliberately isolated:
- doc.go (untagged) keeps the package in the default build; tools.go is behind
  the `majordomo` build tag and NOT in go.mod, so `go build/test ./...` and the
  server binary carry no majordomo/LLM deps. Built + tested locally against real
  majordomo (go test -tags majordomo ./internal/agent/).
- NewToolbox(svc, actorID) exposes list_gardens, describe_garden, create_object,
  move_object, place_planting, fill_region, clear_object as llm.DefineTool
  wrappers — thin typed adapters over the service, each running as the bound
  actor.

Tests: NamedRegion for all names + unknown→ErrInvalidInput; deterministic
hex-packing count (60×60 bed → 4 plops) + re-fill skips covered; rotated bed
fills the correct LOCAL corner; ClearObject; viewer→ErrForbidden on fill/clear
but can describe; and the DESIGN corner/half scenario (garlic NE, basil NW, beans
south) verified via DescribeGarden. A tagged demo drives the same scenario
through the toolbox (JSON args → tool → service) and confirms a viewer is refused.

GOWORK=off go build/vet/test ./internal/... green (majordomo-free).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-19 00:54:03 -04:00

120 lines
5.5 KiB
Go

//go:build majordomo
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 a named region of a plantable object with a plant, hex-packed. Region is one of nw/ne/sw/se (corners), north/south/east/west or top/bottom/left/right (halves), or all.",
a.fillRegion),
llm.DefineTool("clear_object",
"Remove all plants from an object (soft-remove; history is kept).",
a.clearObject),
)
}
// 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"`
}) (any, error) {
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride)
}
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
}