READINGThe Leaky Abstraction (Vol 2): Multi-Turn Agent State Transport and Buffer Disconnects
PART 2 OF THE LEAKY ABSTRACTION
Systems Architecture7 min read

The Leaky Abstraction (Vol 2): Multi-Turn Agent State Transport and Buffer Disconnects

Why multi-turn agent streaming fails when asynchronous tool execution exceeds TCP keep-alive thresholds, and how to architect idempotent stream reassembly with Cloud Run and Firestore.

Illustration for The Leaky Abstraction (Vol 2): Multi-Turn Agent State Transport and Buffer Disconnects
AUDIO OVERVIEWFenrir Studio Voice • EBU R128 (-16 LUFS)
NOW PLAYING:Multi-Turn State Transport Failures
0:00
1:55

In Volume 1, we examined why single-stream LLM outputs break at the TCP byte layer when UTF-8 characters and JSON objects split across network packet boundaries. But in modern multi-turn agent workflows, the network abstraction leaks at a far more dangerous layer: session state transport during asynchronous tool execution.

When an autonomous agent invokes a chain of external tools (querying a database, executing code in a sandbox, or calling third-party APIs), the generation stream pauses. To your frontend, the connection appears idle. To intermediate proxies, API gateways, and load balancers, an idle HTTP connection is dead weight marked for termination.

The Multi-Turn Reality: When an agent tool call takes 15 to 30 seconds to complete, standard HTTP/1.1 and Server-Sent Events (SSE) connections frequently drop. If your architecture relies on in-memory streaming state in ephemeral backend containers, a reconnecting client either duplicates costly tool side-effects or encounters unrecoverable state drift.
PART 01

The architectural gap: Tool latency vs. proxy timeouts

In standard request-response lifecycles, latency is bounded. In multi-turn agent execution, model generation alternates between fast token streaming and long, silent tool execution phases:

01

The silent proxy black hole

Cloud load balancers, CDN edges, and enterprise corporate proxies enforce aggressive idle connection timeouts (often 30 to 60 seconds). When an agent pauses text generation to wait on an external tool (such as an asynchronous BigQuery query or a multi-step web retrieval), zero bytes flow across the wire.

The proxy silently drops the socket without sending a TCP FIN or RST packet to the client. The client UI sits permanently in a loading state, while the backend continues to execute and bill for unmonitored compute.

The Keep-Alive Rule: Production streaming gateways must inject periodic SSE comment heartbeats (: ping\n\n) at sub-15-second intervals during asynchronous tool execution to maintain active TCP socket state (IETF RFC 9293) through intermediate proxies.
02

The stateless reconnection trap

When a mobile client experiences a network handoff (e.g., switching from Wi-Fi to cellular) or recovers from a silent timeout, it initiates a reconnection. In naive serverless architectures:

  • Ephemeral Instance Routing: The reconnected request lands on a different container instance (e.g., in Google Cloud Run) that lacks the in-memory stream buffer of the previous session.
  • Duplicate Tool Execution: If the client blindly re-sends the original prompt, the agent re-executes non-idempotent tool calls (such as charging a payment or creating duplicate database rows).
  • Token Buffer Thrashing: If the server replays the entire conversation history from scratch over the new stream, the client UI stutters, re-renders hundreds of tokens, and corrupts local scroll state.
PART 02

The old way vs. the new way

Building production-grade multi-turn agent systems requires shifting from in-memory stream assumptions to durable, event-sourced session transport:

Failure ModeThe Old Way (Naive In-Memory Streaming)The New Way (Transactional Session Gateway)
Idle Tool LatencyZero bytes sent during tool execution; proxy drops socket after 30s.Background heartbeat emitter sends periodic SSE comments (: ping\n\n) every 10s.
Mid-Stream DisconnectStream state lost on container recycle; client restart aborts session.Event-sourced log in Cloud Firestore records each token chunk and tool payload with a monotonic seq_id.
Reconnection IngressClient re-submits prompt, risking duplicate non-idempotent tool actions.Client sends Last-Event-ID header; gateway replays only unacknowledged events from Firestore.
Tool ConcurrencyConcurrent client retries trigger race conditions in parallel containers.Distributed lease / mutex lock in Firestore ensures only one container executes tools per session.
Resilient Multi-Turn Agent Transport TopologyClient Ingress • Cloud Run Gateway • Transactional Session Store
LAYER 01: CLIENT RUNTIMEApp Check + Last-Event-ID
Idempotent Stream Consumer

Tracks monotonic event sequence IDs, automatically reconnects with exponential backoff on transport drop, and passes Last-Event-ID for gap-free resumption.

HTTPS SSE Stream / Reconnect with Last-Event-ID
LAYER 02: EXECUTION GATEWAYGoogle Cloud Run
Stateful Multi-Turn Reassembler

Emits sub-15s keep-alive ping frames during tool execution, acquires atomic session locks, and streams model tokens while persisting event batches.

Atomic Append and Mutex Lease (Firestore Transactions)
LAYER 03: PERSISTENCE LAYERCloud Firestore
Event-Sourced Session Journal

Maintains the authoritative append-only log of token chunks, tool call requests, and verified tool execution results for deterministic resumption.

Interactive Lab · chunk-dropMid-Stream Disconnect Simulator

Kill the connection mid-generation and watch idempotent reassembly recover exactly where TCP died.

Zero dependencies · runs 100% in your browser · nothing leaves this page
PART 03

Production-ready TypeScript implementation

Below is the production-tested AgentSessionStreamGateway implementation. It runs on Cloud Run or Firebase App Hosting, managing heartbeats during tool calls and enabling seamless reconnection via Last-Event-ID:

AgentSessionStreamGateway.ts
TypeScript
import { Response } from 'express';
import { Firestore } from '@google-cloud/firestore';

export interface StreamEvent {
  seq: number;
  type: 'token' | 'tool_start' | 'tool_end' | 'done' | 'error';
  payload: unknown;
  timestamp: number;
}

export class AgentSessionStreamGateway {
  private heartbeatTimer?: NodeJS.Timeout;
  private currentSeq: number = 0;
  private eventBuffer: StreamEvent[] = [];
  private readonly MAX_REPLAY_LIMIT = 200;

  constructor(
    private readonly sessionId: string,
    private readonly res: Response,
    private readonly db: Firestore
  ) {}

  /**
   * Initializes SSE response headers and begins periodic keep-alive pings.
   */
  public initHeaders(): void {
    this.res.setHeader('Content-Type', 'text/event-stream');
    this.res.setHeader('Cache-Control', 'no-cache, no-transform');
    this.res.setHeader('Connection', 'keep-alive');
    this.res.setHeader('X-Accel-Buffering', 'no');
    this.res.flushHeaders();

    // Emit an SSE comment ping every 10 seconds to prevent proxy timeouts
    this.heartbeatTimer = setInterval(() => {
      if (!this.res.writableEnded) {
        this.res.write(': ping\n\n');
      }
    }, 10_000);
  }

  /**
   * Replays unacknowledged events with bounded limit on client reconnect.
   */
  public async replayFrom(lastEventId: number): Promise<number> {
    const snapshot = await this.db
      .collection('agent_sessions')
      .doc(this.sessionId)
      .collection('events')
      .where('seq', '>', lastEventId)
      .orderBy('seq', 'asc')
      .limit(this.MAX_REPLAY_LIMIT)
      .get();

    for (const doc of snapshot.docs) {
      const event = doc.data() as StreamEvent;
      this.writeSseFrame(event);
      this.currentSeq = Math.max(this.currentSeq, event.seq);
    }

    return this.currentSeq;
  }

  /**
   * Emits tokens instantly to client socket and buffers state for batched persistence.
   */
  public emit(type: StreamEvent['type'], payload: unknown): void {
    this.currentSeq += 1;
    const event: StreamEvent = {
      seq: this.currentSeq,
      type,
      payload,
      timestamp: Date.now(),
    };

    // 1. Flush immediately to client socket (zero latency penalty on streaming)
    this.writeSseFrame(event);

    // 2. Buffer in memory for batched commit
    this.eventBuffer.push(event);

    // 3. Flush checkpoints immediately on tool execution boundaries
    if (type !== 'token') {
      void this.flushBuffer();
    }
  }

  /**
   * Flushes in-flight event buffer to Firestore using atomic batched writes.
   */
  public async flushBuffer(): Promise<void> {
    if (this.eventBuffer.length === 0) return;

    const eventsToCommit = [...this.eventBuffer];
    this.eventBuffer = [];

    const batch = this.db.batch();
    const sessionRef = this.db.collection('agent_sessions').doc(this.sessionId);

    for (const event of eventsToCommit) {
      const docId = event.seq.toString().padStart(8, '0');
      const docRef = sessionRef.collection('events').doc(docId);
      batch.set(docRef, event);
    }

    await batch.commit();
  }

  private writeSseFrame(event: StreamEvent): void {
    if (this.res.writableEnded) return;
    this.res.write(`id: ${event.seq}\n`);
    this.res.write(`event: ${event.type}\n`);
    this.res.write(`data: ${JSON.stringify(event.payload)}\n\n`);
  }

  public async close(): Promise<void> {
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer);
    }
    await this.flushBuffer();
    if (!this.res.writableEnded) {
      this.res.end();
    }
  }
}
Architectural Takeaway: Never treat multi-turn agent streaming as a single ephemeral HTTP pipe. Protect your connections with periodic keep-alive pings during tool execution, persist event streams with monotonic sequence IDs in Cloud Firestore, and support Last-Event-ID reconnection to make your agent runtimes resilient against real-world network drops.
Architecture Blueprint and Spec: Inspect the complete Deterministic Agent Runtime Blueprint → or generate production-ready specification files with the noVibes Agent Spec Generator →
REFERENCES

Primary research and documentation