Skip to content

Plan as engine: deterministic routing

A PulseAgent's engine can be a lazybridge.Plan. Because the tick loop dispatches through the ordinary Agent.run() path, a Plan engine needs no special handling — every step runs, and routing is honoured.

Triage-then-route

The canonical pattern: a cheap structured triage step decides a category, then routes_by jumps to the specialist sub-agent whose step name equals that category value.

from typing import Literal
from pydantic import BaseModel
from lazybridge import Agent, LLMEngine, Plan, Step
from lazypulse import PulseAgent
from lazypulse.adapters.gmail import GmailInbox, GmailPolicy

class Triage(BaseModel):
    # Each Literal value must match a downstream Step name.
    category: Literal["research", "calendar", "draft_reply"]
    confidence: float

triager   = Agent(name="triager",   engine=LLMEngine("claude-haiku-4-5"), output=Triage)
research  = Agent(name="research",  engine=LLMEngine("claude-opus-4-7"), tools=[web_search])
calendar  = Agent(name="calendar",  engine=LLMEngine("claude-opus-4-7"), tools=[calendar_tools])
draft_reply = Agent(name="draft_reply", engine=LLMEngine("claude-opus-4-7"))

pulse = PulseAgent(
    name="router",
    engine=Plan(
        Step("triager", output=Triage, routes_by="category"),
        Step("research"),
        Step("calendar"),
        Step("draft_reply"),
    ),
    tools=[triager, research, calendar, draft_reply],
    policy=GmailPolicy(owner_emails=["me@example.com"]),
    adapters=[GmailInbox(...)],
    store=store,
    session=session,
)

routes_by="category" requires that every Literal value of the category field is the name of a declared step. Routing is a detour: the router jumps to the matched step, runs it, then linear progression resumes from that step's declared position.

For predicate-based routing instead of a model field, use Step("triager", routes={"research": when.field("category").equals("research"), ...}).

Per-task isolation

Each inbound message gets its own PulseRecord (a unique task_id), and the Plan runs fresh per task. That is the isolation that matters for a pulse loop: task-level lifecycle and crash recovery live in the Store, independent of any single Plan run.

A note on checkpoints (vs. the original v3 sketch)

The v3 plan sketched passing plan_state={"checkpoint_key": ..., "resume": False} into the engine per task. That does not match lazybridge 0.7.9: Plan's checkpoint_key and resume are constructor arguments, and the plan_state= run-kwarg is a PlanState dataclass used for resume — not a config dict. Moreover, Agent.run() deliberately does not thread store / plan_state to the engine.

So LazyPulse keeps the simpler, correct contract: dispatch uniformly through self.run(). This respects the "never override run()" constraint and means Plan routing works out of the box. If you need mid-Plan, step-level resume across a crash, construct the Plan with its own checkpoint_key and store (use on_concurrent="fork" so concurrent tasks get isolated keys) — that is a lazybridge-level configuration on the engine you pass in, orthogonal to the pulse loop.