feat: add toJSON() to all streaming events for wire-safe serialization#708
Conversation
|
@agent-of-mkmeral do we send all of these events through stream? we don't need to do it for hooks, only streamed events |
|
Good question @mkmeral! Yes — in the current architecture, all 16 events flow through Here's the relevant code from 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 That said, you raise a valid design point. We could scope
But even lifecycle events carry Would you prefer I:
Happy to adjust either way! 👍 |
src/hooks/events.ts
Outdated
| * Serializes for wire transport, excluding the agent reference. | ||
| * Called automatically by JSON.stringify(). | ||
| */ | ||
| toJSON(): { type: 'initializedEvent' } { |
There was a problem hiding this comment.
let's use Pick<InitializedEvent, 'type'> and similar through out here; it simplifies the types and keeps them in sync
There was a problem hiding this comment.
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 }— usesPickfor the fields that pass through unchanged, plus an intersection for theError → stringtransformation.
All quality gates pass: 1705/1705 tests ✅, type-check ✅, ESLint ✅, Prettier ✅
| toJSON(): { type: 'beforeToolsEvent'; message: Message } { | ||
| return { type: this.type, message: this.message } | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Great forward-looking question! A few options I can see:
- Static
fromJSON()factory on each event class — takes thetoJSON()shape and returns the event, passing a stub/null agent. Cleanest for frontend consumption. - 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. - Discriminated union —
type 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)
src/hooks/events.ts
Outdated
| * Converts Error to its message string for safe serialization. | ||
| * Called automatically by JSON.stringify(). | ||
| */ | ||
| toJSON(): Pick<AfterToolCallEvent, 'type' | 'toolUse' | 'result'> & { error?: string } { |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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,codelater) - ✅ 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 }) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
|
/strands review |
|
Assessment: Request Changes This PR implements wire-safe serialization for streaming events cleanly. The code quality is solid with proper use of Review Categories
Blocking Issue: This PR adds new public API surface ( Please add a "Documentation PR" section to the PR description with either:
Note: Consider adding the |
|
@agent-of-mkmeral create a docs PR, we probably want to mention the serialization behavior somewhere |
…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
|
Done ✅ — Created docs PR: strands-agents/docs#693 The PR adds a "Wire-Safe Serialization (TypeScript)" section to the Streaming Events overview with:
Documentation PR: strands-agents/docs#693 |
Motivation
Every event from
agent.stream()carriesagent: LocalAgent— the full Agent instance with conversation history, app state, model config, and internal registries. When users send events over the wire viaJSON.stringify(event), the output is massively bloated: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 thatJSON.stringify()calls automatically. Zero breaking changes —event.agentstill works for direct access in hooks and in-process code.What each event serializes to:
toJSON()outputModelStreamUpdateEvent{ 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 }{ type }Excluded from all events:
agent: LocalAgent. Additionally excluded where applicable:tool: Tool(class with methods), mutable flags (cancel,retry).Errorobjects are converted to their.messagestring.cc @mkmeral