Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
241 lines
9.0 KiB
Go
241 lines
9.0 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"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"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// An id for this run, stamped on the change set so a row in the history list
|
|
// can be matched to the log lines that produced it. Without it, "the agent
|
|
// did something odd on Tuesday" has no thread back to what it was thinking.
|
|
runID := newRunID()
|
|
slog.Info("agent: run start", "run", runID, "garden", gardenID, "actor", actorID)
|
|
|
|
var (
|
|
result *agent.Result
|
|
runErr error
|
|
truncErr bool
|
|
)
|
|
changeSet, err := r.svc.WithChangeSet(ctx, actorID, gardenID, service.ChangeSetOptions{
|
|
Source: domain.SourceAgent,
|
|
Summary: turnSummary(message),
|
|
AgentRunID: &runID,
|
|
}, 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)
|
|
}
|
|
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."
|
|
}
|
|
}
|
|
|
|
// newRunID returns a short random identifier for one run.
|
|
func newRunID() string {
|
|
var b [8]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
// The id is for correlating logs, not for security. A clock-based
|
|
// fallback is worse than random and better than an empty string.
|
|
return fmt.Sprintf("t%d", time.Now().UnixNano())
|
|
}
|
|
return hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Trimmed by RUNES, not bytes: slicing a byte offset would cut a multibyte
|
|
// character in half and store invalid UTF-8 in the summary — which is not a
|
|
// hypothetical for text people type.
|
|
func turnSummary(message string) string {
|
|
const max = 120
|
|
s := strings.Join(strings.Fields(message), " ")
|
|
runes := []rune(s)
|
|
if len(runes) > max {
|
|
s = strings.TrimSpace(string(runes[: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)
|
|
}
|