Agent runtime: majordomo in-process, Ollama Cloud config, chat endpoint (#56)
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
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
|
||||
// Loop bounds. These exist so a stuck run terminates, NOT to control spend —
|
||||
// pansy is a personal tool and multi-tenant cost control is explicitly not a
|
||||
// concern here. A model that gets wedged calling describe_garden forever should
|
||||
// stop on its own, and a hung upstream shouldn't hold a connection open all day.
|
||||
const (
|
||||
maxSteps = 24
|
||||
// A turn that legitimately fills several beds does a lot of round trips, so
|
||||
// this is generous; it is a backstop, not a budget.
|
||||
runTimeout = 4 * time.Minute
|
||||
// Successive all-error steps, and identical repeated calls, that end a run.
|
||||
maxConsecutiveToolErrors = 4
|
||||
maxSameCallRepeats = 3
|
||||
)
|
||||
|
||||
// Runner drives a model over pansy's toolbox. One per process; Run is safe to
|
||||
// call concurrently.
|
||||
type Runner struct {
|
||||
svc *service.Service
|
||||
model llm.Model
|
||||
}
|
||||
|
||||
// NewRunner resolves the configured model and returns a Runner, or an error if
|
||||
// the assistant can't be offered. Callers should treat an error as "no
|
||||
// assistant" rather than a startup failure — an instance with no key must still
|
||||
// serve the app.
|
||||
func NewRunner(svc *service.Service, cfg *config.Config) (*Runner, error) {
|
||||
if !cfg.Agent.Ready() {
|
||||
return nil, errors.New("agent: not configured")
|
||||
}
|
||||
|
||||
// A private registry, not the package-level default: pansy passes the key it
|
||||
// was configured with rather than depending on ambient environment, and
|
||||
// majordomo's own ollama-cloud preset reads OLLAMA_API_KEY while pansy (like
|
||||
// gadfly) is configured with OLLAMA_CLOUD_API_KEY. Registering the provider
|
||||
// explicitly makes that bridge visible instead of a mysterious empty token.
|
||||
reg := majordomo.New()
|
||||
reg.RegisterProvider(ollama.Cloud(ollama.WithToken(cfg.Agent.OllamaCloudAPIKey)))
|
||||
|
||||
// The spec goes to Parse VERBATIM. The grammar — including comma-separated
|
||||
// failover chains — is majordomo's, and re-implementing any of it here would
|
||||
// only mean two places to update when it grows.
|
||||
model, err := reg.Parse(cfg.Agent.Model)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agent: resolve model %q: %w", cfg.Agent.Model, err)
|
||||
}
|
||||
return &Runner{svc: svc, model: model}, nil
|
||||
}
|
||||
|
||||
// Turn is the outcome of one exchange.
|
||||
type Turn struct {
|
||||
// Reply is what to show the user.
|
||||
Reply string `json:"reply"`
|
||||
// ChangeSetID is the change set this turn produced, if it changed anything —
|
||||
// the handle the UI needs to offer Undo.
|
||||
ChangeSetID *int64 `json:"changeSetId,omitempty"`
|
||||
// Steps is how many model round trips it took.
|
||||
Steps int `json:"steps"`
|
||||
// Truncated is set when the run hit its step cap rather than finishing.
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
// History is the transcript to feed back into the next turn.
|
||||
History []llm.Message `json:"-"`
|
||||
}
|
||||
|
||||
// Run executes one turn against a garden, as actorID.
|
||||
//
|
||||
// The whole turn runs inside ONE change set, so everything the model did undoes
|
||||
// together. That is what makes acting without a confirmation prompt defensible.
|
||||
// The scope is opened even for a turn that turns out to be a question — a change
|
||||
// set with no revisions is never written, so asking costs nothing.
|
||||
func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message string, history []llm.Message, onStep func(agent.Step)) (*Turn, error) {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, runTimeout)
|
||||
defer cancel()
|
||||
|
||||
garden, err := r.svc.GetGarden(ctx, actorID, gardenID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
result *agent.Result
|
||||
runErr error
|
||||
truncErr bool
|
||||
)
|
||||
changeSet, err := r.svc.WithChangeSet(ctx, actorID, gardenID, service.ChangeSetOptions{
|
||||
Source: domain.SourceAgent,
|
||||
Summary: turnSummary(message),
|
||||
}, func(ctx context.Context) error {
|
||||
box := NewToolbox(r.svc, actorID)
|
||||
a := agent.New(r.model, systemPrompt(garden),
|
||||
agent.WithMaxSteps(maxSteps),
|
||||
agent.WithToolErrorLimits(maxConsecutiveToolErrors, maxSameCallRepeats),
|
||||
)
|
||||
a.AddToolbox(box)
|
||||
|
||||
opts := []agent.RunOption{agent.WithHistory(history)}
|
||||
if onStep != nil {
|
||||
opts = append(opts, agent.OnStep(onStep))
|
||||
}
|
||||
result, runErr = a.Run(ctx, message, opts...)
|
||||
// A run that ran out of steps still DID things, and those things must be
|
||||
// recorded and undoable. So the loop-guard errors don't fail the scope —
|
||||
// they're reported to the user instead.
|
||||
if runErr != nil && isLoopLimit(runErr) {
|
||||
truncErr = true
|
||||
return nil
|
||||
}
|
||||
return runErr
|
||||
})
|
||||
if err != nil {
|
||||
// WithChangeSet records whatever committed before failing, so the partial
|
||||
// work is undoable even though the turn errored.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
turn := &Turn{Truncated: truncErr}
|
||||
if changeSet != nil {
|
||||
turn.ChangeSetID = &changeSet.ID
|
||||
}
|
||||
if result != nil {
|
||||
turn.Reply = result.Output
|
||||
turn.Steps = len(result.Steps)
|
||||
turn.History = result.Messages
|
||||
}
|
||||
if turn.Reply == "" {
|
||||
turn.Reply = fallbackReply(turn)
|
||||
}
|
||||
return turn, nil
|
||||
}
|
||||
|
||||
// isLoopLimit reports whether an error is one of majordomo's loop guards firing
|
||||
// rather than a genuine failure. Those runs have a partial result worth keeping.
|
||||
func isLoopLimit(err error) bool {
|
||||
return errors.Is(err, agent.ErrMaxSteps) || errors.Is(err, agent.ErrToolLoop)
|
||||
}
|
||||
|
||||
// fallbackReply covers a run that finished with no text — a model that made its
|
||||
// last tool call and then stopped. Silence reads as a failure, so say what
|
||||
// happened.
|
||||
func fallbackReply(t *Turn) string {
|
||||
switch {
|
||||
case t.Truncated:
|
||||
return "I stopped partway through — that turned into more steps than I should take in one go. " +
|
||||
"Have a look at what changed, and tell me what to do next."
|
||||
case t.ChangeSetID != nil:
|
||||
return "Done — have a look at the canvas."
|
||||
default:
|
||||
return "I didn't change anything."
|
||||
}
|
||||
}
|
||||
|
||||
// turnSummary is what the history list shows for this turn. The user's own words
|
||||
// are the most useful label available, trimmed to fit a list row.
|
||||
func turnSummary(message string) string {
|
||||
const max = 120
|
||||
s := strings.Join(strings.Fields(message), " ")
|
||||
if len(s) > max {
|
||||
s = strings.TrimSpace(s[:max]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// systemPrompt gives the model the conventions it cannot infer.
|
||||
//
|
||||
// The compass convention in particular is not guessable: -y is north because
|
||||
// screen y grows downward, and a model that assumes otherwise plants the south
|
||||
// half when asked for the north one.
|
||||
func systemPrompt(g *domain.Garden) string {
|
||||
units := "metric — all measurements are centimeters"
|
||||
if g.UnitPref == domain.UnitImperial {
|
||||
units = "imperial for display, but every measurement you send or receive is in CENTIMETERS"
|
||||
}
|
||||
return fmt.Sprintf(`You are pansy's garden assistant. You help plan and edit a real garden by calling tools.
|
||||
|
||||
The garden you are working on is %q (id %d), %.0f x %.0f cm. The user's units are %s.
|
||||
|
||||
Conventions you cannot guess and must not assume:
|
||||
- Positions in a garden are centimeters from its top-left corner: x grows east, y grows SOUTH.
|
||||
- Inside an object (a bed), positions are relative to that object's CENTER, and -y is NORTH.
|
||||
So the north half of a bed is negative y. Getting this backwards plants the wrong end.
|
||||
- Objects and plantings are version-guarded. Use the version from describe_garden when editing.
|
||||
|
||||
How to work:
|
||||
- Start from describe_garden to see what is actually there. Do not guess ids.
|
||||
- Use find_plant to turn a plant name into an id. If it returns several candidates,
|
||||
pick the one that matches what the user said, or ask them which they meant.
|
||||
- To replant a bed with something else: clear_object, then fill_region with region "all".
|
||||
- When a tool refuses (for example, the user only has view access to this garden),
|
||||
explain what happened in plain words. Do not retry it.
|
||||
|
||||
When you are done, say briefly what you changed — the user is watching the canvas
|
||||
and wants to know what to look at. If you changed nothing, say that too.`,
|
||||
g.Name, g.ID, g.WidthCM, g.HeightCM, units)
|
||||
}
|
||||
Reference in New Issue
Block a user