Agent seam: bulk ops (FillRegion/ClearObject/DescribeGarden) + DefineTool wrappers (#19)
Build image / build-and-push (push) Successful in 5s
Build image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #38.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
// Package agent adapts pansy's service layer to majordomo tools so an agent can
|
||||
// drive a garden in natural language ("fill the NE corner with garlic, the NW
|
||||
// with basil, the south half with beans"). Each tool runs as a fixed actor and
|
||||
// therefore inherits pansy's ACL checks for free — a viewer's fill_region returns
|
||||
// ErrForbidden, exactly as the REST API would.
|
||||
//
|
||||
// Two deliberate separations keep the core server lean:
|
||||
//
|
||||
// - cmd/pansy does NOT import this package, so the server binary carries no
|
||||
// agent/LLM dependencies.
|
||||
// - the tool wiring (tools.go) sits behind the `majordomo` build tag, so the
|
||||
// default `go build ./...` / `go test ./...` compiles without the majordomo
|
||||
// module. Build the agent tools with `-tags majordomo` once majordomo is a
|
||||
// dependency (`go get gitea.stevedudenhoeffer.com/steve/majordomo`).
|
||||
//
|
||||
// The agent harness itself (model loop, chat surface) lives in the
|
||||
// majordomo/executus stack, outside this repo; this package is only the toolbox.
|
||||
package agent
|
||||
@@ -0,0 +1,119 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//go:build majordomo
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
|
||||
)
|
||||
|
||||
// TestToolboxScenario is the #19 demo harness: it drives the DESIGN scenario
|
||||
// through the tool layer (JSON args → DefineTool → service) rather than a live
|
||||
// model — fill the NE corner with garlic, the NW with basil, the south with
|
||||
// beans — and checks describe_garden reports three groups in the right places.
|
||||
// It also confirms the toolbox inherits pansy's ACL (a viewer is refused).
|
||||
//
|
||||
// Build/run with: go test -tags majordomo ./internal/agent/
|
||||
func TestToolboxScenario(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if err := db.Migrate(ctx); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
svc := service.New(db, &config.Config{Registration: config.RegistrationOpen, LocalAuth: true})
|
||||
|
||||
owner, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "A", Password: "password123"})
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
box := NewToolbox(svc, owner.ID)
|
||||
|
||||
call := func(name string, args any) llm.ToolResult {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(args)
|
||||
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: raw})
|
||||
}
|
||||
|
||||
// Garden + plants are set up directly (there are no create_garden/plant tools);
|
||||
// the agent-facing bits — object + fills + describe — go through the toolbox.
|
||||
g, err := svc.CreateGarden(ctx, owner.ID, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
garlic := mustPlant(t, svc, owner.ID, "Garlic", 15, "🧄")
|
||||
basil := mustPlant(t, svc, owner.ID, "Basil", 25, "🌿")
|
||||
beans := mustPlant(t, svc, owner.ID, "Beans", 10, "🫘")
|
||||
|
||||
// create_object → a 400×400 bed.
|
||||
res := call("create_object", map[string]any{
|
||||
"gardenId": g.ID, "kind": "bed", "name": "Bed 1", "xCm": 1000, "yCm": 1000, "widthCm": 400, "heightCm": 400,
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("create_object: %s", res.Content)
|
||||
}
|
||||
var bed struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(res.Content), &bed); err != nil {
|
||||
t.Fatalf("decode created object: %v", err)
|
||||
}
|
||||
|
||||
// fill_region for each corner/half.
|
||||
for _, f := range []struct {
|
||||
region string
|
||||
plantID int64
|
||||
}{{"ne", garlic.ID}, {"nw", basil.ID}, {"south", beans.ID}} {
|
||||
if r := call("fill_region", map[string]any{"objectId": bed.ID, "region": f.region, "plantId": f.plantID}); r.IsError {
|
||||
t.Fatalf("fill_region %s: %s", f.region, r.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// describe_garden → parse and check locations.
|
||||
res = call("describe_garden", map[string]any{"gardenId": g.ID})
|
||||
if res.IsError {
|
||||
t.Fatalf("describe_garden: %s", res.Content)
|
||||
}
|
||||
var desc service.DescribeResult
|
||||
if err := json.Unmarshal([]byte(res.Content), &desc); err != nil {
|
||||
t.Fatalf("decode describe: %v", err)
|
||||
}
|
||||
if len(desc.Objects) != 1 {
|
||||
t.Fatalf("objects = %d, want 1", len(desc.Objects))
|
||||
}
|
||||
seen := map[string]map[string]bool{}
|
||||
for _, p := range desc.Objects[0].Plantings {
|
||||
if seen[p.Plant] == nil {
|
||||
seen[p.Plant] = map[string]bool{}
|
||||
}
|
||||
seen[p.Plant][p.Location] = true
|
||||
}
|
||||
if !seen["Garlic"]["NE corner"] {
|
||||
t.Errorf("garlic at %v, want NE corner", seen["Garlic"])
|
||||
}
|
||||
if !seen["Basil"]["NW corner"] {
|
||||
t.Errorf("basil at %v, want NW corner", seen["Basil"])
|
||||
}
|
||||
if len(seen["Beans"]) == 0 {
|
||||
t.Error("beans produced no plops")
|
||||
}
|
||||
for loc := range seen["Beans"] {
|
||||
if loc == "north" || loc == "NE corner" || loc == "NW corner" || loc == "center" {
|
||||
t.Errorf("beans at %q, want only southern locations", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// ACL: a viewer's fill_region is refused (the toolbox runs as that actor).
|
||||
viewerUser, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "V", Password: "password123"})
|
||||
if err != nil {
|
||||
t.Fatalf("register viewer: %v", err)
|
||||
}
|
||||
if _, err := svc.AddShare(ctx, owner.ID, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||||
t.Fatalf("share: %v", err)
|
||||
}
|
||||
viewerBox := NewToolbox(svc, viewerUser.ID)
|
||||
vr := viewerBox.Execute(ctx, llm.ToolCall{ID: "2", Name: "fill_region", Arguments: mustJSON(t, map[string]any{
|
||||
"objectId": bed.ID, "region": "all", "plantId": garlic.ID,
|
||||
})})
|
||||
if !vr.IsError {
|
||||
t.Errorf("viewer fill_region succeeded, want a permission error")
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) json.RawMessage {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func mustPlant(t *testing.T, svc *service.Service, owner int64, name string, spacing float64, icon string) *domain.Plant {
|
||||
t.Helper()
|
||||
p, err := svc.CreatePlant(context.Background(), owner, service.PlantInput{
|
||||
Name: name, Category: domain.CategoryVegetable, SpacingCM: spacing, Color: "#4a7c3f", Icon: icon,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create plant %s: %v", name, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
Reference in New Issue
Block a user