@cuylabs/agent-runtime-dapr
v9.1.0
Published
Dapr-backed workload runtime and host adapters for @cuylabs/agent-runtime
Readme
@cuylabs/agent-runtime-dapr
Run AI agents with Dapr durability — crash-safe workflows, persistent state,
and scheduled jobs. Built on @cuylabs/agent-core and @cuylabs/agent-runtime.
Package Boundary
Use @cuylabs/agent-runtime-dapr when you want Dapr-backed infrastructure for workloads or agents:
- Dapr runtime driver for scheduled and manually triggered jobs
- durable workflow decomposition for agent turns
- persistent execution snapshots and checkpoints
- hosted HTTP runners and multi-agent hosts
- Dapr service invocation and workflow clients
This package does not redefine agent semantics or generic workload orchestration. It builds on:
agent-corefor task and turn execution semanticsagent-runtimefor the outer workload runtime contract
When paired with @cuylabs/agent-server, this package should sit behind the
same session/turn surface rather than replacing it. Use
createDaprAgentServerAdapter(runner) when you want agent-server
transports like WebSocket to route turns, steering, follow-ups, and
interactive requests through the same Dapr workflow runtime as the hosted
HTTP routes.
Context, Memory, and Workflow Versioning
Turn-start/end are separate lifecycle activities, not model-step callbacks.
Per-turn context and collector completion are serialized across workers and
overflow retries. Model-step activities return a DaprAgentTurnModelStepOutcome:
the existing successful result, a pre-provider kind: "context-compaction"
result, or a recoverable kind: "context-overflow" result. Custom consumers
must handle all three, and manual registration must include the kit's optional
activities.turnLifecycle activity.
Compaction reuses the core strategy and commit contracts. Checkpoint identity
supports retries on the active journal branch under the existing single-writer
session contract; external memory effects still require provider idempotency.
Each new turn stores a schema-v2 fingerprinted execution descriptor. Its
compaction entry pins the strategy, trigger policy, scheduling controls, and
proposal protocol (allowSummary plus exact core operation IDs/versions). Its
composition entry snapshots agent.describeComposition(): selected memory,
ordered middleware and hooks, capability providers, prompt-section identities,
tools, and effective memory settings. Command help, resource ownership, and
composition report labels are excluded from compatibility. Prompt text and
other secret or executable inputs are omitted. Versioned preset and plugin
source labels on executable surfaces make a distribution upgrade change the
composition fingerprint.
Lifecycle, policy-gate, model, compaction, and tool activities reject an
incompatible worker before resolving providers or performing effects.
This changes the durable activity sequence. Keep in-flight workflows on their original worker version and deploy new turns under a new workflow name/version. See the durable execution and migration notes before upgrading a deployed host.
Why This Package Is Bigger Than A Simple Driver
agent-runtime-dapr has two roles:
- It implements shared runtime contracts from
@cuylabs/agent-runtime - It exposes Dapr-native helpers that should stay outside the shared contract
The first category is the portability seam:
DaprRuntimeDriverimplementsRuntimeDriverDaprOrchestratorRunStoreimplementsOrchestratorRunStorecreateDaprWorkloadRuntime(...)builds aWorkloadRuntimewith those pieces
The second category is intentionally Dapr-specific:
- workflow clients and workflow activities
- HTTP host/runners
- sidecar job callbacks
- execution checkpoint persistence
- cross-service invocation helpers
Those features are not drift in the base runtime contract. They are adapter surfaces that exist because Dapr offers more than a generic scheduler/store.
Why Dapr?
Dapr provides the durable infrastructure while your agent owns the intelligence:
Your code (agent + tools) ←→ Dapr sidecar (state, workflows, jobs)- Crash recovery — if the process dies mid-turn, Dapr resumes from the last checkpoint.
- Persistent state — execution history, sessions, and checkpoints survive restarts.
- Scheduled jobs — trigger agent work on a cron schedule via Dapr Jobs API.
- Zero vendor lock-in — Dapr runs anywhere: local Docker, Kubernetes, cloud.
Installation
pnpm add @cuylabs/agent-runtime-dapr @cuylabs/agent-core @dapr/daprFocused imports are also available when you want the package surface to mirror the internal modules:
import { createDaprExecutionObserver } from "@cuylabs/agent-runtime-dapr/execution";
import { createDaprAgentRunner } from "@cuylabs/agent-runtime-dapr/host";
import { DaprWorkflowClient } from "@cuylabs/agent-runtime-dapr/workflow";Under the hood, this package now exposes two layers:
createDaprWorkloadRuntime(...)for any workload that fits the neutralagent-runtimecontractcreateDaprAgentRuntime(...)andcreateDaprAgentRunner(...)as theagent-core-specific adapters built on top of that
The rule is:
- if your code only needs portable scheduling/orchestration, target
agent-runtime - if your code wants Dapr durability or Dapr host capabilities, opt into this package explicitly
Tool Hosts And Durable Turns
ToolHost configuration still belongs on the agent, not on the Dapr runner.
import { WorkflowRuntime } from "@dapr/dapr";
import { createAgent } from "@cuylabs/agent-core";
import { dockerHost } from "@cuylabs/agent-sandbox-docker";
import { createDaprAgentRunner } from "@cuylabs/agent-runtime-dapr";
const agent = createAgent({
model,
host: dockerHost({ image: "node:22", workspaceDir: "/workspace" }),
tools,
});
const runner = createDaprAgentRunner({
agent,
name: "my-agent",
workflowRuntime: new WorkflowRuntime(),
});In direct mode and durable mode, host-backed tools use the same agent-core
execution seam. Dapr persists workflow state around the tool call, but the
tool still executes through agent.getHost().
For the full explanation, see Tool Hosts In Durable Workflows.
Durable Context Compaction
Durable turns compact at the same model-step boundary as direct Agent.chat(),
but they do it through workflow state instead of the in-process chat loop.
Before a model-step activity runs, the workflow can call a
context-compaction activity. That activity applies the agent's normal
compaction policy to the serialized workflow messages. When compaction commits,
it persists the compaction entry to session storage, returns the compacted
message snapshots, and the workflow checkpoints context-compaction-finish
before continuing. An unchanged result remains an activity result without a
session entry or that observer checkpoint.
For descriptor-bearing turns, the default model-step activity also evaluates
the policy after it assembles current context fragments and system-prompt
overhead, before it calls the provider. It invokes the same idempotent compaction
operation with history plus the assembled context-fragment and system-prompt
overhead. If compaction commits, the activity returns a typed
context-compaction outcome; the workflow checkpoints it and re-enters the step
with the smaller history. Final request-budget validation then accounts for the
remaining request framing before a provider call.
The host passes the full agent.describeComposition() report when it creates
the descriptor. The workflow uses the pinned scheduling controls; workers
recompute both fingerprints and reject a mismatch before effects. Raw factory
options, execution keys, executable functions, provider objects, clients, and
credentials remain outside workflow state. Lower-level callers that omit the
composition report get an explicit empty composition snapshot. Schema-v1
compaction-only descriptors and descriptor-free histories remain on their
legacy paths and keep their prior activity payload shape.
If a model-step reports context overflow from the local request-budget check
or the provider, the workflow calls the same operation with forced recovery,
emits a context-compaction event with phase: "recovery" and its
summary-source diagnostics, checkpoints the result, and retries the model step
directly. It skips the otherwise redundant pre-step compaction check at that
immediate retry boundary.
Overflow outcomes distinguish a local assembled request-budget rejection from a
provider rejection through failure.source. Both enter bounded recovery; a
local rejection occurs before the provider is invoked.
Descriptor-bearing turns also call the activity at the post-turn boundary so
the pinned trigger policy can request final cleanup. An unchanged policy result
is still recorded as a Dapr activity result, but it creates no session
compaction entry and no context-compaction-finish observer checkpoint.
This keeps the durable path crash-safe without importing direct-loop internals:
direct execution uses ChatModelStepSnapshot; Dapr durable execution uses
AgentWorkflowTurnState.messages and AgentWorkflowModelStepPlan.
Capability providers in durable activities
The workflow host resolves capability providers in worker activity code, never in replaying orchestrator code. One activity resolution supplies the effective tools, MCP tools, context, and middleware for every operation nested in that activity. Prompt construction and compaction therefore use the same effective middleware as the model step that requested them.
The worker releases that resolution in finally before the activity returns.
This is an activity-scoped resource lifetime: turn-lifecycle(start), model,
tool, standalone compaction, and turn-lifecycle(end) may run on different
workers and receive separate provider instances. Data that must cross those
boundaries must live in workflow/session state or an external service rather
than an in-memory middleware closure. Provider effects must tolerate activity
retry and use stable session, turn, and operation identities where needed.
All activities in one workflow turn resolve providers from the original,
persisted turn input. Steering messages update model context without silently
selecting a different tool or middleware set halfway through that turn.
Quick Start
Step 1: Define your agent
import { createAgent, Tool } from "@cuylabs/agent-core";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const greetTool = Tool.define("greet", {
description: "Greet someone by name.",
parameters: z.object({ name: z.string() }),
execute: async ({ name }) => ({
title: `Greeted ${name}`,
output: `Hello, ${name}!`,
metadata: {},
}),
});
const agent = createAgent({
model: openai("gpt-4o"),
cwd: process.cwd(),
systemPrompt: "You are a helpful assistant. Use the greet tool when asked.",
maxSteps: 10,
tools: [greetTool],
});Step 2: Create the runner
import { WorkflowRuntime } from "@dapr/dapr";
import { createDaprAgentRunner } from "@cuylabs/agent-runtime-dapr";
const runner = createDaprAgentRunner({
agent,
name: "my-agent",
workflowRuntime: new WorkflowRuntime(),
});
await runner.serve({ port: 3000 });Step 3: Start with Dapr
dapr run --app-id my-agent --dapr-grpc-port 50001 -- npx tsx my-agent.tsStep 4: Send requests
# Direct execution (synchronous)
curl -s http://localhost:3000/agents/run \
-H "Content-Type: application/json" \
-d '{"message": "Greet Carlos"}' | jq
# Durable run (async, crash-recoverable)
curl -s http://localhost:3000/agents/run-durable \
-H "Content-Type: application/json" \
-d '{"message": "Greet Carlos"}' | jqThat's it. The runner handles workflow registration, HTTP server, runtime wiring, logging, and graceful shutdown.
Multi-Agent Host
Run multiple agents in a single process with createDaprMultiAgentRunner():
import { createDaprMultiAgentRunner } from "@cuylabs/agent-runtime-dapr";
const runner = createDaprMultiAgentRunner({
agents: [
{ agent: greeter, name: "greeter" },
{ agent: calculator, name: "calculator" },
],
workflowRuntime: new WorkflowRuntime(),
});
await runner.serve({ port: 3000 });Each agent gets its own workflow definition, execution store, and logging — but they share a single Dapr sidecar, workflow worker, and HTTP port.
# Target a specific agent by URL path
curl -s http://localhost:3000/agents/greeter/run \
-H "Content-Type: application/json" \
-d '{"message": "Say hi to Alice"}' | jqTwo Execution Modes
Every agent host exposes two ways to run a turn:
| Mode | Endpoint | Behavior |
| ----------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Direct | POST /agents/run | Synchronous. Returns result in the HTTP response. State is persisted, but execution is not crash-recoverable. |
| Durable | POST /agents/run-durable | Asynchronous. Returns 202 with an instanceId immediately. The turn runs as a Dapr workflow — crash-safe with activity-level checkpoints. |
The workflow follows a core phase sequence plus optional lifecycle, control, compaction, and tool-policy activities:
input-commit
→ turn-lifecycle(start)
→ steer-drain? → context-compaction? → model-step
→ human-input-check? → approval-check? → tool-call → step-commit
→ ... repeat model/tool steps ...
→ output-commit → context-compaction(post-turn)? → turn-lifecycle(end)input-commit is a planner phase dispatched through the same step-commit
activity handler used by later step commits; it is not a separately registered
activity.
Dapr records each completed activity result. If the process crashes after
tool-call, replay resumes from that boundary without repeating the model call
or tool execution. The default model-step can also return a typed compaction
outcome after assembling request context; the workflow checkpoints the compacted
state and re-enters the step before calling the provider.
Team Coordination
createDaprTeamRunner() applies the same split to multi-agent coordination:
Vocabulary:
run()= direct, in-process coordinator executionrunDurable()= start the durable root coordinator workflow- child workflow = one durable member task execution started by the root
waitForDurableRun()= external polling helper for the root workflow
run(prompt)keeps the coordinator loop in-process while using Dapr-backed stores.runDurable(prompt, options?)starts a durable coordinator workflow and returns{ teamId, workflowName, coordinatorSessionId, instanceId }.getDurableRun(instanceId)reads workflow status and extracts the final coordinator result when present.waitForDurableRun(instanceId, options?)is the explicit edge-level wait helper when you want to block for completion.
The HTTP surface mirrors that programmatic contract:
POST /team/runPOST /team/run-durableGET /team/workflows/:instanceId
HTTP API Reference
| Method | Path | Description |
| ------ | ----------------------------------------------- | --------------------------------------------------------------- |
| GET | /health | Liveness check |
| GET | /healthz | Liveness alias |
| GET | /ready | Readiness check |
| GET | /readyz | Readiness alias |
| GET | /agents | List registered agents |
| POST | /agents/run | Run agent turn (direct) |
| POST | /agents/run-durable | Run agent turn (durable) |
| POST | /agents/:id/run | Run specific agent (direct) |
| POST | /agents/:id/run-durable | Run specific agent (durable) |
| GET | /agents/inputs | List durable human input requests for the single hosted agent |
| GET | /agents/inputs/:requestId | Get durable human input request for the single hosted agent |
| POST | /agents/inputs/:requestId/respond | Resolve durable human input request for the single hosted agent |
| GET | /agents/approvals | List durable approval requests for the single hosted agent |
| GET | /agents/approvals/:requestId | Get durable approval request for the single hosted agent |
| POST | /agents/approvals/:requestId/respond | Resolve durable approval for the single hosted agent |
| GET | /agents/:id/inputs | List durable human input requests |
| GET | /agents/:id/inputs/:requestId | Get durable human input request |
| POST | /agents/:id/inputs/:requestId/respond | Resolve durable human input request |
| GET | /agents/:id/approvals | List durable approval requests |
| GET | /agents/:id/approvals/:requestId | Get durable approval request |
| POST | /agents/:id/approvals/:requestId/respond | Resolve durable approval with allow, deny, or remember |
| GET | /agents/:id/executions/:sessionId | Get execution details |
| GET | /agents/:id/executions/:sessionId/checkpoints | Get execution checkpoints |
| GET | /agents/:id/workflows/:instanceId | Get workflow state |
| POST | /agents/:id/workflows/:instanceId/terminate | Terminate a running workflow |
| POST | /agents/steer | Inject steering message (single-agent host) |
| POST | /agents/:id/steer | Inject steering message into running workflow |
| POST | /agents/follow-up | Queue follow-up message (single-agent host) |
| POST | /agents/:id/follow-up | Queue follow-up for after current turn |
| GET | /agents/follow-ups | List follow-up requests (single-agent host) |
| GET | /agents/:id/follow-ups | List follow-up requests |
| GET | /agents/:id/events/:sessionId | SSE stream of agent events |
| GET | /dapr/subscribe | Dapr pub/sub subscription declaration |
| POST | /dapr/:topic | Dapr pub/sub event delivery callback |
| POST | /job/:name | Handle Dapr scheduled job trigger |
Runner Options
createDaprAgentRunner() accepts these options:
| Option | Required | Default | Description |
| ------------------- | -------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------- |
| agent | Yes | — | The Agent instance |
| name | No | agent.name | Agent ID in the Dapr ecosystem |
| workflowRuntime | Yes | — | new WorkflowRuntime() from @dapr/dapr |
| daprHttpEndpoint | No | http://$DAPR_HOST:$DAPR_HTTP_PORT | Sidecar HTTP endpoint |
| stateStoreName | No | "statestore" | Dapr state store component |
| workflowComponent | No | "dapr" | Dapr workflow component name |
| driverOptions | No | — | Advanced Dapr runtime driver options: API token, retries, timeouts, custom fetch, sidecar verification |
| observers | No | [] | Extra execution lifecycle observers |
| logging | No | true | Enable/disable console logging |
| logPrefix | No | [${name}] | Log line prefix |
| aliases | No | [] | Alternative names for agent lookup |
The runner returns an object with:
start()— start runtime and workflow workercreateHttpHandler(options?)— build the Dapr host HTTP handler for embedding in a custom serveragentServerCapabilities()— capabilities patch describing the Dapr-backed runtimeserve(options?)— start HTTP server, block on SIGINT/SIGTERMrun(message, options?)— run a task programmaticallyrunDurable(message, options?)— start a durable turn programmaticallystop()— graceful shutdown
serve(options?) also accepts lightweight UI-hosting options:
staticDir— serve static files before the built-in agent routesindexFile— file served for/whenstaticDiris configuredextraRoutes— exact-match custom routes layered ahead of static assets and agent APIs
Runner startup is transactional: if the workflow worker fails to start, the runtime is stopped before the error is returned.
Retention and Cleanup
Durability needs explicit retention. The package exposes cleanup methods on both durable stores so hosts can trim history without reaching into raw Dapr state.
await runtimeBundle.executionStore.cleanup({
maxAgeMs: 7 * 24 * 60 * 60 * 1000,
maxCheckpointsPerExecution: 20,
});
await runStore.cleanup({
maxAgeMs: 30 * 24 * 60 * 60 * 1000,
maxRuns: 10_000,
});The intended production pattern is to schedule cleanup as ordinary runtime work rather than relying on ad hoc scripts.
Going Deeper
For advanced use cases (custom workflow shapes, custom HTTP handlers, cross-service invocation), the package also exports the lower-level building blocks:
| Helper | Purpose |
| --------------------------------------- | -------------------------------------------------------------------- |
| createDaprAgentWorkflowHost() | Wrap an Agent into a workflow host with retry-safe manual compaction |
| createDaprAgentServerAdapter() | Bridge @cuylabs/agent-server to the Dapr workflow runtime |
| createDaprWorkflowWorker() | Register workflow hosts in a WorkflowRuntime |
| createDaprWorkloadRuntime() | Dapr-backed runtime bundle for generic workloads |
| createDaprAgentRuntime() | Create runtime bundle (scheduling + runner + store) |
| startDaprHostHttpServer() | Start the HTTP control surface |
| DaprWorkflowClient | Manage workflow instances via HTTP API |
| DaprRuntimeDriver | Low-level RuntimeDriver for Dapr state |
| DaprExecutionStore | Persistent execution snapshots and checkpoints |
| createDaprExecutionObserver() | Persist execution events to the store |
| createDaprLoggingObserver() | Console logging for execution lifecycle |
| DaprServiceInvoker | Call agents across Dapr service boundaries |
| invokeRemoteAgentRun() | Convenience wrapper for cross-service agent calls |
| createRemoteAgentTool() | Create a tool that invokes a remote Dapr agent |
| createDaprWorkflowApprovalRuntime() | Durable approval runtime |
| createDaprWorkflowHumanInputRuntime() | Durable human-input runtime |
| createDaprWorkflowSteerRuntime() | Durable steering runtime |
| createDaprWorkflowFollowUpRuntime() | Durable follow-up runtime |
| createDaprHostHttpHandler() | Build Request → Response handler for custom servers |
| createEventBus() | In-process event bus for SSE streaming |
| createDaprPubSubEventBridge() | Multi-instance event fan-out via Dapr pub/sub |
| createDaprDispatchRuntime() | Dapr-backed async dispatch runtime |
| createDaprTeamRunner() | Multi-agent team runner with durable coordination |
See the docs/ folder for detailed guides:
- Architecture — how the three packages compose
- Workflow Internals — the 5-activity decomposition
- Durable Tool Approvals — how approval middleware pauses and resumes Dapr workflows
- Durable Human Input — how the built-in
questiontool pauses and resumes Dapr workflows - API Reference — all exported types and functions, including event streaming
- Advanced Patterns — cross-service invocation, custom observers, etc.
Runtime Boundary
The package layering is:
agent-core: agent turn/task semantics, EventBus interface, AgentSignalagent-runtime: generic workload orchestration contractagent-runtime-dapr: Dapr-backed implementation of that contract, plusDaprPubSubEventBridgefor multi-instance event fan-out
agent-runtime-dapr integrates with those lower layers in two different ways:
- outer workload path: it uses
agent-runtimeto schedule, dispatch, retry, and observe jobs - inner durable turn path: it uses
agent-coreexecution primitives to split one agent turn into durable workflow activities such asmodel-step,tool-call,step-commit, andoutput-commit
So this package does not only sit "on top of" agent-runtime. It also reaches
into the reusable turn/task surface exported by agent-core when it needs
fine-grained durable execution.
If you are running ordinary jobs or non-agent workloads, use
createDaprWorkloadRuntime(...).
If you are running agent-core tasks, use createDaprAgentRuntime(...) or the
higher-level createDaprAgentRunner(...).
Examples
The examples/ directory has complete, runnable scripts:
| Script | Description |
| ----------------------------------------------------------- | ------------------------------------------- |
| 01-simple-agent.ts | Minimal agent with one tool |
| 02-coding-agent.ts | File-system tools via @cuylabs/agent-code |
| 03-multi-agent.ts | Two agents in one process |
| 04-crash-recovery.ts | Process crash mid-turn, Dapr auto-resumes |
| 05-tracing-zipkin.ts | OpenTelemetry tracing → Zipkin |
| 06-tracing-phoenix.ts | OpenTelemetry tracing → Arize Phoenix |
| 07-maintenance-host.ts | Retention jobs + Prometheus metrics |
See the examples README for step-by-step setup and usage.
Production Notes
DaprRuntimeDriververifies the sidecar is reachable on startup (verifySidecarOnStart, defaulttrue)- Per-request timeout: 15s default (
requestTimeoutMs) - Automatic retries for transient failures (
maxRequestRetries, default2) - Supports Dapr API token authentication (
dapr-api-tokenheader) GET /readyandGET /readyzreport runtime, worker, sidecar, and state-store readiness- Dapr Jobs API calls are isolated behind an internal adapter so scheduler changes stay local to the Dapr package
- Use
DaprExecutionStore.cleanup(...)andDaprOrchestratorRunStore.cleanup(...)to enforce retention budgets - For a concrete operational service, see
examples/07-maintenance-host.ts - For containers: run one sidecar per app process, point
daprHttpEndpointat the local sidecar
License
Apache-2.0
