Agent runtime: majordomo in-process, Ollama Cloud config, chat endpoint (#56) (#70)
Build image / build-and-push (push) Successful in 6s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #70.
This commit is contained in:
2026-07-21 06:22:10 +00:00
committed by steve
parent a1bb8ec463
commit 3a3ce16fce
19 changed files with 1309 additions and 47 deletions
+32 -14
View File
@@ -1,18 +1,36 @@
// 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.
// Package agent is pansy's garden assistant: a majordomo toolbox over the
// service layer, plus the run loop that drives a model with it.
//
// Two deliberate separations keep the core server lean:
// Every tool runs as a fixed actor, so the agent inherits pansy's permission
// checks for free — a viewer's fill_region returns ErrForbidden, exactly as the
// REST API would. That is the whole reason the tools are thin adapters over
// internal/service and never reach past it; the moment one does, the ACL story
// stops being automatic and becomes something to remember.
//
// - 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`).
// # A turn is one change set
//
// The agent harness itself (model loop, chat surface) lives in the
// majordomo/executus stack, outside this repo; this package is only the toolbox.
// Runs wrap their whole turn in a single change set (source "agent"), so
// "empty the garlic bed and plant cucumbers" — one object edit and a dozen
// planting inserts — undoes as one action rather than thirteen. That is what
// makes acting without a confirmation prompt defensible: the answer to "it did
// the wrong thing" is one click, not an archaeology exercise.
//
// # No build tag
//
// This package used to sit behind a `majordomo` build tag, with cmd/pansy
// deliberately not importing it, so the default build carried no LLM
// dependency. Both separations are gone, on purpose: 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 would have meant an
// untagged CI that never compiled the code that matters.
//
// majordomo is stdlib-first and pure Go, so CGO_ENABLED=0 and the single static
// binary survive it.
//
// # Unconfigured instances
//
// With no API key the assistant is simply not offered: the chat route isn't
// registered and the capability isn't advertised — the same shape as OIDC
// 404ing when unconfigured. An instance without a key starts and serves the app
// exactly as it did before.
package agent
+240
View File
@@ -0,0 +1,240 @@
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)
}
+359
View File
@@ -0,0 +1,359 @@
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
"unicode/utf8"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// scriptedRunner wires a Runner to a fake provider so the whole loop — tools,
// change-set scoping, loop guards — is exercised with no live model.
func scriptedRunner(t *testing.T, svc *service.Service, steps ...fake.Step) *Runner {
t.Helper()
p := fake.New("fake")
for _, s := range steps {
p.Enqueue("m", s)
}
model, err := p.Model("m")
if err != nil {
t.Fatalf("fake model: %v", err)
}
return &Runner{svc: svc, model: model}
}
// toolCall scripts one model turn that calls a tool.
func toolCall(name string, args any) fake.Step {
raw, _ := json.Marshal(args)
return fake.ReplyWith(llm.Response{
FinishReason: llm.FinishToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: name, Arguments: raw}},
})
}
// TestTurnIsOneChangeSet is the acceptance criterion the whole "act freely"
// posture rests on: a turn that clears a bed and replants it — one object edit
// and many planting inserts — has to undo as ONE action, not thirteen.
func TestTurnIsOneChangeSet(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
garlic := mustPlant(t, svc, owner, "Garlic", 15, "🧄")
cucumber := mustPlant(t, svc, owner, "Cucumber", 45, "🥒")
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
Kind: domain.KindBed, Name: "Garlic bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
})
if err != nil {
t.Fatalf("bed: %v", err)
}
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil); err != nil {
t.Fatalf("seed garlic: %v", err)
}
before, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if err != nil {
t.Fatalf("history: %v", err)
}
r := scriptedRunner(t, svc,
toolCall("clear_object", map[string]any{"objectId": bed.ID}),
toolCall("fill_region", map[string]any{"objectId": bed.ID, "region": "all", "plantId": cucumber.ID}),
fake.Reply("Cleared the garlic and replanted the bed with cucumbers."),
)
turn, err := r.Run(ctx, owner, g.ID, "change the garlic bed to cucumbers this year", nil, nil)
if err != nil {
t.Fatalf("Run: %v", err)
}
if turn.ChangeSetID == nil {
t.Fatal("a turn that changed things produced no change set")
}
if !strings.Contains(turn.Reply, "cucumbers") {
t.Errorf("reply = %q", turn.Reply)
}
// Exactly ONE new change set, whatever the model did inside the turn.
after, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(after)-len(before) != 1 {
t.Fatalf("turn produced %d change sets, want exactly 1", len(after)-len(before))
}
cs := after[0]
if cs.Source != domain.SourceAgent {
t.Errorf("source = %q, want agent", cs.Source)
}
if cs.Summary != "change the garlic bed to cucumbers this year" {
t.Errorf("summary = %q, want the user's own words", cs.Summary)
}
// The bed really is cucumbers now.
full, _ := svc.GardenFull(ctx, owner, g.ID, nil)
if len(full.Plantings) == 0 {
t.Fatal("bed ended up empty")
}
for _, p := range full.Plantings {
if p.PlantID != cucumber.ID {
t.Errorf("unexpected plant %d still in the bed", p.PlantID)
}
}
// And one undo puts it back.
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, *turn.ChangeSetID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("undo: err=%v conflicts=%+v", err, conflicts)
}
full, _ = svc.GardenFull(ctx, owner, g.ID, nil)
for _, p := range full.Plantings {
if p.PlantID != garlic.ID {
t.Errorf("after undo the bed holds plant %d, want the garlic back", p.PlantID)
}
}
}
// TestViewerGetsAnExplainableRefusal — the ACL story only works if the model can
// narrate the refusal, so a tool denial has to reach it as a tool RESULT it can
// read, not as a 500 that ends the run.
func TestViewerGetsAnExplainableRefusal(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
viewer, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "V", Password: "password123"})
if err != nil {
t.Fatalf("register: %v", err)
}
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
Kind: domain.KindBed, XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
})
if err != nil {
t.Fatalf("bed: %v", err)
}
if _, err := svc.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
t.Fatalf("share: %v", err)
}
// A viewer can't open a change set at all, so the turn is refused up front —
// before any model call — and the API turns that into a plain explanation.
r := scriptedRunner(t, svc, fake.Reply("unused"))
_, err = r.Run(ctx, viewer.ID, g.ID, "plant garlic in that bed", nil, nil)
if !errors.Is(err, domain.ErrForbidden) {
t.Fatalf("viewer turn err = %v, want ErrForbidden", err)
}
// And at the tool layer, a refusal comes back as a readable tool result
// rather than killing the run.
box := NewToolbox(svc, viewer.ID)
raw, _ := json.Marshal(map[string]any{"objectId": bed.ID})
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "clear_object", Arguments: raw})
if !res.IsError {
t.Fatal("a viewer's clear_object succeeded")
}
if res.Content == "" {
t.Error("the refusal carried no text for the model to explain")
}
}
// TestRunStopsAtTheStepCap — a model that gets wedged should stop on its own,
// and what it managed to do must still be recorded and undoable.
func TestRunStopsAtTheStepCap(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
// More describe_garden calls than the cap allows, forever.
steps := make([]fake.Step, 0, maxSteps+4)
for i := 0; i < maxSteps+4; i++ {
steps = append(steps, toolCall("describe_garden", map[string]any{"gardenId": g.ID}))
}
r := scriptedRunner(t, svc, steps...)
turn, err := r.Run(ctx, owner, g.ID, "look at the garden", nil, nil)
if err != nil {
t.Fatalf("a capped run should end cleanly, got %v", err)
}
if !turn.Truncated {
t.Error("turn.Truncated = false; the run hit the cap and should say so")
}
if turn.Reply == "" {
t.Error("a capped run said nothing; silence reads as a hang")
}
// It only read, so there is nothing to undo — and no empty change set either.
if turn.ChangeSetID != nil {
t.Errorf("a read-only turn produced change set %d", *turn.ChangeSetID)
}
}
// TestReadOnlyTurnWritesNoChangeSet — asking a question must not litter the
// history with empty entries.
func TestReadOnlyTurnWritesNoChangeSet(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
before, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
r := scriptedRunner(t, svc,
toolCall("describe_garden", map[string]any{"gardenId": g.ID}),
fake.Reply("It's empty — nothing planted yet."),
)
turn, err := r.Run(ctx, owner, g.ID, "what's in the garden?", nil, nil)
if err != nil {
t.Fatalf("Run: %v", err)
}
if turn.ChangeSetID != nil {
t.Errorf("a question produced change set %d", *turn.ChangeSetID)
}
after, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if len(after) != len(before) {
t.Errorf("history grew by %d for a read-only turn", len(after)-len(before))
}
}
// TestNewRunnerNeedsConfiguration — an instance with no key must not get a
// half-built runner; the caller treats the error as "no assistant" and carries on.
func TestNewRunnerNeedsConfiguration(t *testing.T) {
svc, _ := newAgentTestService(t)
for _, cfg := range []*config.Config{
{Agent: config.AgentConfig{Enabled: false, OllamaCloudAPIKey: "k", Model: "ollama-cloud/x"}},
{Agent: config.AgentConfig{Enabled: true, OllamaCloudAPIKey: "", Model: "ollama-cloud/x"}},
{Agent: config.AgentConfig{Enabled: true, OllamaCloudAPIKey: "k", Model: ""}},
} {
if _, err := NewRunner(svc, cfg); err == nil {
t.Errorf("NewRunner accepted %+v", cfg.Agent)
}
}
// A model spec naming a provider that doesn't exist is a configuration
// error, not a panic at first use.
if _, err := NewRunner(svc, &config.Config{Agent: config.AgentConfig{
Enabled: true, OllamaCloudAPIKey: "k", Model: "nonesuch/model",
}}); err == nil {
t.Error("NewRunner accepted an unresolvable model spec")
}
}
// TestTurnSummaryFitsAHistoryRow — the user's own words are the most useful
// label for a change set, but a paragraph would wreck the list.
func TestTurnSummaryFitsAHistoryRow(t *testing.T) {
if got := turnSummary(" change the garlic bed\n to cucumbers "); got != "change the garlic bed to cucumbers" {
t.Errorf("turnSummary = %q", got)
}
long := turnSummary(strings.Repeat("plant garlic ", 40))
if len(long) > 130 || !strings.HasSuffix(long, "…") {
t.Errorf("long summary = %q (%d chars)", long, len(long))
}
}
// TestSystemPromptStatesTheCompassConvention — -y being north is not guessable,
// and a model that assumes otherwise plants the wrong end of the bed.
func TestSystemPromptStatesTheCompassConvention(t *testing.T) {
p := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitImperial})
for _, want := range []string{"NORTH", "-y", "centimeters", "Plot", "version"} {
if !strings.Contains(p, want) {
t.Errorf("system prompt is missing %q:\n%s", want, p)
}
}
if !strings.Contains(p, fmt.Sprintf("%.0f", 500.0)) {
t.Error("system prompt doesn't state the garden's size")
}
}
// TestPartialWorkSurvivesATimeout is the finding that mattered most on this PR.
//
// The run context carries a timeout. When it fires, WithChangeSet's recovery
// path has to record what already committed — and doing that with the SAME
// dead context would fail, losing the history for changes that really happened.
// The user-facing message says "anything I'd already changed is in History", so
// this isn't just a gap, it's a promise the code has to keep.
func TestPartialWorkSurvivesATimeout(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
})
if err != nil {
t.Fatalf("bed: %v", err)
}
before, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
// A turn that renames the bed, then dies with the context already cancelled.
cancelled, cancel := context.WithCancel(ctx)
r := scriptedRunner(t, svc,
toolCall("move_object", map[string]any{
"objectId": bed.ID, "xCm": 600.0, "yCm": 600.0, "version": bed.Version,
}),
fake.Step{Err: context.DeadlineExceeded},
)
// Cancel once the first tool call has landed, so the failure path runs with a
// dead context — exactly the timeout case.
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
_, err = r.Run(cancelled, owner, g.ID, "move the bed", nil, nil)
if err == nil {
t.Fatal("expected the turn to fail")
}
// The move committed, so it must be in history and undoable.
after, _, herr := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if herr != nil {
t.Fatalf("history: %v", herr)
}
if len(after) != len(before)+1 {
t.Fatalf("the failed turn recorded %d change sets, want 1 — its work is otherwise un-undoable",
len(after)-len(before))
}
if !strings.Contains(after[0].Summary, "failed partway") {
t.Errorf("summary = %q, want it marked as partial", after[0].Summary)
}
if _, conflicts, rerr := svc.RevertChangeSet(ctx, owner, after[0].ID, domain.SourceUI); rerr != nil || len(conflicts) != 0 {
t.Fatalf("the partial turn should be undoable: err=%v conflicts=%+v", rerr, conflicts)
}
o, _ := svc.DescribeGarden(ctx, owner, g.ID)
if len(o.Objects) > 0 && o.Objects[0].XCM != bed.XCM {
t.Errorf("undo left the bed at %v, want %v", o.Objects[0].XCM, bed.XCM)
}
}
// TestTurnSummaryTrimsByRunes — slicing a byte offset would cut a multibyte
// character in half and store invalid UTF-8 in the history summary.
func TestTurnSummaryTrimsByRunes(t *testing.T) {
// 200 multibyte runes: a byte slice at 120 would land mid-character.
got := turnSummary(strings.Repeat("🌱", 200))
if !utf8.ValidString(got) {
t.Errorf("turnSummary produced invalid UTF-8: %q", got)
}
if !strings.HasSuffix(got, "…") {
t.Errorf("long summary should be elided, got %q", got)
}
if n := utf8.RuneCountInString(got); n > 121 {
t.Errorf("summary is %d runes, want it trimmed", n)
}
}
-2
View File
@@ -1,5 +1,3 @@
//go:build majordomo
package agent
import (
-2
View File
@@ -1,5 +1,3 @@
//go:build majordomo
package agent
import (
+189
View File
@@ -0,0 +1,189 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
mdagent "gitea.stevedudenhoeffer.com/steve/majordomo/agent"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// The garden assistant's chat surface (#56).
//
// Streaming, because a turn that clears a bed and replants it makes a dozen tool
// calls over tens of seconds. Without streaming that is a long silence followed
// by everything at once, which reads as a hang — and the whole design rests on
// watching the canvas change as it happens.
// chatRequest is the body of POST /agent/chat.
type chatRequest struct {
GardenID int64 `json:"gardenId" binding:"required"`
Message string `json:"message" binding:"required"`
}
// chatEvent is one server-sent event. Exactly one field is set.
type chatEvent struct {
// Step reports a completed model round trip: which tools it called.
Step *stepEvent `json:"step,omitempty"`
// Done carries the finished turn.
Done *agent.Turn `json:"done,omitempty"`
// Error is a turn that failed, in words meant for a person.
Error string `json:"error,omitempty"`
// Warning rides alongside Done: the turn worked, but something adjacent to it
// didn't, and saying nothing would be the quieter lie.
Warning string `json:"warning,omitempty"`
}
type stepEvent struct {
Index int `json:"index"`
Tools []string `json:"tools"`
}
func (h *handlers) agentChat(c *gin.Context) {
var req chatRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a gardenId and a message are required")
return
}
actor := mustActor(c)
history, err := h.svc.AgentHistory(c.Request.Context(), actor.ID, req.GardenID)
if err != nil {
writeServiceError(c, err)
return
}
send := openEventStream(c)
turn, err := h.agent.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
replayHistory(history),
func(s mdagent.Step) {
send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}})
})
if err != nil {
// The stream is already open, so an error is an event rather than a
// status code — the client has committed to reading a stream by now.
send(chatEvent{Error: chatErrorMessage(err)})
return
}
// The turn itself succeeded — the garden really did change — so Done goes out
// regardless. But if the transcript couldn't be saved, say so: a clean "done"
// followed by a conversation that has forgotten the exchange after a reload is
// exactly the kind of quiet inconsistency that makes a tool feel unreliable.
//
// Detached from the request context, because the commonest reason this fails
// is the client having gone away — and the exchange is worth keeping either
// way, since the change set it produced certainly is.
if _, err := h.svc.RecordAgentExchange(context.WithoutCancel(c.Request.Context()), actor.ID, req.GardenID,
req.Message, turn.Reply, turn.ChangeSetID); err != nil {
slog.Error("api: record agent exchange", "error", err, "garden", req.GardenID)
send(chatEvent{Done: turn, Warning: "I couldn't save this exchange, so it won't be here after a reload. Anything I changed is still on the canvas, and in History."})
return
}
send(chatEvent{Done: turn})
}
// openEventStream puts the response into SSE mode and returns a sender.
//
// Headers go out before the first write and the stream is flushed immediately,
// so a proxy holding the response until it looks complete can't reintroduce
// exactly the silence streaming exists to remove.
func openEventStream(c *gin.Context) func(chatEvent) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("X-Accel-Buffering", "no")
c.Writer.Flush()
return func(ev chatEvent) {
b, err := json.Marshal(ev)
if err != nil {
slog.Error("api: encode chat event", "error", err)
return
}
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", b)
c.Writer.Flush()
}
}
// getAgentHistory returns the actor's thread for a garden.
func (h *handlers) getAgentHistory(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
msgs, err := h.svc.AgentHistory(c.Request.Context(), mustActor(c).ID, gardenID)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"messages": msgs})
}
// deleteAgentHistory is the "start over" escape hatch.
func (h *handlers) deleteAgentHistory(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
if err := h.svc.ClearAgentHistory(c.Request.Context(), mustActor(c).ID, gardenID); err != nil {
writeServiceError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// replayHistory turns stored text into model messages.
//
// Only the text is replayed — no stored tool calls. Continuity needs what was
// said and what came back; replaying a tool call would be replaying a decision
// made against a garden that has since moved on.
func replayHistory(msgs []domain.AgentMessage) []llm.Message {
out := make([]llm.Message, 0, len(msgs))
for _, m := range msgs {
if m.Role == domain.AgentRoleUser {
out = append(out, llm.UserText(m.Body))
} else {
out = append(out, llm.AssistantText(m.Body))
}
}
return out
}
func toolNames(s mdagent.Step) []string {
names := make([]string, 0, len(s.Results))
for _, r := range s.Results {
names = append(names, r.Name)
}
return names
}
// chatErrorMessage turns a failure into something worth reading. Permission
// errors in particular get named: "the agent broke" and "you can only view this
// garden" want very different reactions.
func chatErrorMessage(err error) string {
switch {
case errors.Is(err, domain.ErrForbidden):
return "You can only view this garden, so I can't change anything in it."
case errors.Is(err, domain.ErrNotFound):
return "I can't find that garden."
case errors.Is(err, domain.ErrInvalidInput):
return "I didn't get a message to work from."
case errors.Is(err, io.EOF), errors.Is(err, context.Canceled):
return "The connection dropped partway through. Anything I'd already changed is on the canvas, and in History."
case errors.Is(err, context.DeadlineExceeded):
return "That took too long and I stopped. Anything I'd already changed is on the canvas, and in History."
default:
slog.Error("api: agent run failed", "error", err)
return "Something went wrong talking to the model. Anything I'd already changed is on the canvas, and in History."
}
}
+33
View File
@@ -0,0 +1,33 @@
package api
import (
"net/http"
"strconv"
"testing"
)
// TestAgentRoutesAbsentWithoutAKey — an instance with no API key must start,
// serve the app, and simply not offer the assistant. The routes aren't
// registered at all, so this is a 404 rather than a handler that apologizes:
// the same shape as OIDC when unconfigured.
func TestAgentRoutesAbsentWithoutAKey(t *testing.T) {
r := authEngine(t, localCfg()) // localCfg has no agent configuration
cookie := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, cookie, "G")
w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
map[string]any{"gardenId": gid, "message": "plant garlic"}, cookie)
if w.Code != http.StatusNotFound {
t.Errorf("chat without a key: status %d, want 404", w.Code)
}
path := "/api/v1/gardens/" + strconv.FormatInt(gid, 10) + "/agent/history"
if w := doJSON(t, r, http.MethodGet, path, nil, cookie); w.Code != http.StatusNotFound {
t.Errorf("history without a key: status %d, want 404", w.Code)
}
// And the rest of the app is entirely unaffected.
if w := doJSON(t, r, http.MethodGet, fullPath(gid), nil, cookie); w.Code != http.StatusOK {
t.Errorf("editor load: status %d, want 200 — an unconfigured agent must not break the app", w.Code)
}
}
+25
View File
@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
sloggin "github.com/samber/slog-gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
@@ -22,6 +23,9 @@ type handlers struct {
cfg *config.Config
svc *service.Service
oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady)
// agent is nil unless the assistant is configured; the chat routes are only
// registered when it isn't, so a handler never has to check.
agent *agent.Runner
}
// New builds the gin engine with the standard middleware stack and registers the
@@ -118,6 +122,27 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
plantings.PATCH("/:id", h.updatePlanting)
plantings.DELETE("/:id", h.deletePlanting)
// The garden assistant, registered only when it can actually be offered —
// the same shape as OIDC. An instance with no API key serves the app
// normally and simply doesn't have these routes.
if cfg.Agent.Ready() {
runner, err := agent.NewRunner(svc, cfg)
if err != nil {
// Configured but unusable (an unresolvable model spec, say). Log it and
// carry on without the assistant rather than refusing to start: a
// garden planner that won't boot because of a chat feature is worse
// than one without chat.
slog.Error("api: garden assistant disabled", "error", err)
} else {
h.agent = runner
agentGroup := v1.Group("/agent", h.requireAuth())
agentGroup.POST("/chat", h.agentChat)
gardens.GET("/:id/agent/history", h.getAgentHistory)
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
slog.Info("api: garden assistant enabled", "model", cfg.Agent.Model)
}
}
// Undo. A change set is addressed by its own id; the service resolves the
// owning garden for the permission check, same as objects and plantings.
changeSets := v1.Group("/change-sets", h.requireAuth())
+42
View File
@@ -19,6 +19,9 @@ const (
RegistrationClosed = "closed"
)
// DefaultAgentModel is the assistant's model when PANSY_AGENT_MODEL is unset.
const DefaultAgentModel = "ollama-cloud/glm-5.2:cloud"
// Config is the fully-resolved runtime configuration.
type Config struct {
// Port is the TCP port the HTTP server listens on (PANSY_PORT, default 8080).
@@ -37,6 +40,8 @@ type Config struct {
LocalAuth bool
// OIDC holds the optional OpenID Connect provider settings.
OIDC OIDCConfig
// Agent holds the garden-assistant settings.
Agent AgentConfig
// TrustedProxies is the set of proxy CIDRs/IPs gin trusts for client IP
// resolution (PANSY_TRUSTED_PROXIES, comma-separated). Empty trusts none.
TrustedProxies []string
@@ -51,6 +56,33 @@ type OIDCConfig struct {
ButtonLabel string // PANSY_OIDC_BUTTON_LABEL — login-page button text
}
// AgentConfig holds the garden assistant's settings.
type AgentConfig struct {
// Model is the majordomo model spec, passed VERBATIM to majordomo.Parse
// (PANSY_AGENT_MODEL). The grammar is majordomo's, not pansy's — parsing or
// validating it here would only mean two places to update when it grows. A
// comma-separated spec is a failover chain, so
// "ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud" gets a fallback
// for free.
Model string
// OllamaCloudAPIKey authenticates against Ollama Cloud
// (OLLAMA_CLOUD_API_KEY — the same secret name gadfly uses, which is why
// it isn't majordomo's own OLLAMA_API_KEY; pansy passes it explicitly rather
// than relying on ambient environment).
OllamaCloudAPIKey string
// Enabled turns the assistant on (PANSY_AGENT_ENABLED). Defaults to on when
// a key is present, so an instance with no key starts cleanly and simply
// doesn't offer the agent — the same shape as OIDC 404ing when unconfigured.
Enabled bool
}
// Ready reports whether the assistant can actually be offered. Both the route
// registration and whatever advertises capabilities gate on this, so what's
// advertised always matches what's live.
func (a AgentConfig) Ready() bool {
return a.Enabled && a.OllamaCloudAPIKey != "" && a.Model != ""
}
// Enabled reports whether enough OIDC config is present to attempt discovery.
func (o OIDCConfig) Enabled() bool {
return o.Issuer != "" && o.ClientID != ""
@@ -87,6 +119,16 @@ func Load() *Config {
TrustedProxies: envList("PANSY_TRUSTED_PROXIES"),
}
agentKey := envStr("OLLAMA_CLOUD_API_KEY", "")
cfg.Agent = AgentConfig{
Model: envStr("PANSY_AGENT_MODEL", DefaultAgentModel),
OllamaCloudAPIKey: agentKey,
// Default on when a key is present: having configured the key IS the
// opt-in, and making people set a second flag to use what they just
// configured is a papercut with no upside.
Enabled: envBool("PANSY_AGENT_ENABLED", agentKey != ""),
}
if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed {
slog.Warn("config: invalid PANSY_REGISTRATION, defaulting to open", "value", cfg.Registration)
cfg.Registration = RegistrationOpen
+22
View File
@@ -191,6 +191,28 @@ type JournalEntry struct {
AuthorName string `json:"authorName,omitempty"`
}
// Roles a stored agent message can have.
const (
AgentRoleUser = "user"
AgentRoleAssistant = "assistant"
)
// AgentMessage is one side of a chat exchange with the garden assistant. Only
// the text is kept, not the model's full transcript with its tool calls:
// continuity needs what was said and what came back, and replaying a stored tool
// call would be replaying a decision made against a garden that has since moved
// on.
type AgentMessage struct {
ID int64 `json:"id"`
ConversationID int64 `json:"conversationId"`
Role string `json:"role"`
Body string `json:"body"`
// ChangeSetID is what this turn changed, if anything — the handle the chat
// panel needs to offer Undo on the message that caused it.
ChangeSetID *int64 `json:"changeSetId,omitempty"`
CreatedAt string `json:"createdAt"`
}
// RevertConflict reports one entity a revert deliberately left alone, because
// reverting it would have discarded a change made after the change set being
// reverted. The rest of the change set still reverts.
+62
View File
@@ -0,0 +1,62 @@
package service
import (
"context"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// Chat persistence for the garden assistant (#56). The run loop itself lives in
// internal/agent; this is the part that needs pansy's permission checks.
// maxRetainedTurns caps how much of a thread is kept in context. Retained turns
// are a working memory, not an archive — a season of chat replayed into every
// prompt would cost more than it informs.
const maxRetainedTurns = 20
// AgentHistory returns the recent messages of the actor's own thread for a
// garden they can edit, oldest first.
//
// Requires EDITOR, not viewer: the assistant acts, so being able to talk to it
// about a garden is the same permission as being able to change it. A viewer
// asking it to plant something would get a refusal from every tool anyway, and
// offering the conversation would be offering something that cannot work.
func (s *Service) AgentHistory(ctx context.Context, actorID, gardenID int64) ([]domain.AgentMessage, error) {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
return nil, err
}
conv, err := s.store.EnsureConversation(ctx, gardenID, actorID)
if err != nil {
return nil, err
}
return s.store.ListAgentMessages(ctx, conv, maxRetainedTurns*2)
}
// RecordAgentExchange stores one user message and the assistant's reply, and
// returns the reply row. Called by the runtime after a turn completes.
func (s *Service) RecordAgentExchange(ctx context.Context, actorID, gardenID int64, userMessage, reply string, changeSetID *int64) (*domain.AgentMessage, error) {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
return nil, err
}
conv, err := s.store.EnsureConversation(ctx, gardenID, actorID)
if err != nil {
return nil, err
}
if _, err := s.store.AppendAgentMessage(ctx, &domain.AgentMessage{
ConversationID: conv, Role: domain.AgentRoleUser, Body: userMessage,
}); err != nil {
return nil, err
}
return s.store.AppendAgentMessage(ctx, &domain.AgentMessage{
ConversationID: conv, Role: domain.AgentRoleAssistant, Body: reply, ChangeSetID: changeSetID,
})
}
// ClearAgentHistory drops the actor's thread for a garden — the "start over"
// escape hatch when a conversation has gone somewhere unhelpful.
func (s *Service) ClearAgentHistory(ctx context.Context, actorID, gardenID int64) error {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
return err
}
return s.store.DeleteConversation(ctx, gardenID, actorID)
}
+11 -4
View File
@@ -99,7 +99,8 @@ type ChangeSetOptions struct {
Source string
// Summary is the one-line description shown in the history list.
Summary string
// AgentRunID joins this change set back to the executus run that produced it.
// AgentRunID joins this change set back to the agent run that produced it,
// so a change in the history list can be correlated with the run's log lines.
AgentRunID *string
}
@@ -128,7 +129,11 @@ func (s *Service) WithChangeSet(ctx context.Context, actorID, gardenID int64, op
// which is exactly the situation undo exists for. Record what happened,
// mark the summary, and still report the failure.
sc.summary = partialSummary(sc.summary)
if _, cerr := s.commitScope(ctx, sc, nil); cerr != nil {
// Detached from cancellation on purpose. The commonest reason fn failed is
// that ctx was cancelled or timed out — and using that same dead context
// to record what committed would fail too, losing the history for changes
// that really happened. That is precisely the case this path exists for.
if _, cerr := s.commitScope(context.WithoutCancel(ctx), sc, nil); cerr != nil {
slog.Error("service: partial turn could not be recorded", "error", cerr, "garden", gardenID)
}
return nil, err
@@ -306,8 +311,10 @@ func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int6
changes, conflict, err := s.applyInverse(ctx, r, applied)
if err != nil {
// Record what actually landed before surfacing the failure, so the
// partial revert is visible and undoable rather than orphaned.
if _, cerr := s.commitScope(ctx, sc, &target.ID); cerr != nil {
// partial revert is visible and undoable rather than orphaned. Detached
// from cancellation for the same reason as WithChangeSet's path: a
// timed-out context can't be used to write the record of what it did.
if _, cerr := s.commitScope(context.WithoutCancel(ctx), sc, &target.ID); cerr != nil {
slog.Error("service: partial revert could not be recorded", "error", cerr, "changeSet", changeSetID)
}
return nil, nil, err
+95
View File
@@ -0,0 +1,95 @@
package store
import (
"context"
"fmt"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// agentMessageColumns lists agent_messages columns in scan order.
const agentMessageColumns = `id, conversation_id, role, body, change_set_id, created_at`
// EnsureConversation returns the (garden, user) conversation id, creating it on
// first use. One thread per person per garden: two people editing a shared
// garden hold separate dialogues, and merging them would put words in someone's
// mouth.
func (d *DB) EnsureConversation(ctx context.Context, gardenID, userID int64) (int64, error) {
var id int64
err := d.sql.QueryRowContext(ctx,
`SELECT id FROM agent_conversations WHERE garden_id = ? AND user_id = ?`,
gardenID, userID).Scan(&id)
if err == nil {
return id, nil
}
if err := d.sql.QueryRowContext(ctx,
`INSERT INTO agent_conversations (garden_id, user_id) VALUES (?, ?)
ON CONFLICT (garden_id, user_id) DO UPDATE SET updated_at = updated_at
RETURNING id`,
gardenID, userID).Scan(&id); err != nil {
return 0, fmt.Errorf("store: ensure conversation: %w", err)
}
return id, nil
}
// AppendAgentMessage records one side of an exchange.
func (d *DB) AppendAgentMessage(ctx context.Context, m *domain.AgentMessage) (*domain.AgentMessage, error) {
var out domain.AgentMessage
if err := d.sql.QueryRowContext(ctx,
`INSERT INTO agent_messages (conversation_id, role, body, change_set_id)
VALUES (?, ?, ?, ?)
RETURNING `+agentMessageColumns,
m.ConversationID, m.Role, m.Body, m.ChangeSetID,
).Scan(&out.ID, &out.ConversationID, &out.Role, &out.Body, &out.ChangeSetID, &out.CreatedAt); err != nil {
return nil, fmt.Errorf("store: append agent message: %w", err)
}
// Touch the conversation so a "most recent thread" read is possible later.
if _, err := d.sql.ExecContext(ctx,
`UPDATE agent_conversations SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?`,
m.ConversationID); err != nil {
return nil, fmt.Errorf("store: touch conversation: %w", err)
}
return &out, nil
}
// ListAgentMessages returns the last `limit` messages of a conversation in
// chronological order.
//
// Reading the TAIL rather than the head is the point: an old opening exchange is
// the least useful context for "now do the same to the north bed", and an
// uncapped read would grow without bound over a season.
func (d *DB) ListAgentMessages(ctx context.Context, conversationID int64, limit int) ([]domain.AgentMessage, error) {
rows, err := d.sql.QueryContext(ctx,
`SELECT `+agentMessageColumns+` FROM (
SELECT `+agentMessageColumns+` FROM agent_messages
WHERE conversation_id = ? ORDER BY id DESC LIMIT ?
) ORDER BY id`,
conversationID, limit)
if err != nil {
return nil, fmt.Errorf("store: list agent messages: %w", err)
}
defer rows.Close()
msgs := []domain.AgentMessage{}
for rows.Next() {
var m domain.AgentMessage
if err := rows.Scan(&m.ID, &m.ConversationID, &m.Role, &m.Body, &m.ChangeSetID, &m.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan agent message: %w", err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate agent messages: %w", err)
}
return msgs, nil
}
// DeleteConversation clears a thread (the messages cascade).
func (d *DB) DeleteConversation(ctx context.Context, gardenID, userID int64) error {
if _, err := d.sql.ExecContext(ctx,
`DELETE FROM agent_conversations WHERE garden_id = ? AND user_id = ?`,
gardenID, userID); err != nil {
return fmt.Errorf("store: delete conversation: %w", err)
}
return nil
}
@@ -0,0 +1,36 @@
-- Agent conversations (#56): multi-turn chat with the garden assistant.
--
-- "Now do the same to the north bed" needs the previous exchange, and history
-- held only by the client would be lost on a refresh — which is exactly when
-- someone reloads to see whether the agent's change actually landed.
--
-- One conversation per (user, garden). Two people editing a shared garden get
-- separate threads: a conversation is a dialogue with one person, and merging
-- them would put words in someone's mouth.
CREATE TABLE agent_conversations (
id INTEGER PRIMARY KEY,
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE (garden_id, user_id)
);
-- Only the user/assistant TEXT of each turn is kept, not the full model
-- transcript with its tool calls. Continuity needs what was said and what came
-- back; replaying a stored tool call would be replaying a decision made against
-- a garden that has since moved on. It also keeps the schema free of majordomo's
-- message shape, which is a dependency's business, not the database's.
CREATE TABLE agent_messages (
conversation_id INTEGER NOT NULL REFERENCES agent_conversations (id) ON DELETE CASCADE,
id INTEGER PRIMARY KEY,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
body TEXT NOT NULL,
-- The change set this turn produced, so the panel can offer Undo on the
-- message that caused it. SET NULL rather than CASCADE: losing the history
-- entry must not delete what was said about it.
change_set_id INTEGER REFERENCES change_sets (id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_agent_messages_conversation ON agent_messages (conversation_id, id);