Skip to content
This repository was archived by the owner on Mar 20, 2026. It is now read-only.

TS streaming events #41

@mkmeral

Description

@mkmeral

Validate this issue, fix it, and create a PR on TS repo, and tag me. (make sure to read agents.md)

# TypeScript SDK: Wire-Safe Streaming Events

## Problem

Every event from `agent.stream()` carries `agent: LocalAgent` — the full Agent instance with conversation history, app state, model config, and internal registries. `JSON.stringify(event)` doesn't crash, but it's bloated:

| Conversation size | Per-event JSON | Should be |
|---|---|---|
| 100 messages | ~54 KB | ~100-200 bytes |
| 1,000 messages | ~246 KB | ~100-200 bytes |

## Fix

Add `toJSON()` to every event class. `JSON.stringify` calls it automatically. Zero API changes — `event.agent` still works for direct access.

## What `stream()` Yields (in order)

```
BeforeInvocationEvent
MessageAddedEvent              ← user message appended
BeforeModelCallEvent
ModelStreamUpdateEvent (×N)    ← token deltas (bulk of events)
ContentBlockEvent (×N)         ← completed blocks (text, tool_use, reasoning)
ModelMessageEvent              ← full message + stopReason
AfterModelCallEvent
BeforeToolsEvent
BeforeToolCallEvent
ToolStreamUpdateEvent (×N)     ← tool progress events
AfterToolCallEvent
ToolResultEvent
AfterToolsEvent
MessageAddedEvent (×2)         ← assistant + tool result messages
  ── loop repeats if LLM requests more tools ──
ModelStreamUpdateEvent (×N)    ← final answer tokens
ContentBlockEvent
ModelMessageEvent
AfterModelCallEvent
MessageAddedEvent
AfterInvocationEvent
AgentResultEvent               ← final result
```

## Event-by-Event: What to Keep, What to Exclude

**Excluded from ALL events:** `agent: LocalAgent`

| Event | Keep | Exclude (besides `agent`) | `toJSON()` |
|---|---|---|---|
| `ModelStreamUpdateEvent` | `event: ModelStreamEvent` | | `{ type, event }` |
| `ContentBlockEvent` | `contentBlock` | | `{ type, contentBlock }` |
| `ModelMessageEvent` | `message`, `stopReason` | | `{ type, message, stopReason }` |
| `ToolResultEvent` | `result: ToolResultBlock` | | `{ type, result }` |
| `ToolStreamUpdateEvent` | `event: ToolStreamEvent` | | `{ type, event }` |
| `AgentResultEvent` | `result: AgentResult` | | `{ type, result }` |
| `MessageAddedEvent` | `message` | | `{ type, message }` |
| `BeforeToolCallEvent` | `toolUse: {name,id,input}` | `tool: Tool` (class w/ methods), `cancel` (mutable state) | `{ type, toolUse }` |
| `AfterToolCallEvent` | `toolUse`, `result` | `tool: Tool`, `error` → `.message`, `retry` (mutable state) | `{ type, toolUse, result, error? }` |
| `AfterModelCallEvent` | `stopData?` | `error` → `.message` | `{ type, stopData?, error? }` |
| `BeforeToolsEvent` | `message` | `cancel` (mutable state) | `{ type, message }` |
| `AfterToolsEvent` | `message` | | `{ type, message }` |
| `BeforeInvocationEvent` | *(nothing)* | | `{ type }` |
| `AfterInvocationEvent` | *(nothing)* | | `{ type }` |
| `BeforeModelCallEvent` | *(nothing)* | | `{ type }` |
| `InitializedEvent` | *(nothing)* | | `{ type }` |

All the "keep" fields are already serializable: `Message`, `ContentBlock`, `ToolResultBlock`, `AgentResult` have `toJSON()`. `ModelStreamEvent` and `ToolStreamEvent` are plain data. `StopReason` is a string. `toolUse` is `{ name: string, toolUseId: string, input: JSONValue }`.

## Before / After

```typescript
for await (const event of agent.stream('Hello')) {
  res.write(`data: ${JSON.stringify(event)}\n\n`)
}
```

Same code. Before: ~54KB per text delta (entire agent serialized). After: ~120 bytes (just the delta).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions