From 58e2ba0affc44a39c7feb987e653204e7901f457 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Sat, 1 Aug 2026 16:26:28 -0700 Subject: [PATCH] fix(runtime): don't truncate SSE stream on tool results >64KB The client SSE reader used bufio.Scanner with the default 64KiB token cap (bufio.MaxScanTokenSize). Each agent event is delivered as one `data: {json}` line, and a large tool result (e.g. a big tool_call_response) exceeds that, so scanner.Scan() trips bufio.ErrTooLong, the loop ends, and the run appears to stop silently after the preceding event. The scanner error was also swallowed, leaving no trace. Raise the reader's buffer ceiling and surface any read error as an Error event instead of closing the stream without a word. --- pkg/runtime/client.go | 17 +++++++++++++++++ pkg/runtime/defaults.go | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/pkg/runtime/client.go b/pkg/runtime/client.go index 195e83c9bb..947b4e664f 100644 --- a/pkg/runtime/client.go +++ b/pkg/runtime/client.go @@ -400,6 +400,10 @@ func (c *Client) runAgentWithAgentName(ctx context.Context, sessionID, agent, ag defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) + // A single SSE line can carry a large tool response; raise the cap + // above bufio's 64 KiB default so an oversized line does not silently + // truncate the stream (bufio.ErrTooLong). + scanner.Buffer(make([]byte, 0, bufio.MaxScanTokenSize), maxSSELineBytes) for scanner.Scan() { line := scanner.Bytes() if len(line) == 0 || line[0] == ':' { @@ -438,7 +442,12 @@ func (c *Client) runAgentWithAgentName(ctx context.Context, sessionID, agent, ag eventChan <- e } + // Surface a read failure (e.g. an over-long line) instead of ending + // the stream silently — otherwise the run appears to stop with no + // error after the last event that fit. if err := scanner.Err(); err != nil { + slog.DebugContext(ctx, "event", "scanner_error", err) + eventChan <- Error(fmt.Sprintf("reading event stream: %v", err)) return } }() @@ -541,6 +550,10 @@ func (c *Client) StreamSessionEvents(ctx context.Context, sessionID string) (<-c defer resp.Body.Close() scanner := bufio.NewScanner(resp.Body) + // A single SSE line can carry a large tool response; raise the cap + // above bufio's 64 KiB default so an oversized line does not silently + // truncate the stream (bufio.ErrTooLong). + scanner.Buffer(make([]byte, 0, bufio.MaxScanTokenSize), maxSSELineBytes) for scanner.Scan() { line := scanner.Bytes() if len(line) == 0 || line[0] == ':' { @@ -579,8 +592,12 @@ func (c *Client) StreamSessionEvents(ctx context.Context, sessionID string) (<-c eventChan <- e } + // Surface a read failure (e.g. an over-long line) instead of ending + // the stream silently — otherwise the run appears to stop with no + // error after the last event that fit. if err := scanner.Err(); err != nil { slog.DebugContext(ctx, "scanner error", "error", err) + eventChan <- Error(fmt.Sprintf("reading event stream: %v", err)) } }() diff --git a/pkg/runtime/defaults.go b/pkg/runtime/defaults.go index 36983ecf79..13ecfc371e 100644 --- a/pkg/runtime/defaults.go +++ b/pkg/runtime/defaults.go @@ -12,6 +12,17 @@ import "time" // so the buffer size is set in exactly one place. const defaultEventChannelCapacity = 128 +// maxSSELineBytes bounds a single Server-Sent-Events line the client SSE +// reader will accept. Each event is delivered as one `data: {json}` line, and +// a tool result can be large (e.g. a big tool-response payload), so the default +// bufio.Scanner cap of 64 KiB (bufio.MaxScanTokenSize) truncates the stream: +// the oversized line trips bufio.ErrTooLong, the scan ends, and the run appears +// to stop silently after the preceding event. The reader raises its buffer to +// this ceiling and surfaces any remaining scanner error as an Error event +// rather than closing the stream without a trace. Sized to comfortably hold a +// large tool response while still bounding per-line memory. +const maxSSELineBytes = 16 * 1024 * 1024 + // defaultMaxOverflowCompactions caps the number of consecutive // context-overflow auto-compactions that the run loop will attempt before // giving up and surfacing the error to the caller. The runtime's