Runners
One AgentRunner SPI, two implementations — inline runs in-process by default, durable is an opt-in that checkpoints every step and turns HITL approval into a real suspend.
The agent loop doesn't know or care how its step and
awaitApproval hooks are implemented — that's the whole point of the AgentRunner SPI. Two
implementations exist, and switching between them never touches your tools, your frontend, or the
wire protocol.
interface AgentRunner {
start(input: AgentRunInput): Promise<{ runId: string }>;
signal(runId: string, toolCallId: string, decision: Decision): Promise<void>;
cancel(runId: string): Promise<void>;
}start enqueues and returns immediately with the runId — the actual tokens flow separately over
the TokenStreamSink data plane, not through this call. See
Architecture for how the control and data planes split.
Inline (default) vs. durable (opt-in)
| Inline runner (default) | Durable runner (durable: true) | |
|---|---|---|
| The pause | an in-process runner holds the turn open | a real durable suspend — the run is checkpointed to the state store |
| Surviving a restart | no — the pending turn is lost | yes — the run resumes from cache on approval |
| Where the turn executes | the pod that received the request, start to finish | whichever worker picks up the agent.run workflow — and, by default, the model call and each tool call can each land on a different worker again (see below) |
| Requires | nothing extra | @dudousxd/nestjs-durable + AgentDurableModule + a configured DurableModule |
| Wire protocol | identical | identical |
AgentModule.forRoot binds InlineAgentRunner to the AGENT_RUNNER token unless durable: true is
set — inline is the real, zero-dependency default, not a fallback.
When to pick which
- Inline for local development, tests (pairs naturally with
@dudousxd/nestjs-agent-testing's fake model and in-memory store), and any deployment where a lost in-flight turn on a crash is acceptable. - Durable once you need a turn to survive a process restart mid-approval, want every model and
tool call individually checkpointed (so a crash replays from cache instead of re-charging the model
or re-running a side effect), or already run
@dudousxd/nestjs-durableelsewhere in the app.
Because the AgentRunner SPI is the only seam, you can develop against inline and flip to durable in
production by changing configuration, not code.
Dispatched steps: the durable runner's default posture
Under durable: true, the workflow's bookkeeping (persisting a message, bumping quota, stream
markers) checkpoints as ctx.localStep — pinned to whichever worker is running the workflow. But
the turn's two genuinely long operations — the model call and each tool execution — are, by
default, dispatched as separately-routed durable steps instead (AgentRunSteps.llm /
AgentRunSteps.tool), so a turn isn't pinned to one pod for however long the model takes to
respond or a tool takes to run. Set dispatchedSteps: false (requires durable: true) to opt back
into the pre-dispatch behavior and keep those two steps as ctx.localSteps as well.
Both worker groups are always registered by AgentDurableModule regardless of this flag — only
whether the workflow actually routes to them is controlled by dispatchedSteps — so a fleet
never ends up with an unserved worker group after a config change either way.
Multi-pod fleets need a cross-process sink
Because a dispatched step can execute on any worker, the pod that streams tokens for llm may not
be the pod holding the client's SSE connection. Wire a cross-process TokenStreamSink (e.g.
RedisTokenStreamSink from -transport-redis) for any multi-pod deployment under durable: true
— dispatched or not, since the run itself is already not pinned to the requesting pod. A boot
warning fires when dispatching is effectively on with the default in-process sink still bound. See
Architecture for the full control/data-plane picture.
The one-import shortcut
The long form wires durable: true on AgentModule.forRoot and imports AgentDurableModule
separately. agentDurable(options) collapses both into one:
import { DurableModule } from '@dudousxd/nestjs-durable';
import { agentDurable } from '@dudousxd/nestjs-agent/durable';
@Module({
imports: [
DurableModule.forRoot({ /* store, transport, … */ }),
...agentDurable({
model: myModelProvider,
store: myAgentStore,
actorResolver: new HeaderActorResolver(),
// ...same options as AgentModule.forRoot, minus `durable`
}),
],
})
export class AppModule {}agentDurable spreads into imports because it wires AgentModule.forRoot({ ...options, durable: true }) and AgentDurableModule together — it isn't a module itself.
durable: true alone isn't enough
Setting durable: true on AgentModule.forRoot without importing AgentDurableModule throws at
boot with a clear message rather than failing silently or falling back to inline — a forgotten import
is a configuration error, not a degraded mode.
Full details
The mechanics of the durable suspend itself — the agent.run workflow, ctx.waitForSignal, and
resuming a dropped stream — are in
Human-in-the-loop & Durability; the durable
runtime it depends on is documented in full at @dudousxd/nestjs-durable.
Where to go next
The agent loop
One turn is model → tools → model, bounded by maxSteps, with a usage row appended every time the model is called.
Governance
Four layers stack on every turn — who's calling and what they may do, how many tokens they've spent today, what it cost, and an audit trail of everything that happened.