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
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
)
|
||||
|
||||
// This file holds pansy's bulk, "natural-language-shaped" operations — the ones
|
||||
// an agent drives ("fill the NE corner with garlic", "clear the bed"). They live
|
||||
// on *Service like every other operation, so agent tools (internal/agent) and any
|
||||
// future REST surface inherit the same ACL enforcement via objectForRole /
|
||||
// requireGardenRole. Geometry is in each object's LOCAL frame (origin at the
|
||||
// object's center, +x east, +y south, so -y is NORTH).
|
||||
|
||||
// Region is an area in an object's local frame: an axis-aligned rectangle, or a
|
||||
// circle when Circle is set. NamedRegion produces the common ones.
|
||||
type Region struct {
|
||||
MinX, MinY, MaxX, MaxY float64 // rectangle (also the circle's bounding box)
|
||||
Circle bool
|
||||
CX, CY, Radius float64 // used when Circle
|
||||
}
|
||||
|
||||
// contains reports whether a local point lies in the region.
|
||||
func (r Region) contains(x, y float64) bool {
|
||||
if r.Circle {
|
||||
dx, dy := x-r.CX, y-r.CY
|
||||
return dx*dx+dy*dy <= r.Radius*r.Radius
|
||||
}
|
||||
return x >= r.MinX && x <= r.MaxX && y >= r.MinY && y <= r.MaxY
|
||||
}
|
||||
|
||||
// rect builds a rectangular region.
|
||||
func rect(minX, minY, maxX, maxY float64) Region {
|
||||
return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY}
|
||||
}
|
||||
|
||||
// NamedRegion resolves a compass name to a Region in the object's local frame.
|
||||
// Recognizes the quarter corners "nw|ne|sw|se", the halves
|
||||
// "north|south|east|west" and their "top|bottom|left|right" synonyms, and "all".
|
||||
// A trailing "corner"/"half" word is ignored ("NE corner", "south half"). North
|
||||
// is -y (see the file header). Unknown names return ErrInvalidInput.
|
||||
func NamedRegion(o *domain.GardenObject, name string) (Region, error) {
|
||||
hw, hh := o.WidthCM/2, o.HeightCM/2
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
key = strings.TrimSpace(strings.TrimSuffix(key, "corner"))
|
||||
key = strings.TrimSpace(strings.TrimSuffix(key, "half"))
|
||||
|
||||
switch key {
|
||||
case "all", "":
|
||||
return rect(-hw, -hh, hw, hh), nil
|
||||
case "north", "top":
|
||||
return rect(-hw, -hh, hw, 0), nil
|
||||
case "south", "bottom":
|
||||
return rect(-hw, 0, hw, hh), nil
|
||||
case "east", "right":
|
||||
return rect(0, -hh, hw, hh), nil
|
||||
case "west", "left":
|
||||
return rect(-hw, -hh, 0, hh), nil
|
||||
case "nw", "northwest":
|
||||
return rect(-hw, -hh, 0, 0), nil
|
||||
case "ne", "northeast":
|
||||
return rect(0, -hh, hw, 0), nil
|
||||
case "sw", "southwest":
|
||||
return rect(-hw, 0, 0, hh), nil
|
||||
case "se", "southeast":
|
||||
return rect(0, 0, hw, hh), nil
|
||||
default:
|
||||
return Region{}, domain.ErrInvalidInput
|
||||
}
|
||||
}
|
||||
|
||||
// defaultPlopRadius is #15's formula for a placed plop's radius: max(1.5×spacing,
|
||||
// 15cm). Reused here (don't duplicate the constant elsewhere).
|
||||
func defaultPlopRadius(spacingCM float64) float64 {
|
||||
return math.Max(1.5*spacingCM, 15)
|
||||
}
|
||||
|
||||
// FillRegion lays a hex-packed field of plops of one plant across a region of a
|
||||
// plantable object the actor can edit. Plop radius comes from the plant's spacing
|
||||
// (or spacingOverride) via defaultPlopRadius; centers sit on a hex lattice at 2×
|
||||
// radius pitch, kept where the center is inside the region. A candidate is
|
||||
// skipped when its plop would sit entirely inside an existing active plop (so
|
||||
// re-filling doesn't stack duplicates). Returns the plops it created.
|
||||
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
||||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !o.Plantable {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
plant, err := s.visiblePlant(ctx, actorID, plantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spacing := plant.SpacingCM
|
||||
if spacingOverride != nil {
|
||||
if !isFinite(*spacingOverride) || *spacingOverride < minPlantSpacingCM || *spacingOverride > maxPlantSpacingCM {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
spacing = *spacingOverride
|
||||
}
|
||||
radius := defaultPlopRadius(spacing)
|
||||
|
||||
existing, err := s.store.ListActivePlantingsForObject(ctx, objectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
today := s.now().UTC().Format(dateLayout)
|
||||
halfW, halfH := o.WidthCM/2, o.HeightCM/2
|
||||
created := []domain.Planting{}
|
||||
for _, c := range hexCenters(region, radius) {
|
||||
// The center must be inside the object (region ⊆ bounds already, but guard).
|
||||
if math.Abs(c.x) > halfW || math.Abs(c.y) > halfH {
|
||||
continue
|
||||
}
|
||||
if coveredByExisting(c.x, c.y, radius, existing) {
|
||||
continue
|
||||
}
|
||||
p := &domain.Planting{ObjectID: objectID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today}
|
||||
stored, err := s.store.CreatePlanting(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored.DerivedCount = derivedCount(stored.RadiusCM, spacing)
|
||||
created = append(created, *stored)
|
||||
// Count what we just placed so later candidates in this same fill don't
|
||||
// stack on top of it.
|
||||
existing = append(existing, *stored)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
type localPoint struct{ x, y float64 }
|
||||
|
||||
// hexCenters returns hex-packed lattice centers whose center lies in the region.
|
||||
// Rows are spaced radius·√3 apart and every other row is offset by radius, the
|
||||
// standard hexagonal packing at a 2×radius pitch. The lattice is anchored one
|
||||
// radius inside the region's min corner so the first plop sits inside it.
|
||||
func hexCenters(r Region, radius float64) []localPoint {
|
||||
if radius <= 0 {
|
||||
return nil
|
||||
}
|
||||
pitch := 2 * radius
|
||||
rowH := pitch * math.Sqrt(3) / 2
|
||||
const eps = 1e-6
|
||||
var pts []localPoint
|
||||
row := 0
|
||||
for y := r.MinY + radius; y <= r.MaxY+eps; y += rowH {
|
||||
xStart := r.MinX + radius
|
||||
if row%2 == 1 {
|
||||
xStart += radius
|
||||
}
|
||||
for x := xStart; x <= r.MaxX+eps; x += pitch {
|
||||
if r.contains(x, y) {
|
||||
pts = append(pts, localPoint{x, y})
|
||||
}
|
||||
}
|
||||
row++
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
// coveredByExisting reports whether a new plop (center, radius) would sit
|
||||
// entirely inside some existing active plop.
|
||||
func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
|
||||
for _, e := range existing {
|
||||
if math.Hypot(x-e.XCM, y-e.YCM)+radius <= e.RadiusCM {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half")
|
||||
// instead of a resolved Region — the ergonomic form for agent tools, which don't
|
||||
// hold the object's geometry. It resolves the name against the object, then fills.
|
||||
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
||||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
region, err := NamedRegion(o, regionName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.FillRegion(ctx, actorID, objectID, region, plantID, spacingOverride)
|
||||
}
|
||||
|
||||
// ClearObject soft-removes every active plop in an object the actor can edit (one
|
||||
// UPDATE), returning how many were cleared. Distinct from deleting the object.
|
||||
func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int, error) {
|
||||
if _, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
today := s.now().UTC().Format(dateLayout)
|
||||
return s.store.ClearObjectPlantings(ctx, objectID, today)
|
||||
}
|
||||
|
||||
// DescribeResult is a structured summary of a garden for prompting an agent.
|
||||
type DescribeResult struct {
|
||||
GardenID int64 `json:"gardenId"`
|
||||
Name string `json:"name"`
|
||||
WidthCM float64 `json:"widthCm"`
|
||||
HeightCM float64 `json:"heightCm"`
|
||||
UnitPref string `json:"unitPref"`
|
||||
Objects []DescribeObject `json:"objects"`
|
||||
}
|
||||
|
||||
// DescribeObject is one object plus its active plantings, for DescribeResult.
|
||||
// Version is included so an agent can move/edit the object (the mutation guard).
|
||||
type DescribeObject struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Shape string `json:"shape"`
|
||||
WidthCM float64 `json:"widthCm"`
|
||||
HeightCM float64 `json:"heightCm"`
|
||||
XCM float64 `json:"xCm"`
|
||||
YCM float64 `json:"yCm"`
|
||||
RotationDeg float64 `json:"rotationDeg"`
|
||||
Plantable bool `json:"plantable"`
|
||||
Version int64 `json:"version"`
|
||||
Plantings []DescribePlanting `json:"plantings"`
|
||||
}
|
||||
|
||||
// DescribePlanting is one plop with a rough compass location, for DescribeResult.
|
||||
type DescribePlanting struct {
|
||||
PlantID int64 `json:"plantId"`
|
||||
Plant string `json:"plant"`
|
||||
Count int `json:"count"`
|
||||
Location string `json:"location"`
|
||||
RadiusCM float64 `json:"radiusCm"`
|
||||
}
|
||||
|
||||
// DescribeGarden returns a structured summary — dimensions, objects, and each
|
||||
// object's active plantings (plant, effective count, rough location) — for a
|
||||
// garden the actor can view. Built on GardenFull so it inherits the ACL check.
|
||||
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) {
|
||||
full, err := s.GardenFull(ctx, actorID, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plantByID := make(map[int64]domain.Plant, len(full.Plants))
|
||||
for _, p := range full.Plants {
|
||||
plantByID[p.ID] = p
|
||||
}
|
||||
plopsByObject := make(map[int64][]domain.Planting)
|
||||
for _, pl := range full.Plantings {
|
||||
plopsByObject[pl.ObjectID] = append(plopsByObject[pl.ObjectID], pl)
|
||||
}
|
||||
|
||||
res := &DescribeResult{
|
||||
GardenID: full.Garden.ID,
|
||||
Name: full.Garden.Name,
|
||||
WidthCM: full.Garden.WidthCM,
|
||||
HeightCM: full.Garden.HeightCM,
|
||||
UnitPref: full.Garden.UnitPref,
|
||||
Objects: make([]DescribeObject, 0, len(full.Objects)),
|
||||
}
|
||||
for _, o := range full.Objects {
|
||||
do := DescribeObject{
|
||||
ID: o.ID, Kind: o.Kind, Name: o.Name, Shape: o.Shape,
|
||||
WidthCM: o.WidthCM, HeightCM: o.HeightCM, XCM: o.XCM, YCM: o.YCM,
|
||||
RotationDeg: o.RotationDeg, Plantable: o.Plantable, Version: o.Version,
|
||||
Plantings: []DescribePlanting{},
|
||||
}
|
||||
for _, pl := range plopsByObject[o.ID] {
|
||||
count := pl.DerivedCount
|
||||
if pl.Count != nil {
|
||||
count = *pl.Count
|
||||
}
|
||||
do.Plantings = append(do.Plantings, DescribePlanting{
|
||||
PlantID: pl.PlantID,
|
||||
Plant: plantByID[pl.PlantID].Name,
|
||||
Count: count,
|
||||
Location: describeLocation(pl.XCM, pl.YCM),
|
||||
RadiusCM: pl.RadiusCM,
|
||||
})
|
||||
}
|
||||
res.Objects = append(res.Objects, do)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// describeLocation reverse-maps a local point to a rough compass location — the
|
||||
// inverse of NamedRegion's quarters/halves ("NE corner", "south", "center").
|
||||
func describeLocation(x, y float64) string {
|
||||
const eps = 1e-6
|
||||
ns := ""
|
||||
switch {
|
||||
case y < -eps:
|
||||
ns = "N"
|
||||
case y > eps:
|
||||
ns = "S"
|
||||
}
|
||||
ew := ""
|
||||
switch {
|
||||
case x < -eps:
|
||||
ew = "W"
|
||||
case x > eps:
|
||||
ew = "E"
|
||||
}
|
||||
switch {
|
||||
case ns == "" && ew == "":
|
||||
return "center"
|
||||
case ns != "" && ew != "":
|
||||
return ns + ew + " corner"
|
||||
case ns == "N":
|
||||
return "north"
|
||||
case ns == "S":
|
||||
return "south"
|
||||
case ew == "E":
|
||||
return "east"
|
||||
default:
|
||||
return "west"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user