What happens
AgentExecutor.context_filter is invoked from _build_context_messages in
_workflows/orchestrator.py, which is reached from run_workflow_orchestrator. That function
yields on ctx.task_all and ctx.wait_for_external_event, so it is replayed orchestrator code.
A durable orchestrator does not resume, it re-executes from the top on every episode. The filter
therefore runs once per replay rather than once per handoff: roughly once per node in a sequential
workflow, and again each time a workflow parked on a human decision wakes.
Core types the filter as Callable[[list[Message]], list[Message]] and requires nothing further,
because an in-process executor runs it exactly once. Durable silently imposes a stricter contract.
It fails softly, which bounds the severity
Verified against durabletask/worker.py. Non-determinism is detected by checking that an action
exists at the expected id and is of the expected kind:
action = ctx._pending_actions.pop(entity_call_id, None)
if not action:
raise _get_non_determinism_error(...)
elif not action.HasField("sendEntityMessage") or not ...
The action's input is never compared, and the projection is only ever an input with nothing
branching on it. So a divergent filter raises no NonDeterminismError and delivers no altered
context to an agent. The recomputed value is discarded and the recorded result stands.
What does bite, in order:
- Side effects repeat on every replay, so one handoff can write many audit entries.
- I/O can raise on a later replay, failing an orchestration whose original run succeeded and whose
result is already recorded.
- Slow filters are paid for per episode rather than once.
Only custom is exposed. full and last_agent are list slicing.
The placement trade-off
| Where the projection is applied |
On the wire |
User code in replay |
| Orchestrator (today) |
Projection only |
Yes |
| At the destination |
Whole conversation |
No |
| Inside an activity |
Projection only |
No, one extra round trip |
The current design took the first deliberately, because keeping only the projection on the wire is
what makes context_mode an effective capacity lever. Measured, at 800 turns full is 675,560
bytes against 845 for last_agent.
Work this tracks
Not pursued
Asking core to require purity of context_filter for all users. The constraint comes from replay,
which only durable has, so durable owns the delta. Documented in ADR 0032.
Already done
The durable contract is documented in ADR 0032 under "context_filter must be pure under durable":
synchronous, deterministic, free of side effects, and independent of wall-clock time, randomness and
external state.
Raised by Laveesh Rohra (@larohra) in review of #59.
What happens
AgentExecutor.context_filteris invoked from_build_context_messagesin_workflows/orchestrator.py, which is reached fromrun_workflow_orchestrator. That functionyields on
ctx.task_allandctx.wait_for_external_event, so it is replayed orchestrator code.A durable orchestrator does not resume, it re-executes from the top on every episode. The filter
therefore runs once per replay rather than once per handoff: roughly once per node in a sequential
workflow, and again each time a workflow parked on a human decision wakes.
Core types the filter as
Callable[[list[Message]], list[Message]]and requires nothing further,because an in-process executor runs it exactly once. Durable silently imposes a stricter contract.
It fails softly, which bounds the severity
Verified against
durabletask/worker.py. Non-determinism is detected by checking that an actionexists at the expected id and is of the expected kind:
The action's input is never compared, and the projection is only ever an input with nothing
branching on it. So a divergent filter raises no
NonDeterminismErrorand delivers no alteredcontext to an agent. The recomputed value is discarded and the recorded result stands.
What does bite, in order:
result is already recorded.
Only
customis exposed.fullandlast_agentare list slicing.The placement trade-off
The current design took the first deliberately, because keeping only the projection on the wire is
what makes
context_modean effective capacity lever. Measured, at 800 turnsfullis 675,560bytes against 845 for
last_agent.Work this tracks
Upstream: public accessor for
context_mode/context_filteronAgentExecutor.Durable reads the private attributes today with a
fullfallback. Guarded by projection teststhat build a real
AgentExecutorper mode, so a rename fails CI, but it should not need guarding.Decide internally: whether the accessor is sufficient, or whether the projection should
also be applied inside an activity for
custommode so it is recorded once.Applying it in an activity removes the purity requirement entirely, at the cost of a scheduling
round trip per handoff. Not obviously worth it, since violating the contract fails softly today.
The accessor alone may be enough, given the contract is now documented.
Not pursued
Asking core to require purity of
context_filterfor all users. The constraint comes from replay,which only durable has, so durable owns the delta. Documented in ADR 0032.
Already done
The durable contract is documented in ADR 0032 under "
context_filtermust be pure under durable":synchronous, deterministic, free of side effects, and independent of wall-clock time, randomness and
external state.
Raised by Laveesh Rohra (@larohra) in review of #59.