Human-in-the-loop & Durability
Action tools pause for a human decision — and under the durable runner that pause is a real durable signal, so the run suspends in the state store and resumes on approval, surviving restarts.
Some tools are safe to run on the model's say-so; some are not. A read tool auto-executes. An
action tool never does — the loop pauses for an explicit approve/reject. How that pause is
implemented is the difference between the inline runner and the durable runner, but the wire
protocol your frontend speaks is identical either way.
The approval flow
Declare a tool kind: 'action' and it becomes gated:
@AiTool({
name: 'purgeCache',
kind: 'action', // never auto-executes — the run pauses here
description: 'Purge a cache key.',
input: z.object({ key: z.string() }),
})
export class PurgeCacheTool implements ToolHandler<{ key: string }> {
async execute(input: { key: string }) {
await this.cache.purge(input.key);
return { purged: input.key };
}
}When the model calls purgeCache, the loop stops before execute and waits. A human resolves it
through the REST endpoints:
# approve
curl -X POST http://localhost:3000/agent/tool-call/approve \
-H 'content-type: application/json' \
-H 'x-actor-id: u1' -H 'x-actor-role: ADMIN' \
-d '{ "toolCallId": "..." }'
# reject (with an optional reason the model sees)
curl -X POST http://localhost:3000/agent/tool-call/reject \
-H 'content-type: application/json' \
-H 'x-actor-id: u1' -H 'x-actor-role: ADMIN' \
-d '{ "toolCallId": "...", "reason": "not now" }'On approval the tool executes and the turn continues; on rejection the loop resumes with the rejection (and any reason) fed back to the model, which adapts instead of running the action.
A decision carries only the toolCallId: the server derives the exact run awaiting it (the tool
call's thread's active stream), so no run id is sent or trusted. That is also what lets a delegated
sub-agent's action tool be approved — a sub-agent streams into the top-level run the human is
watching, and the same toolCallId resolves to the sub-agent's own child run.
Both endpoints are ownership-scoped: the acting actor must own the tool call's run, or the request
gets a 403 (404 if the tool call doesn't exist at all).
The frontend wraps this
useAgentChat surfaces pending approvals and exposes approve/reject callbacks, so a UI renders an
"Approve?" prompt inline in the conversation. See Frontend.
Two runners, one protocol
The same pause is backed two ways, chosen by the durable flag on AgentModule.forRoot:
| 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 |
| Requires | nothing extra | AgentDurableModule + a configured DurableModule |
| Wire protocol | identical | identical |
Because the protocol is identical, you can develop against the inline runner and flip to durable in production without touching your frontend or your tools.
Under the durable runner
With durable: true, each agent turn runs as a @dudousxd/nestjs-durable workflow (the
agent.run workflow). Every model call and every tool call is a checkpointed step — so a crash
mid-turn replays from cache rather than re-charging the model or re-running a side effect.
AgentModule.forRoot({
model: myModelProvider,
store: myAgentStore,
actorResolver: new HeaderActorResolver(),
durable: true, // each turn is the durable `agent.run` workflow
// ...
});durable: true on its own configures the agent to expect the durable runner; the runner itself lives
in AgentDurableModule, which you import alongside a configured DurableModule. The
agentDurable(options) helper from @dudousxd/nestjs-agent/durable collapses those two imports into
one — it wires AgentModule.forRoot({ ...options, durable: true }) and AgentDurableModule together,
so you spread it into imports:
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 {}The longhand — AgentModule.forRoot({ durable: true }) plus a separate AgentDurableModule import —
still works; agentDurable is just the one-import shortcut.
The human-in-the-loop pause becomes a durable signal: on the approval boundary the run suspends
into the state store (via the durable waitForSignal primitive) instead of holding a connection
open. An approve/reject POST delivers the signal and the run resumes — minutes or restarts later —
replay-safe.
Streaming and durability compose, they don't fight
Live tokens don't go through the durable checkpoint path — they flow on a separate data plane (a
TokenStreamSink). So you get resumable, replay-safe orchestration and token-by-token streaming
at once, rather than trading one for the other.
Dispatched steps: the run isn't pinned to a pod
Under durable: true, the turn's two long steps — the model call and each tool execution — dispatch
as routed durable steps (AgentRunSteps.llm / AgentRunSteps.tool) instead of running in-process.
This is the default the moment durable: true is set; opt out with dispatchedSteps: false to
keep them as in-process ctx.localSteps.
agentDurable({
model: myModelProvider,
store: myAgentStore,
actorResolver: new HeaderActorResolver(),
// dispatchedSteps: false, // opt out — keep the llm/tool steps in-process
});AgentRunSteps.llm and .tool are always registered as worker groups by AgentDurableModule
regardless of this flag — the worker group is never left unserved — so the flag only decides whether
the workflow dispatches to them or runs the equivalent step in-process with ctx.localStep. Each
dispatched step re-resolves its own dependencies from whichever worker picks it up (its own DI, via
AGENT_DEPS_FACTORY.forAgent), so a run can leave its originating pod for the model call and come
back to a different one for a tool execution; the retry policy differs by step too — the llm step
retries (@Step({ retries: 3 })), the tool step doesn't, since tool idempotency is your domain, not
the runtime's.
Multi-pod fleets need a cross-process token sink
A dispatched step can run on any worker in the fleet — not necessarily the pod holding the SSE
connection. Wire a cross-process TokenStreamSink (e.g. RedisTokenStreamSink) so streamed tokens
reach the right connection regardless of which pod executes the step. A boot warning fires when
dispatchedSteps is effectively on (the default under durable: true) with the default in-process
sink.
A suspend is never mispersisted as a tool failure
The durable runner's suspend/continue-as-new signals — thrown when a waitForSignal (an action
tool's approval pause, among others) fires — are recognized by the durable runtime's marker-based
isWorkflowControlFlowSignal before they ever reach the loop's tool-catch handling. Earlier releases
mis-detected this by instanceof, which broke under the BullMQ thin worker (a different Suspend
class) and corrupted the run's history on resume; the marker check fixed that across every
classification site (the workflow's own catch, the loop's isControlFlowError hook, and the runner's
start-suspend swallow). This holds whether the suspending step ran in-process or dispatched, so you
never need to special-case a suspend in your own tool code.
Approving from a console, not just chat
Approve/reject don't have to originate from the chat UI watching the run. A separate surface — a
governance console, an ops dashboard — can bind AGENT_APPROVAL_PORT (AgentApprovalPort), an SPI
the agent runtime implements and provides, routing through the exact same decision path the chat
endpoints use (the durable signal, or the inline in-memory resolution) without re-running the
ownership check the chat endpoints do — the console's own guards are expected to front it instead.
interface AgentApprovalPort {
approve(toolCallId: string, opts?: { executedByRef?: string }): Promise<void>;
reject(toolCallId: string, opts?: { executedByRef?: string; reason?: string }): Promise<void>;
}executedByRef records who actually decided. It lands on the tool call's persisted decision
(decision.executedByRef ?? the run's own actor) on both the executed and the rejected path, so a
console operator's approval is attributable even though the run's own actor never touched the
request. @dudousxd/nestjs-agent-dashboard's approvals inbox stamps this from the live console
request via an approvalActorRef dashboard option that — as of @dudousxd/nestjs-agent 0.7.0 —
defaults to the same ActorResolver the agent module is already configured with, so a console whose
auth matches chat auth needs no extra wiring; override approvalActorRef only when the console's
request shape differs. See Cost & Governance for the
approvals inbox itself (pendingApprovals, the dashboard section, the 501 a port-less console gets).
Resuming a stream
Durability also covers the transport. A dropped SSE connection reconnects and replays the buffered tokens for the in-flight run:
# resume an in-flight run's stream
curl -N http://localhost:3000/agent/chat/$RUN_ID/stream \
-H 'x-actor-id: u1' -H 'x-actor-role: ADMIN'A run can also be cancelled:
curl -X POST http://localhost:3000/agent/chat/$RUN_ID/cancel \
-H 'content-type: application/json' \
-H 'x-actor-id: u1' -H 'x-actor-role: ADMIN'Cancel is ownership-scoped, same as approve/reject: the acting actor must own the run, or the
request gets a 403 (404 if the run doesn't exist).
A failed durable run surfaces the same way as an inline one
Durable runs emit event: error on a failure (e.g. quota exceeded) exactly like the inline runner —
the HTTP stream ends with the structured error frame instead of hanging. Durability changes how the
run is checkpointed, not how a failure is reported to the client.
Observability
Every stage of a run is announced on Node's diagnostics_channel under aviary:agent:*
(run.started, message, tool-call, delegated, quota.exceeded, run.failed, run.finished). The
telescope extension consumes them for the "Agent" tab; under the durable runner, runs also appear in
the durable "Workflows" tab for free. See Cost & Governance.
Related
- Tools — declaring
readvsactiontools - Multi-agent — a delegated sub-agent is a durable child run
- Frontend — rendering the approve/reject prompt in the UI
- Cost & Governance — the diagnostics events and run history
Identity & Authorization
How the agent learns who is calling (ActorResolver, with no insecure default) and decides — per tool, server-side — whether they may run it (roles vs abilities).
Multi-agent
Declare named agents as @Agent classes, let an orchestrator hand off to sub-agents via handoff, and run each delegation as a durable child run.