« 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's raw, 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 fired set 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 @router returns, 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 through self.state and 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

= 7else"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; feed score=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.