Skip to content

feat: add toJSON() to all streaming events for wire-safe serialization#708

Open
agent-of-mkmeral wants to merge 3 commits intostrands-agents:mainfrom
agent-of-mkmeral:agent-tasks/wire-safe-streaming-events
Open

feat: add toJSON() to all streaming events for wire-safe serialization#708
agent-of-mkmeral wants to merge 3 commits intostrands-agents:mainfrom
agent-of-mkmeral:agent-tasks/wire-safe-streaming-events

Conversation

@agent-of-mkmeral
Copy link

Motivation

Every event from agent.stream() carries agent: LocalAgent — the full Agent instance with conversation history, app state, model config, and internal registries. When users send events over the wire via JSON.stringify(event), the output is massively bloated:

Conversation size Per-event JSON With toJSON()
100 messages ~54 KB ~100-200 bytes
1,000 messages ~246 KB ~100-200 bytes

This makes SSE streaming, WebSocket forwarding, and any wire transport impractical without manual field picking.

Resolves: agent-of-mkmeral/strands-coder#41

Public API Changes

All 16 streaming event classes now have a toJSON() method that JSON.stringify() calls automatically. Zero breaking changes — event.agent still works for direct access in hooks and in-process code.

// Same code, dramatically different wire output
for await (const event of agent.stream('Hello')) {
  // Before: ~54KB per text delta (entire agent serialized)
  // After: ~120 bytes (just the delta)
  res.write(`data: ${JSON.stringify(event)}\n\n`)
}

What each event serializes to:

Event toJSON() output
ModelStreamUpdateEvent { type, event }
ContentBlockEvent { type, contentBlock }
ModelMessageEvent { type, message, stopReason }
ToolResultEvent { type, result }
ToolStreamUpdateEvent { type, event }
AgentResultEvent { type, result }
MessageAddedEvent { type, message }
BeforeToolCallEvent { type, toolUse }
AfterToolCallEvent { type, toolUse, result, error? }
AfterModelCallEvent { type, stopData?, error? }
BeforeToolsEvent / AfterToolsEvent { type, message }
Lifecycle events { type }

Excluded from all events: agent: LocalAgent. Additionally excluded where applicable: tool: Tool (class with methods), mutable flags (cancel, retry). Error objects are converted to their .message string.

cc @mkmeral

@mkmeral
Copy link
Contributor

mkmeral commented Mar 20, 2026

@agent-of-mkmeral do we send all of these events through stream? we don't need to do it for hooks, only streamed events

@agent-of-mkmeral
Copy link
Author

Good question @mkmeral! Yes — in the current architecture, all 16 events flow through stream(). There's no distinction between "hook-only" and "stream-only" events.

Here's the relevant code from agent.ts lines 407-439:

public async *stream(args, options) {
  const streamGenerator = this._stream(args, options)
  let result = await streamGenerator.next()

  while (!result.done) {
    const event = result.value
    // Hooks fire on the event
    if (event instanceof HookableEvent) {
      await this._hooksRegistry.invokeCallbacks(event)
    }
    this._printer?.processEvent(event)
    yield event  // ← every event is also yielded to the stream consumer
    result = await streamGenerator.next()
  }
  // ...
}

So when a user does for await (const event of agent.stream(...)) and then JSON.stringify(event), any of the 16 event types can hit the serialization path — including lifecycle events like BeforeInvocationEvent, BeforeModelCallEvent, etc.

That said, you raise a valid design point. We could scope toJSON() to only the events users typically care about serializing over the wire (the "data events"):

Commonly serialized (data events) Rarely serialized (lifecycle/hook events)
ModelStreamUpdateEvent InitializedEvent
ContentBlockEvent BeforeInvocationEvent / AfterInvocationEvent
ModelMessageEvent BeforeModelCallEvent
ToolResultEvent BeforeToolCallEvent / AfterToolCallEvent
ToolStreamUpdateEvent BeforeToolsEvent / AfterToolsEvent
AgentResultEvent MessageAddedEvent
AfterModelCallEvent (has error)

But even lifecycle events carry agent: LocalAgent, so a single JSON.stringify(event) on any event causes the bloat. Adding toJSON() to all events ensures no landmine regardless of which events the user serializes.

Would you prefer I:

  1. Keep as-istoJSON() on all 16 events (defensive, consistent)
  2. Scope downtoJSON() only on the ~7 data events that users typically serialize, and add the rest later if needed

Happy to adjust either way! 👍

mkmeral
mkmeral previously approved these changes Mar 20, 2026
* Serializes for wire transport, excluding the agent reference.
* Called automatically by JSON.stringify().
*/
toJSON(): { type: 'initializedEvent' } {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's use Pick<InitializedEvent, 'type'> and similar through out here; it simplifies the types and keeps them in sync

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done ✅ — Updated all 16 toJSON() return types to use Pick<>:

  • Lifecycle events: Pick<InitializedEvent, 'type'>, etc.
  • Data events: Pick<ModelStreamUpdateEvent, 'type' | 'event'>, etc.
  • Error-transforming events: Pick<AfterToolCallEvent, 'type' | 'toolUse' | 'result'> & { error?: string } — uses Pick for the fields that pass through unchanged, plus an intersection for the Error → string transformation.

All quality gates pass: 1705/1705 tests ✅, type-check ✅, ESLint ✅, Prettier ✅

toJSON(): { type: 'beforeToolsEvent'; message: Message } {
return { type: this.type, message: this.message }
}
}
Copy link
Member

@zastrowm zastrowm Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm imagining that eventually we're going to want to be able to deserialize these - e.g. Strands Agents on backend, import this on frontend to get nice serialization/classes

Non-blocking and semi-off topic but I'd wonder how'd we do that. Mock out an LocalAgent that we pass into the constructor? Separate data class? @pgrayy just to get your brain juices going.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great forward-looking question! A few options I can see:

  1. Static fromJSON() factory on each event class — takes the toJSON() shape and returns the event, passing a stub/null agent. Cleanest for frontend consumption.
  2. Separate wire types — export the Pick<> shapes as standalone type aliases (e.g., type ModelStreamUpdateEventJSON = Pick<ModelStreamUpdateEvent, 'type' | 'event'>) so frontend code can import them without needing the full class.
  3. Discriminated uniontype StreamEventJSON = ModelStreamUpdateEventJSON | ContentBlockEventJSON | ... for exhaustive switch matching on the frontend.

The Pick<> approach we just adopted makes option 2 particularly clean — the wire types are already derived from the class definitions.

Happy to explore this as a follow-up if there's interest!

Address review feedback from @zastrowm to use Pick<EventClass, 'field'>
instead of inline type literals. This keeps return types in sync with
class definitions automatically.

- Simple events: Pick<EventClass, 'type'>
- Data events: Pick<EventClass, 'type' | 'field1' | 'field2'>
- Error-transforming events: Pick<...> & { error?: string }
  (since Error is serialized as its .message string)
* Converts Error to its message string for safe serialization.
* Called automatically by JSON.stringify().
*/
toJSON(): Pick<AfterToolCallEvent, 'type' | 'toolUse' | 'result'> & { error?: string } {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For snapshot/Messages, we currently exclude errors, until we have a better serialization story for them; Should we do that here?

If not, what if we start with { error: { message?: string } } so that later we can add more information to it (for example the type or something)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great point on consistency with snapshots/Messages.

I think the safest approach is option 1: { error: { message?: string } } — it gives us an extensible object shape from day one, so we can later add type, code, stack etc. without breaking wire consumers. Excluding errors entirely would lose useful debugging info for SSE/WebSocket consumers who want to know why a tool or model call failed.

I'll update both AfterToolCallEvent and AfterModelCallEvent:

// Return type
& { error?: { message?: string } }

// Implementation
...(this.error !== undefined && { error: { message: this.error.message } })

This way:

  • ✅ Wire consumers get error context
  • ✅ Object shape is extensible (add type, code later)
  • ✅ Consistent pattern that can be adopted by snapshots when ready

Let me know if you'd prefer excluding entirely instead — happy to go either way.

callback: () => 'result',
})
const toolUse = { name: 'testTool', toolUseId: 'id-1', input: { query: 'hello' } }
const event = new BeforeToolCallEvent({ agent, toolUse, tool })
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a good way to determine what is excluded? For example, could we have a test with every event, and just verify that the class and the serialized json match except for agent, tool, etc.?

I'm a bit concerned of us adding new fields and not realizing that we needed to add it to the event too

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent concern — this is exactly the kind of regression that's easy to miss. Here's my plan:

I'll add a "serialization completeness" test that uses Object.keys() to compare what the class has vs what toJSON() returns, with an explicit allowlist of known exclusions:

// Known fields that should NEVER appear in toJSON() output
const EXCLUDED_FIELDS = new Set(['agent', 'tool', 'cancel', 'retry'])

// For each event class:
it('toJSON() includes all fields except known exclusions', () => {
  const event = new SomeEvent({ agent, ... })
  const instanceKeys = new Set(Object.keys(event))
  const jsonKeys = new Set(Object.keys(event.toJSON()))
  
  // Every instance key should either be in JSON or in the exclusion set
  for (const key of instanceKeys) {
    if (!jsonKeys.has(key)) {
      expect(EXCLUDED_FIELDS).toContain(key)
    }
  }
  
  // Every JSON key should come from the instance
  for (const key of jsonKeys) {
    expect(instanceKeys).toContain(key)
  }
})

This way:

  • ✅ If someone adds a new field to an event class, the test fails unless they also add it to toJSON() or to the exclusion set
  • ✅ The exclusion set is explicit and auditable
  • ✅ Catches both missing inclusions AND accidental inclusions

I'll parameterize it across all 16 event classes so it's a single describe.each block. Will push this with the error format update.

@mkmeral
Copy link
Contributor

mkmeral commented Mar 20, 2026

/strands review

@github-actions github-actions bot added the strands-running <strands-managed> Whether or not an agent is currently running label Mar 20, 2026
@github-actions
Copy link

Assessment: Request Changes

This PR implements wire-safe serialization for streaming events cleanly. The code quality is solid with proper use of Pick<> types, extensible error format, and comprehensive serialization completeness tests. All 1737 tests pass.

Review Categories
  • Code Quality: Good implementation following reviewer feedback - Pick<> return types keep types in sync, extensible { error: { message?: string } } format for future compatibility
  • Testing: Excellent coverage with new serialization completeness tests that catch missing fields automatically
  • Documentation: TSDoc comments are present and clear

Blocking Issue:

This PR adds new public API surface (toJSON() methods on all 16 streaming event classes) that users will need to know about for SSE/WebSocket scenarios. Per contribution guidelines, a Documentation PR is required.

Please add a "Documentation PR" section to the PR description with either:

  1. A link to a corresponding PR in https://github.com/strands-agents/docs/pull/...
  2. Or a brief justification if documentation is not needed (though given this is a new user-facing feature for wire transport, documentation seems warranted)

Note: Consider adding the needs-api-review label since this introduces new public API methods.

@github-actions github-actions bot removed the strands-running <strands-managed> Whether or not an agent is currently running label Mar 20, 2026
@mkmeral
Copy link
Contributor

mkmeral commented Mar 20, 2026

@agent-of-mkmeral create a docs PR, we probably want to mention the serialization behavior somewhere

agent-of-mkmeral added a commit to agent-of-mkmeral/docs that referenced this pull request Mar 20, 2026
…ents

Document the toJSON() serialization behavior for all TypeScript streaming
events. This includes:

- New 'Wire-Safe Serialization' section in the streaming overview
- Table showing what each event serializes to
- Explanation of excluded fields (agent, tool, cancel, retry)
- Error handling format ({ error: { message?: string } })
- In-process vs wire access patterns
- Updated Express.js example with serialization comments

Related: strands-agents/sdk-typescript#708
@agent-of-mkmeral
Copy link
Author

Done ✅ — Created docs PR: strands-agents/docs#693

The PR adds a "Wire-Safe Serialization (TypeScript)" section to the Streaming Events overview with:

  • How toJSON() works with JSON.stringify() (zero code changes required)
  • Complete table of what each event serializes to
  • List of excluded fields (agent, tool, cancel, retry)
  • Error serialization format ({ error: { message?: string } })
  • In-process vs wire access pattern comparison
  • Updated Express.js example with serialization comments

Documentation PR: strands-agents/docs#693

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TS streaming events

3 participants