Skip to content

Workflow context_filter runs inside orchestrator replay #79

Description

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:

  1. Side effects repeat on every replay, so one handoff can write many audit entries.
  2. I/O can raise on a later replay, failing an orchestration whose original run succeeded and whose
    result is already recorded.
  3. 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

  • Upstream: public accessor for context_mode / context_filter on AgentExecutor.
    Durable reads the private attributes today with a full fallback. Guarded by projection tests
    that build a real AgentExecutor per 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 custom mode 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_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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions