Address Gadfly review on the agent runtime
Build image / build-and-push (push) Successful in 7s

The first finding breaks this PR's central promise and is the reason the review
was worth running.

The run context carries a timeout. When it fired, WithChangeSet's recovery path
tried to record what had already committed using that SAME dead context — which
fails, losing the history for changes that really happened. So a timed-out turn
left its partial work un-undoable, while the user-facing message cheerfully said
"anything I'd already changed is in History". That message was a lie in exactly
the case it was written for.

Both recovery paths (WithChangeSet and RevertChangeSet) now commit with
context.WithoutCancel. The commonest reason those paths run at all is a
cancelled or timed-out context, so using it to write the record of what it did
was self-defeating. There's a test that cancels mid-turn and asserts the partial
work is recorded, marked partial, and revertible.

turnSummary sliced bytes, so a message whose 120th byte fell inside a multibyte
character stored invalid UTF-8 in the history summary. Not hypothetical for text
people type. Trimmed by runes now, with a test using emoji.

RecordAgentExchange's failure was logged and swallowed, directly under a comment
claiming the user would see that their turn wasn't saved. The turn itself
succeeded, so Done still goes out — but with a warning saying the exchange
wasn't saved and won't survive a reload, because a clean "done" followed by a
conversation that has forgotten it is the quieter lie. It also now records with
a detached context, since the commonest reason that write fails is the client
having gone away, and the exchange is worth keeping either way.

AgentRunID was declared, documented as an executus join, and never set by
anything. It's set now, from a per-run id that's also logged at run start, so a
row in the history list has a thread back to the run that produced it — and the
comment describes that rather than a dependency this repo doesn't have.

Turn.History was populated and never read, left over from the client-held
history design that persistence replaced. Removed.

The SSE plumbing moved out of the handler into openEventStream, so agentChat
reads like the other decode/call/encode handlers in the package.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-21 02:21:36 -04:00
co-authored by Claude Opus 4.8
parent 3f3a5b057c
commit 91c6d66fa2
4 changed files with 158 additions and 32 deletions
+30 -7
View File
@@ -2,8 +2,11 @@ package agent
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"strings"
"time"
@@ -76,8 +79,6 @@ 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.
@@ -100,14 +101,21 @@ 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
truncErr bool
)
changeSet, err := r.svc.WithChangeSet(ctx, actorID, gardenID, service.ChangeSetOptions{
Source: domain.SourceAgent,
Summary: turnSummary(message),
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),
@@ -143,7 +151,6 @@ 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)
@@ -172,13 +179,29 @@ 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), " ")
if len(s) > max {
s = strings.TrimSpace(s[:max]) + "…"
runes := []rune(s)
if len(runes) > max {
s = strings.TrimSpace(string(runes[:max])) + "…"
}
return s
}