Remote child chunks are written once; a write reports its result (RM-86, RM-89)
A parent agent proxies a remote child's stream chunks into its own stream. Before this release, two faults could change what the parent's stream held:
- Duplicates (RM-86). A forwarder that re-attaches (a crash, a Durable Object eviction, a Temporal activity or CF Workflows / DBOS step retry) re-reads the child's stream from the progress it last persisted, which can lag what it already wrote, and a zombie forwarder can keep running beside its replacement. The same child chunk could land in the parent stream twice. Only the ai-sdk
RemotePatchFilterdropped repeats, and only fordata-state-patch, so re-proxied text and tool chunks rendered twice. - Loss (RM-89). Every forwarder recorded a chunk as forwarded BEFORE its write. A write that threw (a store outage) left that record ahead of the stream: runtime-js and the CF Durable Object retried from it in-process, core's dispatch (and Temporal through it) persisted it on the drop path, and DBOS recorded it on the failed ref, so the chunk was never forwarded again.
Now every forwarder writes first and then records the chunk, and every stream manager writes a proxied chunk at most once per parent run. It is a minor release of @helix-agents/core, @helix-agents/store-memory, @helix-agents/store-redis, @helix-agents/store-cloudflare, @helix-agents/runtime-js, @helix-agents/runtime-cloudflare, @helix-agents/runtime-temporal and @helix-agents/runtime-dbos.
StreamWriter.write resolves with what it did
// Before
write(chunk: StreamChunk, options?: StreamWriteOptions): Promise<void>;
// After
write(chunk: StreamChunk, options?: StreamWriteOptions): Promise<StreamWriteResult | void>;
type StreamWriteResult =
| { written: true }
| { written: false; reason: 'fenced' | 'guest_inactive' | 'duplicate_remote_chunk' };Every built-in stream manager (memory, Redis, the Cloudflare DO's DOStreamManager, store-cloudflare's stream Durable Object and its binding) now resolves every write with its result. A deliberate drop is still never a throw: fenced (CP-85, a superseded run's chunk), guest_inactive (CP-129, a child's chunk to an ended / failed / paused parent stream) and duplicate_remote_chunk (RM-86). Code that ignores the result is unaffected.
- If you wrap a
StreamWriter, return the inner write's result (return inner.write(chunk, options)). A wrapper that resolves with nothing reports nothing. - If you typed a promise of a write as
Promise<void>, widen it toPromise<StreamWriteResult | void>(or map it with.then(() => undefined)).
remoteSource.childRunId
RemoteSource gains an optional childRunId: the runId the child stamped on the chunk, which every built-in forwarder records before the parent's own writer can fill a missing runId with the parent's run. It is the parent stream's dedup key. If you forward a remote child's chunks yourself (a bring-your-own loop), record it: remoteSource: { childSessionId, childSequence, streamId, ...(chunk.runId ? { childRunId: chunk.runId } : {}) }, and advance your own cursor only after the write resolved. executeRemoteSubAgentDispatch does both.
Custom StreamManager implementations
The store contract (@helix-agents/core/testing, streamManagerContractTests) adds the RD.* cases. To pass them:
- Keep a high-water per remote child run. Key it with core
remoteChunkDedup(chunk)(kind: 'keyed'giveskey, from(remoteSource.childSessionId, remoteSource.childRunId), andchildSequence). - Inside the same atomic read that decides the write, pass the stored high-water for that key to
decideStreamWriteasremoteHighWater. Map the decision to the result withstreamWriteResultOf. - Raise the high-water to
childSequenceonly AFTER the chunk is stored, in the same atomic step. - Clear it in
resetStream, and inclaimStreamwhen the owner run CHANGES. A re-claim by the current owner keeps it. - A proxied chunk with no
remoteSource.childRunId(kind: 'unstamped') is appended without the dedup. Report it withcreateUnstampedRemoteChunkWarn(logger): onewarnper stream,core.stream.remote_chunk_unstamped.
Storage
| Manager | New state |
|---|---|
| store-memory | an in-memory map per stream |
| store-redis | a hash stream:{id}:remote-hw (same TTL as the stream's keys) |
| runtime-cloudflare (DO) | a stream_remote_high_water table in the DO's SQLite, created on demand |
| store-cloudflare (stream DO) | a remote_high_water table in the stream DO's SQLite, created on demand |
No migration is needed. Deploy store-cloudflare's binding and its stream Durable Object together: the binding now parses the /write answer, and an answer of an unknown shape is a typed transport_error.
Behaviour changes
| Where | Before | After |
|---|---|---|
| Any parent stream, a remote child re-forwarded in one run | every re-forwarded chunk written again | written once; a repeat resolves duplicate_remote_chunk |
| A parent's next run re-attaching an owed child | re-forwarded chunks written | unchanged: a new run clears the high-water |
| A parent-stream write that throws mid-forward (every runtime) | the chunk could be skipped for good (RM-89) | the chunk is forwarded again by the retry |
| A guest chunk dropped from an inactive stream | the write resolved undefined | { written: false, reason: 'guest_inactive' } |