microsoft / microsoft/agent-framework-durable-extension
.NET: `DurableWorkflowResult.Events` retains the full workflow event stream and can exceed payload limits
@ahmedmuhsin is already working on this.
Since Aug 20, 2026.
- Dominant language
- Python
- Stars
- 16
- Forks
- 10
- Avg merge
- 3d 9h
- Merged PRs (30d)
- 9
Description
Verified against: main @ ad941eff
Summary
DurableWorkflowRunner.SuperstepState.AccumulatedEvents grows monotonically for the lifetime of a workflow
run. It is never pruned, it absorbs the complete event log of every sub-workflow the workflow invokes,
and it is returned whole as DurableWorkflowResult.Events in the orchestration output.
On a Durable Task Scheduler deployment without large-payload externalization, that puts a ceiling on the
cumulative serialized event volume a workflow may emit over its lifetime — independent of any individual
message being small. (This is about event bytes, not wall-clock: a workflow parked on a request port for
weeks emits nothing and is unaffected.) Where payload externalization is available the run does not fail,
but the accumulation still shows up as growing memory, transfer, blob-storage and replay cost.
#15 fixed the publishing of this list to custom status (bounded trailing window + EventsStartIndex). It
deliberately did not bound the list itself — the completion output is what backfills events a lagging
consumer missed while it was behind the window. So the accumulation survives on main by design, and the
question this issue raises is what to do about it now that the output path is the remaining unbounded one.
Where it happens on main (@ ad941eff)
dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableWorkflowRunner.cs
| Line | What |
|---|---|
| 291 | AccumulatedEvents is declared as a plain List<string> |
| 407 | ProcessSuperstepResults appends every executor's events to it — the only mutation in the file |
| 223 | the whole list is returned as DurableWorkflowResult.Events in the orchestration output |
dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableExecutorDispatcher.cs
| Line | What |
|---|---|
| 203 | ExecuteSubWorkflowAsync calls the child via CallSubOrchestratorAsync<DurableWorkflowResult?> |
| 231 | ConvertWorkflowResultToExecutorOutput copies the child's entire Events list into the parent's executor output |
There is no retention policy, no configuration knob, and no ContinueAsNew anywhere in the runner, so the
list never resets within a run.
The case that compounds is a parent that invokes a bound sub-workflow inside a cycle: n invocations of a
child whose log serializes to m bytes add roughly n × m bytes to the parent's own output, on top of the
parent's own events. Linear in the iteration count, but with a large constant — the child's whole history
each pass, not its delta.
What we observed
A .NET orchestrator with one top-level workflow binding several sub-workflows via BindAsExecutor, plus
human-in-the-loop request ports for clarification rounds, running on Durable Task Scheduler. Runs died with:
Orchestration response failed validation: Custom status length exceeds the limit of 1048576 UTF8-encoded bytes
We had already been through one round of this and had shrunk our per-message payloads hard — CI guards keep
every event payload under 8 KB and every clarification round under 64 KB total. That bought linear headroom
and nothing more: at ~64 KB per round, ~16 rounds still reaches 1 MiB.
Two caveats on that evidence, so it is not read for more than it shows:
- This is the pre-#15 form of the failure, on the custom-status write. We hit it because no published
package carries #15 — filed separately, since that is a release-pipeline matter with a different owner. - Post-#15 the same accumulation lands on the output write instead.
Why the trailing window does not close it
The window in #15 bounds what is published, which is the right fix for the live-streaming reader. The
underlying list is still the run's whole history, and it is still serialized in full into the orchestration
output at completion.
That is not an oversight — it is a delivery guarantee. DurableStreamingWorkflowRun.WatchStreamAsync drains
the completion output with the same absolute lastReadEventIndex it used against the live window, precisely
so a consumer that fell behind still receives everything. DurableWorkflowRunnerEventWindowTests and
DurableStreamingWorkflowRunTests enforce it.
So please do not read this issue as asking for AccumulatedEvents to be trimmed by default. That would
silently drop events for exactly the lagging consumer the current design protects. Any bound needs explicit
semantics.
Suggested directions
Roughly in order of how much we'd value them:
- Externalized or segmented storage for the accumulated log. Keep the delivery guarantee, stop carrying
the whole history in one orchestration payload. This is the option that costs consumers nothing. - Opt-in bounded retention with explicit gap signaling on
DurableWorkflowOptions— a max byte budget
where the output declares that a range was dropped, so a lagging consumer learns it has a gap instead of
silently missing events. Lossy, but only when asked for, and never silently. - Make the child-log fold configurable. A parent that never streams nested events still pays for every
child's full history. Note this is not simply redundant: the parent's executor output legitimately carries
Result,SentMessagesandHaltRequestedas well, andEventsis what gives the top-level consumer
nested streaming visibility — the dispatcher does not surface a child instance id, so a consumer cannot
today go fetch the child's log itself. An opt-out (or an instance-id handle in its place) would let
workflows that don't need nested visibility avoid the cost. - Surface the ceiling before the backend does. Today the backend rejects the turn and the consumer gets
a raw validation string. A typed error naming the workflow, the event count and the knob to change would
turn an unexplained dead run into something an operator can act on. - Document it. "Your workflow's cumulative event volume must fit the backend payload limit unless
externalization is enabled" is a load-bearing constraint that is currently only discoverable by reading
the runner.
Repro sketch
SerializedOutput is not readable until the orchestration completes, so this has to be measured across
completed runs rather than sampled mid-flight:
- Define a child workflow with a handful of executors; bind it into a parent with
BindAsExecutor. - Give the parent a cycle that re-invokes the child, with a parameter controlling how many passes it makes
before exiting the cycle and completing. - Run it to completion for
n = 1, 2, … Nand compareSerializedOutputsize across those runs.
Size grows by the child's full event log per pass. With a large enough child or N, the final run fails on
the output write.
Adjacent issues
- #15 — bounded the custom-status write; the list it publishes from is the subject of this issue.
- #4 — automatic compaction of chat history for the same backend size limit. Same class of problem on the
agent-conversation side; this one is the workflow-event side.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.