Context Variables
Session-scoped mutable state that any participant can read or write, auto-persisted on the WAL, and visible to transition conditions. The modern equivalent of classic ContextVariables from autogen.agentchat.group, scoped to one workflow session.
The Mechanism#
Context variables live on WorkflowState.context_vars: dict[str, Any] — a field folded under the per-session WAL lock. There's no parallel persistence layer; the dict is a derivation of the WAL, rebuilt deterministically by Hub.hydrate() replaying the recorded envelopes.
Two pieces:
- Mutation — anyone in the session emits an
EV_CONTEXT_SETenvelope withevent_data = {"set": {...}, "delete": [...]}. The workflow adapter folds it before any substantive turn check, so the new values are visible to the next fold (typically the speaker's text reply). - Read — transition conditions get
stateas their first arg, soContextEquals(key, value)readsstate.context_vars.get(key)directly. Tools can also injectSessionStateInjectto read.
flowchart LR
Tool["agent's tool calls<br/>session.send(EV_CONTEXT_SET, ...)"]
Hub["Hub.post_envelope<br/>under per-session WAL lock"]
WAL[(WAL append)]
Fold["WorkflowAdapter.fold<br/>merges into context_vars"]
State[("WorkflowState.context_vars")]
Cond["next-speaker rule<br/>ContextEquals(key, value)"]
Tool --> Hub --> WAL --> Fold --> State --> Cond Loose semantics#
Any participant of the session can write EV_CONTEXT_SET, regardless of whose turn it is. The per-session WAL lock serialises concurrent writes — there's no race even with multiple tools racing on the same key. An out-of-turn observer can stamp a flag for the next turn's routing decision; no need to wait for the floor.
Sender must be a participant of the session — non-participants are rejected by validate_send.
Setting Values#
Send the envelope from any participant via the existing Session.send:
audience=[] keeps the dispatch list empty — context updates are state-only; we don't fire anyone's receive. The envelope still lands on the WAL.
The full event_data shape:
{
"set": {"key1": value1, "key2": value2}, # merge into context_vars
"delete": ["key3", "key4"], # remove these keys
}
Either field is optional. Within one envelope, delete runs first, then set — so you can atomically delete-then-overwrite if needed. Multiple envelopes serialise via the WAL lock, so deterministic order is the WAL's order.
Reading Values#
Transition conditions get the State directly. ContextEquals is shipped:
Missing keys compare as None, so ContextEquals(key="foo", value=None) fires when foo was never set or was explicitly deleted.
For tools that need to read context (e.g. to make their writes idempotent), inject the State:
Custom Conditions#
If ContextEquals isn't enough, write a custom TransitionCondition and register it. The Protocol is just two attributes:
Once registered, the condition serialises through TransitionGraph.to_dict() and re-loads correctly — same path as the built-in FromSpeaker / ToolCalled / ContextEquals.
Initial Values#
Pre-populate context at session creation by passing a context_vars knob alongside the graph:
The knob is read once by WorkflowAdapter.initial_state and copied into the State. Subsequent EV_CONTEXT_SET envelopes mutate from there.
Persistence and Hydrate#
Adapter state is not stored separately on disk. The hub's KnowledgeStore persists the WAL; on Hub.hydrate(), every session's adapter state is reconstructed by replaying the WAL through initial_state then fold once per envelope. So the context_vars dict that exists in memory after a write is always the deterministic result of the recorded mutations — survives restart, survives a fresh process, identical across replicas.
This is the lead dev's "WAL is the source of truth, indexes and derivations are fine" rule applied: context_vars is a derivation of EV_CONTEXT_SET envelopes on the WAL.
Turn Bookkeeping#
EV_CONTEXT_SET is non-substantive: it does not advance turn_count, does not rotate expected_next_speaker, and does not appear in the LLM's projected history through the default WindowedSummary view. From the perspective of "whose turn is it," the envelope might as well not exist. Only its effect on context_vars survives.
This means a tool can write context mid-turn (during the active speaker's Agent.ask call), the speaker can then emit a normal EV_TEXT reply, and the reply's fold sees the new context. The next-speaker rule fires against the post-write context. Exactly what you want for "agent's tool decides where we go next."
Worked Example#
For a runnable end-to-end example of context-driven routing — a router agent classifies a request and a ContextEquals transition routes to the matching specialist — see the Context-Aware Routing entry in the Pattern Cookbook.
Comparison to Classic ContextVariables#
| Capability | Classic (autogen.agentchat.group) | Beta workflow |
|---|---|---|
| Mutable session-scoped dict | ContextVariables(...) passed to initiate_chat | WorkflowState.context_vars |
| Tool writes context | ReplyResult(message, context_variables=...) | Tool emits EV_CONTEXT_SET envelope |
| Condition reads context | StringContextCondition, ExpressionContextCondition | ContextEquals (and custom-registered conditions) |
| Auto-render into LLM prompt | Built-in | Not yet — write a middleware that reads SessionStateInject.context_vars and prepends to the prompt |
| Persisted across restart | Held in memory only | WAL-replayed on Hub.hydrate() |
| Visible in audit trail | No | Every mutation is a real envelope on the WAL |
The two missing classic features — auto-render and the rich expression DSL — are deliberate omissions for first cut. Both can be added on top of the existing primitives without framework changes.
See Also#
- Workflow Adapter — graphs, transitions, targets, conditions, and a "Context-Driven Transitions" section dedicated to routing patterns.
- Closing Sessions —
ContextEqualsis also useful for context-driven termination, e.g.Transition(when=ContextEquals("done", True), then=TerminateTarget(reason="user_done")). - Pattern Cookbook — eight canonical orchestrations (Pipeline, Star, Feedback Loop, Triage-with-Tasks, etc.) translated from classic AG2.
- Migrating from Group Chat — side-by-side translation of classic patterns.