Everything below the run loop already existed. This is the thing that runs a model. The build tag is gone, deliberately. internal/agent's doc comment promised two separations — cmd/pansy not importing the package, and the tool wiring behind //go:build majordomo — and both have been rewritten rather than left as a stale aspiration. A tag that keeps the agent out of the binary only earns its keep if you would ever ship a build without the agent, and the agent is the point; keeping it meant an untagged CI that never compiled the code that matters. majordomo is a real dependency now, resolved from the Gitea instance as a pseudo-version with no replace directive, so the Docker build (which has no sibling checkout) resolves it the same way this machine does. It is stdlib-first and pure Go, so CGO_ENABLED=0 and the single static binary survive. A TURN IS ONE CHANGE SET. That is the whole reason acting without a confirmation prompt is defensible: "empty the garlic bed and plant cucumbers" is one object edit and a dozen planting inserts, and it has to undo as one action rather than thirteen. The scope is opened even for a turn that turns out to be a question, because a change set with no revisions is never written — so asking costs nothing and history isn't littered with empty entries. The model spec goes to majordomo.Parse verbatim. That grammar, including comma-separated failover chains, is majordomo's; re-implementing any of it here would only mean two places to update when it grows. The key needs a bridge though: majordomo's ollama-cloud preset reads OLLAMA_API_KEY while pansy (like gadfly) is configured with OLLAMA_CLOUD_API_KEY, so the provider is registered explicitly on a private registry rather than depending on ambient environment. Runs are bounded by a step cap, a timeout and majordomo's loop guards. This is loop safety, not cost control — pansy is a personal tool and spend caps are explicitly not a v2 concern. A capped run does NOT fail: it kept whatever it managed to do, that work is recorded and undoable, and the reply says it stopped early rather than going silent. The chat endpoint streams. A turn that clears a bed and replants it makes a dozen tool calls over tens of seconds, and without streaming that is a long silence followed by everything at once — which reads as a hang, and defeats a design that rests on watching the canvas change as it happens. Conversations persist per (user, garden). Client-held history would be lost on a refresh, which is exactly when someone reloads to check whether the agent's change landed. Only the user/assistant TEXT is stored, not the model's full transcript: continuity needs what was said and what came back, and replaying a stored tool call would replay a decision made against a garden that has since moved on. It also keeps majordomo's message shape out of the schema. An instance with no key starts, serves the app, and doesn't advertise the agent — the routes aren't registered at all, the same shape as OIDC 404ing when unconfigured. A configured-but-unresolvable model logs and disables the assistant rather than refusing to boot: a garden planner that won't start because of a chat feature is worse than one without chat. Tool refusals reach the model as tool results it can explain, not 500s. The ACL story only works if it can narrate the refusal. Closes #56 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
179 lines
9.2 KiB
Go
179 lines
9.2 KiB
Go
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 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),
|
|
)
|
|
}
|
|
|
|
// 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) 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
|
|
}
|