2 Commits
Author SHA1 Message Date
steveandClaude Opus 4.8 2fbe6ea419 Chat panel in the garden editor (#57)
Build image / build-and-push (push) Successful in 15s
The conversational surface, in the editor rather than on its own page. The
reason is the feedback loop: watching the canvas change as the agent works IS
the confirmation, which is exactly what makes "act freely without asking first"
tolerable. It also means the agent never has to guess which garden you mean.

Tool calls surface as they happen, in the app's own vocabulary — "Clearing a
bed", "Looking up a plant" — not raw tool names or JSON. That is the difference
between the panel feeling like it's doing something and feeling like it's hung,
which matters because a replant makes a dozen calls over tens of seconds. An
unknown tool degrades to readable words rather than showing snake_case at the
user, so the client can lag the server by a tool without looking broken.

The canvas refreshes as each step lands, not just at the end. Refreshing only on
completion would put the whole point of siting the chat here — watching it work
— behind the same wait that streaming exists to remove.

Undo sits on the turn itself, so the common case never involves opening the
History panel. It uses #49's useUndo, not a second implementation, which meant
giving that hook an UndoTarget: the chat knows a turn's change set id but not
its tally, and fabricating counts to satisfy the type would have produced a
confidently wrong "1 of 1 changes undone". describeUndo now says "Partly undone"
when it has no denominator rather than inventing one.

The panel is only offered when the instance actually has the assistant
configured, via a new /capabilities read. The routes 404 without a key, so
without this the client would have to probe for a 404 to find out — and a tab
that opens onto an apology is worse than no tab.

Streaming is hand-rolled over fetch rather than EventSource, which can only
issue GETs and this needs a POST body. The wire format is still SSE, so a proxy
that understands it doesn't buffer and the server wouldn't change if EventSource
became viable. Partial frames are buffered across chunks and a malformed frame
is skipped rather than killing a working stream.

Errors read as sentences, and as different sentences: a permission refusal, a
timeout and a model failure want different reactions. Every failure path
refreshes, because something may have landed before it failed — and says where
to look for it.

Closes #57

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 02:19:27 -04:00
steveandClaude Opus 4.8 3f3a5b057c Agent runtime: majordomo in-process, Ollama Cloud config, chat endpoint (#56)
Build image / build-and-push (push) Successful in 25s
Gadfly review (reusable) / review (pull_request) Canceled after 5m56s
Adversarial Review (Gadfly) / review (pull_request) Canceled after 5m56s
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
2026-07-21 02:15:22 -04:00
4 changed files with 32 additions and 158 deletions
+5 -28
View File
@@ -2,11 +2,8 @@ package agent
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"strings"
"time"
@@ -79,6 +76,8 @@ type Turn struct {
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.
@@ -101,12 +100,6 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message strin
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
@@ -115,7 +108,6 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message strin
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),
@@ -151,6 +143,7 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message strin
if result != nil {
turn.Reply = result.Output
turn.Steps = len(result.Steps)
turn.History = result.Messages
}
if turn.Reply == "" {
turn.Reply = fallbackReply(turn)
@@ -179,29 +172,13 @@ func fallbackReply(t *Turn) string {
}
}
// 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])) + "…"
if len(s) > max {
s = strings.TrimSpace(s[:max]) + "…"
}
return s
}
-80
View File
@@ -7,8 +7,6 @@ import (
"fmt"
"strings"
"testing"
"time"
"unicode/utf8"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
@@ -279,81 +277,3 @@ func TestSystemPromptStatesTheCompassConvention(t *testing.T) {
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)
}
}
+21 -37
View File
@@ -38,9 +38,6 @@ type chatEvent struct {
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 {
@@ -62,7 +59,23 @@ func (h *handlers) agentChat(c *gin.Context) {
return
}
send := openEventStream(c)
// Headers before the first write, and a flush straight away: a proxy that
// buffers the response would reintroduce exactly the silence streaming is
// here to remove.
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("X-Accel-Buffering", "no")
c.Writer.Flush()
send := 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()
}
turn, err := h.agent.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
replayHistory(history),
@@ -76,45 +89,16 @@ func (h *handlers) agentChat(c *gin.Context) {
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,
// Record before announcing: if persistence fails, the user should see that
// their turn wasn't saved rather than a clean "done" followed by a thread
// that has forgotten it.
if _, err := h.svc.RecordAgentExchange(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")
+4 -11
View File
@@ -99,8 +99,7 @@ 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 agent run that produced it,
// so a change in the history list can be correlated with the run's log lines.
// AgentRunID joins this change set back to the executus run that produced it.
AgentRunID *string
}
@@ -129,11 +128,7 @@ 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)
// 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 {
if _, cerr := s.commitScope(ctx, sc, nil); cerr != nil {
slog.Error("service: partial turn could not be recorded", "error", cerr, "garden", gardenID)
}
return nil, err
@@ -311,10 +306,8 @@ 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. 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 {
// partial revert is visible and undoable rather than orphaned.
if _, cerr := s.commitScope(ctx, sc, &target.ID); cerr != nil {
slog.Error("service: partial revert could not be recorded", "error", cerr, "changeSet", changeSetID)
}
return nil, nil, err