Chunk Assembly

Focus Deep-Dive

Normalizing vendor SSE streams into unified response payloads

Every LLM provider ships a different Server-Sent Events format. This module intercepts raw SSE, normalizes deltas into a common internal structure, routes content by type (text, tool call, thinking), and assembles the final payloads consumed by downstream agent logic.

📚 What is this & why does it matter?

Chunk assembly is the first stage of the streaming pipeline where raw vendor events become structured data the agent can reason about. Without this normalization layer, every downstream component would need provider-specific parsing logic, creating an explosion of branching paths. Understanding chunk assembly is essential for debugging malformed tool calls, missing tokens, or truncated reasoning output.

💡 Real-world analogy

Imagine a multilingual postal sorting centre. Letters arrive in Japanese, German, Arabic, and Portuguese — each with different address formats, stamp positions, and envelope sizes. The sorting centre opens every envelope, translates the destination into a standard internal routing slip, and forwards the unified package onward. If a letter is split across two envelopes (like a tool call whose JSON arguments span multiple SSE chunks), the centre holds the first half until the second arrives, reassembles them, and only then routes the complete message.

Contents

Overview

Primary role

Normalize vendor-specific SSE streams into a unified internal chunk format, then assemble coherent payloads from those chunks.

Main inputs

Raw SSE events from OpenAI, Anthropic, Google, and Bedrock provider connections — each with a different delta schema.

Key decisions

Content routing: is this chunk text, a tool call fragment, or thinking/reasoning content? Is the tool call argument JSON complete or still accumulating?

Outputs / side effects

Unified EmbeddedRunPayload objects containing assistant text, tool metadata, and token usage — ready for the attempt loop.

flowchart TD
  SSE["Raw SSE Event Stream"]
  OAI["openai-stream-wrappers.ts"]
  GGL["google-stream-wrappers.ts"]
  ANT["anthropic-stream-wrappers.ts"]
  BDR["bedrock-stream-wrappers.ts"]
  NORM["Normalized Chunk"]
  ROUTER["Content Router"]
  TEXT["Text Accumulator"]
  TOOL["Tool-Call Accumulator"]
  THINK["Thinking Stream"]
  USAGE["usage-accumulator.ts"]
  BUILD["payloads.ts — buildEmbeddedRunPayloads()"]
  OUT["EmbeddedRunPayload"]

  SSE --> OAI
  SSE --> GGL
  SSE --> ANT
  SSE --> BDR
  OAI --> NORM
  GGL --> NORM
  ANT --> NORM
  BDR --> NORM
  NORM --> ROUTER
  ROUTER -->|"delta.content"| TEXT
  ROUTER -->|"delta.tool_calls"| TOOL
  ROUTER -->|"delta.thinking"| THINK
  NORM --> USAGE
  TEXT --> BUILD
  TOOL --> BUILD
  THINK --> BUILD
  USAGE --> BUILD
  BUILD --> OUT

The diagram above shows the full chunk-assembly data flow. Each provider wrapper sits behind a common interface: it consumes raw bytes from the HTTP response body and emits normalized chunk objects. The content router inspects each chunk's type discriminator and fans out to the appropriate accumulator. Finally, buildEmbeddedRunPayloads() merges all accumulated state into a single payload object.

Decision Points

DecisionWhat controls itWhy it matters
Provider selectionModel identifier prefix (gpt-, claude-, gemini-)Determines which stream wrapper parses the SSE bytes
Text vs tool-call routingPresence of delta.tool_calls array in the normalized chunkTool-call chunks enter a separate accumulation path with argument repair
Thinking content separationdelta.thinking field or content_block_type === "thinking"Reasoning tokens are tracked separately so they can be shown in the UI or omitted
Tool-call completenessJSON parse success on accumulated argument stringIncomplete JSON triggers the argument-repair path; premature emission would crash downstream
Stream terminationdata: [DONE] sentinel or provider-specific finish eventTriggers final payload flush and usage summary

How It Works

Chunk assembly operates in three logical phases: normalization, routing, and payload construction. Each phase is designed to be stateless with respect to the others, communicating only through the normalized chunk interface.

Phase 1 — Provider Normalization

Each LLM provider streams responses in a vendor-specific SSE format. The provider wrappers translate these into a common NormalizedChunk shape. OpenAI sends data: {choices: [{delta: {content, tool_calls}}]}, while Anthropic uses content_block_delta events with a type discriminator. Google's Vertex AI uses yet another event structure, and Bedrock wraps everything in an AWS EventStream binary framing.

// openai-stream-wrappers.ts — normalize OpenAI delta
interface OpenAIDelta {
  choices: Array<{
    delta: {
      content?: string;
      tool_calls?: Array<{
        index: number;
        id?: string;
        function?: { name?: string; arguments?: string };
      }>;
    };
    finish_reason: string | null;
  }>;
  usage?: { prompt_tokens: number; completion_tokens: number };
}

function normalizeOpenAIChunk(raw: OpenAIDelta): NormalizedChunk {
  const choice = raw.choices[0];
  return {
    content: choice.delta.content ?? null,
    toolCalls: choice.delta.tool_calls?.map(tc => ({
      index: tc.index,
      id: tc.id ?? null,
      name: tc.function?.name ?? null,
      argumentFragment: tc.function?.arguments ?? "",
    })) ?? null,
    thinking: null,
    finishReason: choice.finish_reason,
    usage: raw.usage ?? null,
  };
}
Key detail: OpenAI may send tool-call chunks where only arguments is present (no id or name). The wrapper must handle partial tool-call deltas by using the index field to match fragments to the correct accumulator slot.

Phase 2 — Content Routing

Once normalized, each chunk is routed by type. Text content is appended to a running string buffer. Tool-call fragments are dispatched to per-index accumulators that build up the argument JSON incrementally. Thinking content is piped to a separate stream handled by thinking.ts.

// Simplified content routing logic
function routeChunk(chunk: NormalizedChunk, state: AssemblyState): void {
  if (chunk.thinking) {
    state.thinkingStream.push(chunk.thinking);
    return;
  }

  if (chunk.content) {
    state.textBuffer += chunk.content;
  }

  if (chunk.toolCalls) {
    for (const tc of chunk.toolCalls) {
      if (!state.toolAccumulators[tc.index]) {
        state.toolAccumulators[tc.index] = {
          id: tc.id ?? "",
          name: tc.name ?? "",
          argumentChunks: [],
        };
      }
      const acc = state.toolAccumulators[tc.index];
      if (tc.id) acc.id = tc.id;
      if (tc.name) acc.name = tc.name;
      acc.argumentChunks.push(tc.argumentFragment);
    }
  }

  if (chunk.usage) {
    state.usageAccumulator.add(chunk.usage);
  }
}
Watch out: Anthropic sends tool-call arguments as a single blob in the content_block_stop event rather than streaming them incrementally like OpenAI. The routing logic must not assume incremental delivery — it checks whether the accumulated JSON is already parseable before engaging the repair path.

Phase 3 — Tool-Call Argument Repair

Tool-call arguments arrive as JSON fragments split across multiple SSE chunks. Because the split point is arbitrary (it may land in the middle of a string value or between object keys), naive concatenation can produce invalid JSON. The attempt.tool-call-normalization.ts module first joins all fragments and attempts JSON.parse(). If that fails, attempt.tool-call-argument-repair.ts applies heuristic fixes: closing unclosed strings, adding missing brackets, and stripping trailing commas.

Failure mode: If argument repair cannot produce valid JSON after three attempts, the tool call is marked as malformed and the attempt loop re-prompts the LLM with an error message asking it to re-emit the tool call.

Phase 4 — Payload Construction

After the stream terminates (or a tool-call is deemed complete), payloads.ts calls buildEmbeddedRunPayloads() to merge the text buffer, finalized tool calls, thinking content, and accumulated usage into an EmbeddedRunPayload. This payload is the unit of work consumed by the attempt loop and ultimately returned to the caller.

Behavior Diagram

sequenceDiagram
  participant Provider as LLM Provider
  participant Wrapper as Stream Wrapper
  participant Router as Content Router
  participant TextAcc as Text Accumulator
  participant ToolAcc as Tool-Call Accumulator
  participant Repair as Argument Repair
  participant Builder as Payload Builder

  Provider->>Wrapper: SSE chunk (vendor format)
  Wrapper->>Router: NormalizedChunk
  alt text content
    Router->>TextAcc: append(delta.content)
  else tool-call fragment
    Router->>ToolAcc: accumulate(index, fragment)
  end
  Provider->>Wrapper: SSE chunk #2
  Wrapper->>Router: NormalizedChunk
  Router->>ToolAcc: accumulate(index, fragment)
  Note over ToolAcc: JSON still incomplete
  Provider->>Wrapper: data: [DONE]
  Wrapper->>Router: finishReason = "stop"
  ToolAcc->>Repair: tryParse(joinedArgs)
  alt parse succeeds
    Repair->>Builder: validToolCall
  else parse fails
    Repair->>Repair: heuristic fix + retry
    Repair->>Builder: repairedToolCall
  end
  TextAcc->>Builder: fullText
  Builder->>Builder: buildEmbeddedRunPayloads()
  Builder-->>Provider: EmbeddedRunPayload

The sequence diagram above traces a typical two-chunk interaction where the first SSE event carries text content and the start of a tool call, the second continues the tool-call arguments, and the [DONE] sentinel triggers final assembly. Notice how the argument-repair step only activates when JSON.parse() fails on the concatenated fragments.

Design insight: By deferring tool-call argument validation until stream termination, the system avoids expensive parse attempts on every chunk. This is a deliberate trade-off: it adds latency to tool-call resolution but eliminates redundant JSON parsing overhead during high-throughput streaming.

Annotated Code Walkthrough

Walk through the key functions in the order they execute during a single streaming response.

The entry point receives raw SSE bytes from the provider HTTP response and splits them into individual events. Each event is passed to the appropriate provider wrapper:

// stream-controller.ts — entry point
async function* processStream(
  providerType: ProviderType,
  response: ReadableStream<Uint8Array>,
): AsyncGenerator<NormalizedChunk> {
  const wrapper = getStreamWrapper(providerType);
  const reader = response.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    const events = extractSSEEvents(buffer);
    buffer = events.remainder;
    for (const event of events.parsed) {
      yield wrapper.normalize(event);
    }
  }
}

Tool-call argument repair attempts to fix malformed JSON. The repair function tries progressively more aggressive fixes:

// attempt.tool-call-argument-repair.ts
function repairToolCallArguments(raw: string): object | null {
  // Attempt 1: direct parse
  try { return JSON.parse(raw); } catch {}

  // Attempt 2: close unclosed strings and brackets
  let fixed = raw;
  const openBraces = (fixed.match(/\{/g) || []).length;
  const closeBraces = (fixed.match(/\}/g) || []).length;
  if (openBraces > closeBraces) {
    fixed += "}".repeat(openBraces - closeBraces);
  }
  // Strip trailing commas before closing brace
  fixed = fixed.replace(/,\s*\}/g, "}");
  try { return JSON.parse(fixed); } catch {}

  // Attempt 3: close unclosed string literals
  const quoteCount = (fixed.match(/(?
Performance note: The repair function is called at most once per tool call (at stream end), not on every chunk. In production, fewer than 2% of tool calls require repair — the vast majority parse cleanly on the first attempt.

Finally, buildEmbeddedRunPayloads() merges all accumulated state into the output payload. Token usage from usage-accumulator.ts is included so downstream can track costs:

// payloads.ts — final assembly
function buildEmbeddedRunPayloads(state: AssemblyState): EmbeddedRunPayload {
  const toolCalls = Object.values(state.toolAccumulators).map(acc => ({
    id: acc.id,
    name: acc.name,
    arguments: repairToolCallArguments(acc.argumentChunks.join("")),
  }));

  return {
    assistantText: state.textBuffer,
    toolCalls: toolCalls.filter(tc => tc.arguments !== null),
    malformedToolCalls: toolCalls.filter(tc => tc.arguments === null),
    thinking: state.thinkingStream.join(""),
    usage: state.usageAccumulator.summary(),
  };
}

Edge Cases & Error Handling

ScenarioBehaviorCode Location
Empty SSE event (heartbeat)Silently discarded by the event parser — never reaches the wrapperstream-controller.ts:extractSSEEvents()
Tool-call argument split mid-UnicodeTextDecoder with stream: true holds incomplete byte sequences until the next chunk completes themstream-controller.ts:processStream()
Duplicate [DONE] sentinelSecond sentinel is a no-op; the generator has already returnedstream-controller.ts while-loop guard
Provider sends error event mid-streamWrapper emits a NormalizedChunk with finishReason: "error"; payload builder creates a partial payload with an error flag*-stream-wrappers.ts
Thinking content interleaved with textRouter separates by type discriminator on every chunk; ordering is preserved per-type but not across typesthinking.ts, routeChunk()
Zero tool-call arguments (empty object)JSON.parse("{}") succeeds immediately; no repair neededattempt.tool-call-argument-repair.ts

Configuration

Knob / ParameterDefaultDescription
MAX_ARGUMENT_REPAIR_ATTEMPTS3Number of heuristic repair attempts before marking a tool call as malformed
SSE_HEARTBEAT_INTERVAL_MS15000Expected heartbeat interval; exceeding 2× this triggers a connection health warning
STREAM_BUFFER_HIGH_WATER64 KBMaximum size of the SSE text buffer before forcing a flush to the event parser
THINKING_STREAM_ENABLEDtrueWhether to separate thinking/reasoning tokens into their own stream; disable to merge with text
USAGE_ACCUMULATOR_MODE"incremental"Whether to track token usage per-chunk (incremental) or only from the final summary event (final)

See also:

Directory Map

Source files for this topic
src/agents/pi-embedded-runner/
├── streaming/
│   ├── openai-stream-wrappers.ts      ← OpenAI SSE normalization
│   ├── google-stream-wrappers.ts      ← Google Vertex AI normalization
│   ├── anthropic-stream-wrappers.ts   ← Anthropic SSE normalization
│   ├── bedrock-stream-wrappers.ts     ← AWS Bedrock EventStream normalization
│   ├── payloads.ts                    ← buildEmbeddedRunPayloads() assembly
│   ├── thinking.ts                    ← Thinking/reasoning content stream
│   └── usage-accumulator.ts           ← Token usage tracking across chunks
├── attempt/
│   ├── attempt.tool-call-normalization.ts  ← Tool-call JSON join + parse
│   └── attempt.tool-call-argument-repair.ts ← Heuristic JSON repair
└── stream-controller.ts              ← SSE event loop and provider dispatch