« Phase 19 README · Warmup · Hitchhiker's · Principal Deep Dive · Core Contributor · Staff Notes
Phase 19 — Deep Dive: The Three Execution Engines Under CrewAI
The load-bearing idea in CrewAI is not "role-playing agents." It is that the process is the execution model — the same crew of agents runs under one of three mechanically distinct engines, and each has different ordering, a different LLM-call count, a different determinism guarantee, and a different failure surface. This doc traces all three engines at the level the labs make you build them: the data structures, the control and data flow, the invariants, the complexity, and a worked trace of each. Strip the persona metaphor and what remains is three scheduling algorithms.
Interpolation: the shared front door, and the trap it avoids
Before any engine runs, {placeholder} tokens in a task's description and expected_output are
substituted from the kickoff(inputs=...) dict. The mechanism is a safe substitution — replace
only known keys, leave every other brace untouched:
def interpolate(template, inputs):
out = template
for key, value in inputs.items():
out = out.replace("{" + str(key) + "}", str(value))
return out
The invariant this preserves: task descriptions routinely contain literal braces (JSON examples,
format specs, code), and those must survive uninterpolated. A naive str.format would raise
KeyError on the first stray brace and crash the crew. This is a small detail that separates
"operated a crew at scale" from "ran the quickstart" — and it is why real CrewAI does not use
str.format either.
Engine 1 — the sequential process: an ordered fold with a context switch
The sequential engine is a fold over the task list. The data structures: a Task carries
(description, expected_output, agent, context); each run produces a TaskOutput(description, expected_output, agent, raw); the crew returns a CrewOutput where .raw is the last task's output
and .tasks_output is every task's output in order, plus a _by_task dict for lookup.
The load-bearing mechanism is _build_context, a three-way switch on the task's context field:
def _build_context(self, task, prev, outputs_by_task):
if task.context is None:
return prev.raw if prev is not None else "" # feed-forward ("" for the first task)
parts = []
for src in task.context:
if src not in outputs_by_task: # src hasn't run yet
raise ValueError("context may only reference EARLIER tasks")
parts.append(outputs_by_task[src].raw)
return "\n".join(parts)
context is None(the default) → feed-forward: the previous task'sraw, or""for the first task. This is why a two-task crew "just works."context == [t1, t3]→ fan-in: exactly those tasks' outputs, concatenated in list order.context == []→ isolated: the empty loop yields""; the task sees nothing.
The invariant that makes this a pipeline and not a tangle: a task may reference only earlier
tasks. Because assignment into outputs_by_task happens after a task runs, a forward reference — a
task depending on one listed later — is src not in outputs_by_task and raises. This is a topological
guarantee enforced by the fold order itself, not a separate check.
Complexity is O(N) tasks, one LLM call each (ignoring in-task tool loops). And because nothing
here samples, the sequential process is fully deterministic given the model — stub the injected
policy and you can assert the exact order, the exact context each task saw, and the exact final
output. That is the whole point of sequential, and the reason it is the right default.
Worked trace (research → write → edit, all context=None): the researcher runs with context=""
and emits facts. The writer runs with context=researcher.raw (feed-forward) and drafts from those
facts without any wiring. The editor runs with context=writer.raw and polishes. CrewOutput.raw
is the editor's output; .tasks_output holds all three in order. Now change the writer to
context=[researcher_a, researcher_b] and it fans in two specific sources instead of "the previous
thing" — the single most common place a real crew's output goes subtly wrong is leaving this at its
feed-forward default when a fan-in was meant.
Engine 2 — the hierarchical process: delegate, execute, synthesize
The hierarchical engine inverts assignment: tasks are not pre-assigned. You give the crew a pool
of workers (keyed by unique role) and a Manager with two injected policies — allocate(desc, worker_roles, context) → role and synthesize(goal, outputs) → text. The loop:
build workers_by_role (reject duplicate roles — the manager addresses workers BY role)
for task in tasks:
role := manager.allocate(desc, worker_roles, accumulated_context) # a MANAGER call
if role not in workers_by_role: raise # it can hallucinate
output := workers_by_role[role].execute(desc, expected, context) # a WORKER call
accumulate output into context
final := manager.synthesize(goal, worker_outputs) # a MANAGER call
Two invariants define this engine. Delegation-by-role: the manager returns a role string, so
roles must be unique, and a role that does not exist must be a hard error, not a silent no-op — a
manager LLM will occasionally hallucinate a worker, and swallowing it produces a crew that silently
does nothing useful. The cost invariant: manager_calls == len(tasks) + 1 — one allocation call
per task plus one synthesis call. The lab surfaces this on CrewOutput.manager_calls so it is
telemetry, not folklore. On a 5-task crew that is 6 extra LLM round-trips on top of the 5 worker
calls.
The determinism difference is the crux: sequential's flow is fixed by the list; hierarchical's flow is decided by the manager's judgment, so it is less deterministic and the manager is a single point of failure the whole crew's coherence passes through. Why the naive instinct fails at the mechanism level: reaching for hierarchical because it "coordinates better" on a task whose order is actually fixed pays N+1 manager calls and injects a second unreliable LLM into your control flow for zero benefit — pure cost, latency, and risk. Hierarchical earns its keep only when routing across specialists genuinely must be decided at runtime and their outputs reconciled.
Engine 3 — Flows: an event graph reduced to a fixed point
Flows are a different tool entirely — event-driven orchestration, not a task sequence. Methods are
wired by decorators into an event graph: @start (entry point), @listen(trigger) (fires on the
trigger's completion, receiving its return value), @router(trigger) (fires after the trigger and
emits its return value as a label), and the gates or_ / and_. The engine represents a trigger
as a Condition(kind, triggers) where kind is OR or AND, and evaluates it against a set of
emitted names:
def _satisfied(cond, emitted):
if cond.kind == "OR": return any(t in emitted for t in cond.triggers)
return all(t in emitted for t in cond.triggers) # AND == fan-in gate
The execution is a round-based fixed-point loop: run all @start methods; after each round,
find every listener whose Condition is now satisfied and has not yet fired; run those; repeat until
no listener is newly ready. Three mechanisms make it correct:
- Each method fires at most once. A
firedset guards re-entry. Because there are finitely many methods and none re-fires, the loop always terminates — even with diamonds (a → {b, c} → and_(b, c) → d) or apparent cycles. This is the termination proof, and it is a one-line invariant. - A router emits a label into
emitted. After a@routerreturns, its value is added to the emitted set, so@listen("that-label")methods become ready. Branching is just "emit a name that some listeners are waiting on." The router passes a label, not data — data flows throughself.stateand through@listen(method)return-value payloads. - Payload passing is arity-based. The engine inspects the listener's positional-parameter count; if it accepts an argument, it receives the triggering method's return value, otherwise it is called with none. "Pass it if you asked for it."
State is id-stamped and comes in two shapes: unstructured (self.state is a dict) or
structured (a typed model — a Pydantic BaseModel in real CrewAI, a @dataclass in the lab).
Mutations persist forward across steps — that shared evolving state is the flow's memory — and
@persist snapshots it after each step (in-memory here, SQLite in real CrewAI, keyed by the state
id) so a crashed or human-paused flow can resume.
Worked trace (score → approve/revise): draft (@start) runs, appends to state.trail, emits
"draft". grade (@router(draft)) becomes ready, runs, and returns "approve" if `state.score
= 7
else"revise"— that string is emitted as a label. In the next round, whichever of@listen("approve")/@listen("revise")matches the emitted label fires exactly once, and the other never does. Feedscore=9and the trail isdraft → grade → approve; feedscore=3and it isdraft → grade → revise`. The branch is deterministic given the state, and the graph terminates because each method fired at most once.
What to hold onto
Three engines, three algorithms: sequential is an ordered fold with a three-way context switch, deterministic and cheap; hierarchical is delegate-execute-synthesize with an N+1 manager-call cost and a single point of judgment; Flows are an at-most-once event graph that reduces to a fixed point and therefore always terminates. The persona metaphor is ergonomics; the process is the machine. Know which engine a crew runs, what it costs in LLM calls, and whether its control flow is deterministic, and you can defend any CrewAI design from the mechanism instead of the marketing.