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:
+37
-21
@@ -38,6 +38,9 @@ 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 {
|
||||
@@ -59,23 +62,7 @@ func (h *handlers) agentChat(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
send := openEventStream(c)
|
||||
|
||||
turn, err := h.agent.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
|
||||
replayHistory(history),
|
||||
@@ -89,16 +76,45 @@ func (h *handlers) agentChat(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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,
|
||||
// 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")
|
||||
|
||||
Reference in New Issue
Block a user