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,133 @@
|
||||
//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/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, _ := svc.CreatePlant(ctx, owner.ID, service.PlantInput{Name: "Garlic", Category: "vegetable", SpacingCM: 15, Color: "#d9d2c5", Icon: "🧄"})
|
||||
basil, _ := svc.CreatePlant(ctx, owner.ID, service.PlantInput{Name: "Basil", Category: "herb", SpacingCM: 25, Color: "#4a7c3f", Icon: "🌿"})
|
||||
beans, _ := svc.CreatePlant(ctx, owner.ID, service.PlantInput{Name: "Beans", Category: "vegetable", SpacingCM: 10, Color: "#6b8e23", Icon: "🫘"})
|
||||
|
||||
// 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, _ := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "V", Password: "password123"})
|
||||
if _, err := svc.AddShare(ctx, owner.ID, g.ID, "[email protected]", "viewer"); 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(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(v any) json.RawMessage {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user