Composition patterns¶
Most introductions to LazyBridge compose agents vertically —
Agent.chain(a, b, c) for a one-shot linear pipeline, or
Plan(Step("a"), Step("b"), Step("c")) when you need named steps,
typed hand-offs, sentinels, or routing. That's enough for many
applications.
This page is the horizontal counterpart: how to compose
pipelines side-by-side, and how to nest one pipeline inside another.
The same Agent = Engine + Tools + State mental model applies — the
trick is that Plan itself is an engine, so an agent whose engine
is a Plan is a perfectly valid Step.target for an outer
plan. Pipelines compose recursively.
Vertical recap (one paragraph)¶
Vertical composition produces a single sequence of steps:
from lazybridge import Agent, LLMEngine, Plan, Step
researcher = Agent(engine=LLMEngine("claude-sonnet-4-6"), name="research")
writer = Agent(engine=LLMEngine("gpt-5.4-mini"), name="write")
editor = Agent(engine=LLMEngine("claude-sonnet-4-6"), name="edit")
pipeline = Agent(
engine=Plan(Step("research"), Step("write"), Step("edit")),
tools=[researcher, writer, editor],
name="vertical_pipeline",
)
This is the shape covered by Chain and Plan. Everything below assumes you've read those.
Horizontal: a Plan whose step is a Plan¶
The simplest horizontal composition: one Step in an outer Plan
targets an agent whose own engine is a Plan. The outer plan
sees a single tool call; under the hood, the sub-plan runs its own
multi-step pipeline.
from lazybridge import Agent, LLMEngine, Plan, Step, from_step
# --- Sub-pipeline: a self-contained "research" capability ---
search = Agent(engine=LLMEngine("claude-haiku-4-5"), name="search")
summarise = Agent(engine=LLMEngine("claude-haiku-4-5"), name="summarise")
research_pipeline = Agent(
engine=Plan(
Step("search"),
Step("summarise"),
),
tools=[search, summarise],
name="research", # outer plan references this name
)
# --- Outer pipeline: research → write → edit ---
writer = Agent(engine=LLMEngine("gpt-5.4-mini"), name="write")
editor = Agent(engine=LLMEngine("claude-sonnet-4-6"), name="edit")
pipeline = Agent(
engine=Plan(
Step("research"), # nested plan
Step("write", context=from_step("research")),
Step("edit"),
),
tools=[research_pipeline, writer, editor],
name="article_pipeline",
)
result = pipeline("AI agent frameworks, April 2026")
print(result.text())
print(f"total cost: ${result.metadata.cost_usd}")
What's happening:
research_pipelineis a regularAgent. Itsenginehappens to be aPlan, but from the outer plan's perspective it's just a named tool —Step("research")resolves to it via thetools=[research_pipeline, ...]map.- Cost / token telemetry from the sub-plan rolls up into the outer
envelope's
metadata.nested_*fields. The singleresult.metadata.cost_usdyou read at the end is the whole tree. - Sentinels (
from_step("research")) work transparently — the outer plan seesresearch's final envelope, not its intermediate steps.
When to nest a plan vs flatten the steps¶
| Shape | When |
|---|---|
Flat Plan(Step("search"), Step("summarise"), Step("write")) |
The steps are co-evolving — one team owns them, the data flow is straightforward, and you don't want a separate test surface for "research". |
Nested Plan(Step("research"), Step("write")) where research is its own Agent(engine=Plan(...)) |
You want isolation: the research sub-pipeline ships as a reusable unit, has its own tests, its own checkpoint key if needed, and can be swapped for a different implementation (e.g. a SupervisorEngine or a custom engine) without touching the outer plan. |
Horizontal + parallel: parallel bands of sub-pipelines¶
Step(..., parallel=True) runs sibling steps concurrently. When
each "step" is itself a sub-pipeline, you get N independent
pipelines running side by side, with their outputs aggregated via
from_parallel_all(...).
from lazybridge import Agent, LLMEngine, Plan, Step, from_parallel_all
# Three independent research pipelines, each a Plan of its own.
def make_research_pipeline(name: str, source_agent: Agent) -> Agent:
summarise_agent = Agent(engine=LLMEngine("claude-haiku-4-5"), name="summarise")
return Agent(
engine=Plan(
# Step.target is positional — pass the agent object as ``target=``
# and override the in-plan name to "search" so the sub-pipeline
# has stable step names regardless of which source agent it wraps.
Step(target=source_agent, name="search"),
# String target → tool-map lookup; ``summarise_agent`` is in
# ``tools=[...]`` below under its own ``name="summarise"``.
Step("summarise"),
),
tools=[summarise_agent],
name=name,
)
web_research = make_research_pipeline("web", Agent(engine=LLMEngine("claude-sonnet-4-6"), name="web_search"))
academic_research = make_research_pipeline("academic", Agent(engine=LLMEngine("claude-sonnet-4-6"), name="academic_search"))
internal_research = make_research_pipeline("internal", Agent(engine=LLMEngine("claude-sonnet-4-6"), name="internal_search"))
synthesiser = Agent(engine=LLMEngine("claude-sonnet-4-6"), name="synthesise")
pipeline = Agent(
engine=Plan(
Step("web", parallel=True), # branch 1: full sub-pipeline
Step("academic", parallel=True), # branch 2: full sub-pipeline
Step("internal", parallel=True), # branch 3: full sub-pipeline
Step("synthesise",
context=from_parallel_all("web")), # joins all 3 parallel siblings
),
tools=[web_research, academic_research, internal_research, synthesiser],
name="multi_source_brief",
)
What this gives you:
- Three independent sub-pipelines run concurrently via
asyncio.gather. The framework caps concurrency atPlan(max_parallel_steps=…)(defaults to unbounded). from_parallel_all("web")resolves to the labelled-text join of every contiguousparallel=Truesibling starting atweb— sosynthesisesees one input containing all three branches' outputs, each labelled with its branch name.- First-error short-circuit: if any branch errors, the outer
envelope carries that error and the remaining branches' results
are dropped. Use a
verify=judge orfallback=agent on individual branches if you need graceful degradation.
When to use parallel bands vs Agent.parallel(...)¶
| Shape | When |
|---|---|
Agent.parallel(a, b, c) |
You want a single envelope out (labelled-text join). No further plan structure needed. See Parallel. |
Step("a", parallel=True) … Step("d", context=from_parallel_all("a")) |
The parallel work is one stage of a larger plan — there's setup before, aggregation after, sentinels across, and you want crash-resume on the whole thing. This page. |
Horizontal: agent-as-tool with LLM-decided dispatch¶
The previous two patterns are deterministic — the plan decides
which sub-pipelines run. The third horizontal shape hands the
decision to an LLM: pass sub-pipelines in the outer agent's
tools=[...] and let the model pick.
from lazybridge import Agent, LLMEngine
# Same three sub-pipelines as above (each is an Agent(engine=Plan(...))).
orchestrator = Agent(
engine=LLMEngine(
"claude-sonnet-4-6",
system=(
"You have three research sub-pipelines available. Call only "
"the ones that match the user's question; combine their results."
),
),
tools=[web_research, academic_research, internal_research],
name="adaptive_research",
)
orchestrator("Compare LangGraph and CrewAI for our use case.")
What's different:
- The LLM chooses which sub-pipelines to call, in what order, and whether to call multiple in parallel (the engine emits parallel tool calls automatically when the model requests them in the same turn).
- No
Planat the outer layer — the engine isLLMEngine, so there's no compile-time DAG validation, no checkpointing on the outer call, no sentinels. You trade auditability for adaptability. - Sub-pipeline internals stay deterministic. Each sub-agent's
own
Planstill validates at construction, still produces predictable token cost, still respects its owncheckpoint_key=.
Choosing between the three horizontal shapes¶
Do all sub-pipelines always need to run?
├── Yes, sequentially → outer Plan with one Step per sub-pipeline
├── Yes, concurrently → outer Plan with parallel=True bands + from_parallel_all
└── No — let the model decide → outer LLMEngine with sub-pipelines in tools=[...]
Diagram — vertical vs horizontal¶
VERTICAL (chain / linear Plan)
─────────────────────────────
start → [search] → [summarise] → [write] → [edit] → end
one process, one path, total latency = sum of steps
HORIZONTAL — Plan-of-Plans
──────────────────────────
start → ╔════════════ research ════════════╗ → [write] → [edit] → end
║ [search] → [summarise] ║
╚══════════════════════════════════╝
sub-pipeline is one tool to the outer plan; sentinels traverse the boundary
HORIZONTAL — parallel bands of sub-pipelines
────────────────────────────────────────────
╔════════ web ════════╗
║ [search]→[summarise]║
╠══════ academic ═════╣
start → ║ [search]→[summarise]║ → [synthesise(from_parallel_all)] → end
╠══════ internal ═════╣
║ [search]→[summarise]║
╚═════════════════════╝
three sub-pipelines run concurrently; aggregator joins their outputs
HORIZONTAL — LLM-decided dispatch
─────────────────────────────────
┌── web ────────┐
│ [Plan...] │
start → [LLM] ──── │ academic │ ── (LLM may call 1-N, may parallel)
│ [Plan...] │
│ internal │
│ [Plan...] │
└───────────────┘
no outer Plan; engine emits tool calls based on the user prompt
Cost and observability across nested boundaries¶
Every horizontal shape preserves the cost roll-up:
- Inner pipelines write their tokens + cost + latency to their own
envelope's
metadata.*. - When the outer plan invokes the inner agent as a tool, the inner
envelope's metadata is folded into the outer envelope's
metadata.nested_*fields. Session.usage_summary()walks the whole tree and gives you one number per provider / model.
There is no "I called three sub-pipelines and have no idea what it cost" failure mode — the rollup is automatic.
Pitfalls¶
- Naming collisions are silent. If two sub-agents share the same
name=, the outer tool map registers a single entry (with aUserWarning) and the outer plan resolves bothStep("search")references to the same target. Always name sub-agents distinctly, even when they live in separate sub-pipelines. Plan(max_iterations=N)is per-plan, not transitive. A nestedPlanthat loops forever is invisible to the outer plan's iteration counter. Set sensible caps on every level.- Checkpoint keys must be unique across the tree. If both the
outer plan and an inner plan write to the same
Storewith the samecheckpoint_key=, their state collides. Namespace them (e.g."article/research","article/write"). - Sentinels are per-plan.
from_step("research")in the outer plan resolves to the inner agent's final envelope — you cannot reach intofrom_step("research.search")to read the inner step's output. If the outer plan needs an inner step's value, surface it via the inner plan'swrites=to a sharedStoreand read it withfrom_agent("research.search"). - First-error short-circuit at each level. A failing branch in
an inner parallel band aborts the inner plan, which aborts the
outer step, which aborts the outer plan — unless one of the
intermediate agents has a
fallback=or the failing step is wrapped withverify=.
See also¶
- Chain — the vertical baseline.
- Parallel — single-level
Agent.parallel(...)fan-out; the lighter sibling of parallel plan bands. - Plan — the outer-pipeline surface this page composes.
- Parallel plan steps — the
parallel=Truemechanism in depth. - Sentinels —
from_step,from_parallel_all,from_agentsemantics across plan boundaries. - Checkpoint & resume — applies independently per
plan in the tree; namespace your
checkpoint_key=. - Recipes → Supervisor pattern — an LLM-decided dispatch over sub-agents, the runnable form of the third horizontal shape.