Inside the Planner: Event Choreography and Strategic Orchestration in Soorma
If you haven't read It's Time to DisCo, start there. It covers why single-threaded ReAct loops break down at scale and how event-driven choreography solves the problem. This post skips the argument and goes straight to the mechanics.
What Is a Planner?
In the Distributed Cognition (DisCo) model, every agent falls into one of three roles: Planner, Worker, or Tool.
A Planner does one thing: it receives a business event, decides what to do, and emits task events to Workers. It does not execute tasks itself. It does not wait for a response inline. It reasons, emits, and exits—the event bus carries forward from there.
from soorma.agents.planner import Planner, GoalContext
from soorma.context import PlatformContext
planner = Planner(name="maintenance-planner")
@planner.on_goal("maintenance.goal")
async def plan_maintenance(goal: GoalContext, context: PlatformContext) -> None:
vehicle_id = goal.data.get("vehicle_id")
# Store intent in working memory so Workers can retrieve it
await context.memory.store(
f"vehicle:{vehicle_id}",
{"vehicle_id": vehicle_id, "required_parts": ["brake_pads", "oil_filter"]},
plan_id=goal.correlation_id,
)
# Emit a task event — the Worker will react independently
await context.bus.request(
event_type="parts.check.requested",
data={"vehicle_id": vehicle_id},
response_event="parts.check.completed",
correlation_id=goal.correlation_id,
)
planner.run()
The response_event Contract
Notice the response_event field. This is not optional decoration — it is the explicit correlation contract between Planner and Worker.
When the Planner publishes parts.check.requested with response_event="parts.check.completed", it is declaring: "I expect a reply on this event type." The platform uses this to:
- Route responses: The completion event is delivered back to the originating Planner via its subscription.
- Enforce choreography: No inferred event names. Every event chain is explicit and auditable.
- Enable observability: The Tracker service records the full lifecycle from task emission to completion against this contract. (More on the Tracker in the next post.)
Inferred event names — where the platform guesses what the response will be called — are explicitly forbidden in Soorma. See ARCHITECTURE_PATTERNS.md Section 3 for the full choreography specification.
Plan State Machines
When a Planner orchestrates more than one Worker, the coordination challenge grows. Soorma tracks multi-step plans through context.tracker, which records a plan as a state machine with the following lifecycle:
PENDING → RUNNING → COMPLETED | FAILED | CANCELLED
Each task within a plan has its own nested state:
PENDING → RUNNING → DELEGATED | WAITING | COMPLETED | FAILED | CANCELLED
Plan tracking does not require an explicit API call. The Planner drives state through PlanContext — a durable object that persists plan state in the Memory service and advances through transitions as response events arrive. The Tracker observes those transitions automatically via the response_event correlation chain.
As Workers complete their tasks, they emit response events. The SDK transitions plan and task states, advancing to the next orchestration step. When all tasks reach terminal states, the plan finalises.
This gives you durable orchestration without a workflow engine. The state lives in the Memory service, not in the Planner process. If the Planner restarts, the plan is recoverable via context.memory.get_plan_context(). See ARCHITECTURE_PATTERNS.md Section 5 for the full PlanContext state machine specification.
Working Memory and Planner Isolation
A Planner should never pass all required data directly in task events. Instead, it stores rich context in working memory and passes only a key:
# Planner stores context (plan_id scopes the working memory entry)
await context.memory.store(
f"plan:{plan_id}:vehicle",
vehicle_data,
plan_id=plan_id,
)
# Emits only the key
await context.bus.request(
event_type="parts.check.requested",
data={"vehicle_id": vehicle_id, "plan_id": plan_id},
response_event="parts.check.completed",
correlation_id=goal.correlation_id,
)
The Worker retrieves the full context itself:
from soorma.task_context import TaskContext
@worker.on_task("parts.check.requested")
async def check_parts(task: TaskContext, context: PlatformContext) -> None:
plan_id = task.data.get("plan_id")
vehicle = await context.memory.retrieve(f"plan:{plan_id}:vehicle", plan_id=plan_id)
# ... process
This pattern keeps event payloads small, enforces the abstraction boundary, and means the Planner never needs to know the internal data requirements of each Worker.
What v0.9.1 Ships
The v0.9.1 release includes:
- The full
Plannerclass withon_goalhandler registration context.bus.publish()withresponse_eventparameter enforcementPlanContextfor durable plan orchestration with automatic Tracker observation- Working memory via
context.memory.store()andcontext.memory.retrieve()
The examples directory includes an example that demonstrates Planner agent with state machine-based orchestration. Start with 01-hello-world to get the SDK running locally before jumping into orchestration patterns.
Next up: Tracker Observability — Understanding Plan and Task State in Soorma.