Skip to main content

paigasus_helikon_runtime_temporal/
lib.rs

1//! Temporal-backed durable runtime for the Paigasus Helikon AI SDK.
2//!
3//! A [`Runner`](paigasus_helikon_core::Runner) implementation that makes agent runs durable through
4//! [Temporal](https://temporal.io). Each run becomes a workflow, each model invocation and tool call
5//! becomes an activity, and the agent loop is reconstructed via deterministic replay of the
6//! workflow's history. A crash mid-run resumes from the last completed activity, recovering events
7//! and run state automatically.
8//!
9//! # Quick start
10//!
11//! Start a local dev server:
12//!
13//! ```bash
14//! temporal server start-dev
15//! ```
16//!
17//! Start a worker that registers your agent and serves its activities on a
18//! task queue (`NullModel` stands in for any [`Model`](paigasus_helikon_core::Model)
19//! impl — e.g. an OpenAI/Anthropic provider crate's model):
20//!
21//! ```no_run
22//! # struct NullModel;
23//! # #[async_trait::async_trait]
24//! # impl paigasus_helikon_core::Model for NullModel {
25//! #     async fn invoke(
26//! #         &self,
27//! #         _request: paigasus_helikon_core::ModelRequest,
28//! #         _cancel: paigasus_helikon_core::CancellationToken,
29//! #     ) -> Result<
30//! #         futures_core::stream::BoxStream<
31//! #             'static,
32//! #             Result<paigasus_helikon_core::ModelEvent, paigasus_helikon_core::ModelError>,
33//! #         >,
34//! #         paigasus_helikon_core::ModelError,
35//! #     > {
36//! #         let events: Vec<
37//! #             Result<paigasus_helikon_core::ModelEvent, paigasus_helikon_core::ModelError>,
38//! #         > = vec![];
39//! #         Ok(Box::pin(futures_util::stream::iter(events)))
40//! #     }
41//! #     fn capabilities(&self) -> paigasus_helikon_core::ModelCapabilities {
42//! #         paigasus_helikon_core::ModelCapabilities::default()
43//! #     }
44//! # }
45//! use std::sync::Arc;
46//!
47//! use paigasus_helikon_core::LlmAgent;
48//! use paigasus_helikon_runtime_temporal::worker::TemporalAgentWorker;
49//! use temporalio_client::{Client, ClientOptions, Connection, ConnectionOptions};
50//!
51//! #[tokio::main]
52//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
53//!     // Connect to the Temporal server (`temporal server start-dev` locally).
54//!     let target = url::Url::parse("http://localhost:7233")?;
55//!     let connection = Connection::connect(ConnectionOptions::new(target).build()).await?;
56//!     let client = Client::new(connection, ClientOptions::new("default").build())?;
57//!
58//!     let agent = Arc::new(
59//!         LlmAgent::builder::<()>()
60//!             .name("assistant")
61//!             .model(NullModel)
62//!             .build(),
63//!     );
64//!
65//!     // Registration fails fast if the agent uses hooks, guardrails, or
66//!     // handoffs (unsupported in v0 — see below). Serves until shutdown.
67//!     TemporalAgentWorker::builder::<()>()
68//!         .task_queue("helikon-agents")
69//!         .client(client)
70//!         .with_ctx(|| ())
71//!         .register(agent)?
72//!         .build()?
73//!         .run()
74//!         .await?;
75//!     Ok(())
76//! }
77//! ```
78//!
79//! Then, from any client process, run the agent durably through
80//! [`runner::TemporalRunner`] — the run executes on the worker serving the
81//! task queue; client-side, the runner needs the agent definition only for
82//! its name and session semantics:
83//!
84//! ```no_run
85//! # struct NullModel;
86//! # #[async_trait::async_trait]
87//! # impl paigasus_helikon_core::Model for NullModel {
88//! #     async fn invoke(
89//! #         &self,
90//! #         _request: paigasus_helikon_core::ModelRequest,
91//! #         _cancel: paigasus_helikon_core::CancellationToken,
92//! #     ) -> Result<
93//! #         futures_core::stream::BoxStream<
94//! #             'static,
95//! #             Result<paigasus_helikon_core::ModelEvent, paigasus_helikon_core::ModelError>,
96//! #         >,
97//! #         paigasus_helikon_core::ModelError,
98//! #     > {
99//! #         let events: Vec<
100//! #             Result<paigasus_helikon_core::ModelEvent, paigasus_helikon_core::ModelError>,
101//! #         > = vec![];
102//! #         Ok(Box::pin(futures_util::stream::iter(events)))
103//! #     }
104//! #     fn capabilities(&self) -> paigasus_helikon_core::ModelCapabilities {
105//! #         paigasus_helikon_core::ModelCapabilities::default()
106//! #     }
107//! # }
108//! use paigasus_helikon_core::{AgentInput, LlmAgent, RunConfig, RunContext, Runner};
109//! use paigasus_helikon_runtime_temporal::runner::{TemporalRunner, TemporalRunnerConfig};
110//! use temporalio_client::{Client, ClientOptions, Connection, ConnectionOptions};
111//!
112//! #[tokio::main]
113//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
114//!     let target = url::Url::parse("http://localhost:7233")?;
115//!     let connection = Connection::connect(ConnectionOptions::new(target).build()).await?;
116//!     let client = Client::new(connection, ClientOptions::new("default").build())?;
117//!
118//!     let agent = LlmAgent::builder::<()>()
119//!         .name("assistant")
120//!         .model(NullModel)
121//!         .build();
122//!
123//!     let runner = TemporalRunner::new(client, TemporalRunnerConfig::new("helikon-agents"));
124//!     let result = runner
125//!         .run(
126//!             &agent,
127//!             RunContext::ephemeral(()),
128//!             AgentInput::from_user_text("Hello!"),
129//!             RunConfig::default(),
130//!         )
131//!         .await?;
132//!     println!("{}", result.final_output);
133//!     Ok(())
134//! }
135//! ```
136//!
137//! # v0 Constraint Set
138//!
139//! The durable Temporal runtime v0 explicitly does not support:
140//!
141//! - **Hooks** (`on_turn_started`, `on_model_response`, etc.) — arbitrary async code cannot be
142//!   made deterministic inside the workflow. Running hooks in activities is deferred past v0.
143//! - **Handoffs** (`NextAction::Handoff`) — the agent loop cannot hand off to another system while
144//!   remaining durable. Handoffs are rejected at registration time with a descriptive error.
145//! - **Guardrails** (input/output validation) — running guardrail logic inside the deterministic
146//!   workflow is not yet supported. Guardrails are rejected at registration time.
147//! - **Nested agents** (agent-as-tool) — while the tool call executes and completes, the nested
148//!   agent's run is opaque to Temporal's durability (not itself durable). The nested run may
149//!   complete but the outer run sees it as a black-box activity result, not as a durable workflow.
150//!
151//! The first three — hooks, handoffs, and (input/output) guardrails — are detected during agent
152//! registration (via [`worker::TemporalAgentWorkerBuilder::register`]) and rejected with a
153//! descriptive [`worker::RegistrationError`], failing fast rather than silently skipping
154//! unsupported features. Nested agent-as-tool runs are **permitted** — registration succeeds —
155//! but the nested run executes non-durably inside its tool activity: a crash-retry of that
156//! activity re-executes the entire nested run from scratch.
157//!
158//! # Worker-Side Posture and Security Boundary
159//!
160//! The agent's [`RunContext`](paigasus_helikon_core::RunContext) (`Ctx` generic type) is not
161//! serializable and does not cross the client→worker boundary. For every tool-call activity, the
162//! **worker fabricates a fresh `RunContext::ephemeral(ctx).with_cancel(...)`** from its configured
163//! context factory, then applies its configured [`worker::WorkerPosture`].
164//!
165//! ## Configuring the posture
166//!
167//! [`worker::WorkerPosture`] groups the nine posture knobs
168//! [`RunContext`](paigasus_helikon_core::RunContext) already exposes — permission mode,
169//! deny/allow/guard rules, a permission policy, an approval handler, the built-in destructive
170//! guards, output redaction, and `extra_secrets` — into one value, set via
171//! [`worker::TemporalAgentWorkerBuilder::posture`]. **`WorkerPosture::default()` reproduces the
172//! v0 fixed defaults exactly**: [`PermissionMode::Default`](paigasus_helikon_core::PermissionMode)
173//! with no `permission_policy` installed (a destructive guard's `Ask` therefore degrades to
174//! `Deny` unless an `approval_handler` is also configured), the always-on built-in destructive
175//! guards (blocking `rm -rf /`/`~`, writes under protected system paths, and writes touching
176//! `.git`/`.ssh`/`.env*`), `redact_output = true` (secrets sourced from the worker process's own
177//! environment plus an empty `extra_secrets` list), and no deny/allow/guard rules. **A worker
178//! that never calls `.posture(...)` behaves byte-for-byte as before.** Loosen or extend that
179//! baseline via `with_permission_mode`, `with_deny_rules`, `with_allow_rules`, `with_guard_rules`,
180//! `with_permission_policy`, `with_approval_handler`, `without_default_guards`,
181//! `without_output_redaction`, and `with_extra_secrets`.
182//!
183//! **None of the caller's own `RunContext` configuration propagates to the worker.** The
184//! following caller-side posture fields never cross the client→worker boundary, regardless of the
185//! worker's posture configuration: deny/allow/guard rules, `default_guards`, `redact_output`, the
186//! permission mode and policy, the approval handler, and `extra_secrets`. The worker's configured
187//! posture is authoritative for every tool
188//! call executed in the activity — a client cannot loosen it by configuring its own `RunContext`
189//! differently.
190//!
191//! ## The request-scoped `Ctx` seed
192//!
193//! A client may attach an explicit, opt-in seed — [`runner::TemporalRunnerConfig::with_ctx_seed`]
194//! — that crosses the client→worker boundary as a plain `serde_json::Value`, recorded in
195//! [`payloads::WorkflowInput::ctx_seed`]. On the worker side,
196//! [`worker::TemporalAgentWorkerBuilder::with_seeded_ctx`] /
197//! [`worker::TemporalAgentWorkerBuilder::try_with_seeded_ctx`] reconstitute the per-run `Ctx` from
198//! that seed (a worker that only calls `with_ctx` ignores any seed entirely). This is the
199//! mechanism for handing request-scoped caller data — tenant id, user id, auth subject — to the
200//! worker, **without** ever serializing posture itself: posture stays worker-static (above); the
201//! seed carries data, not policy.
202//!
203//! **Fail-fast contract.** The seeded factory slot is fallible internally: a
204//! [`worker::TemporalAgentWorkerBuilder::try_with_seeded_ctx`] factory that rejects a seed maps to
205//! a **non-retryable** activity failure, so a malformed or hostile seed fails the run immediately
206//! rather than retry-looping forever (`render_instructions` carries no retry policy of its own) or
207//! silently falling back to a default identity under the wrong caller. **Prefer
208//! `try_with_seeded_ctx` over the infallible `with_seeded_ctx` whenever the seed drives
209//! authorization** — `with_seeded_ctx`'s totality contract requires its closure never panic,
210//! which is the wrong tool for rejecting a bad seed.
211//!
212//! **The seed is recorded in Temporal history.** Keep it small (ids, claims) — never bulk data,
213//! and never put secrets in it. History is a permanent persistence boundary, same as tool output
214//! (below).
215//!
216//! ## Per-run authorization without a serializable policy
217//!
218//! A worker-registered [`PermissionPolicy`](paigasus_helikon_core::PermissionPolicy) is a trait
219//! object and stays worker-static — it is never serialized. Because the worker rebuilds `Ctx`
220//! from the per-run seed before applying posture, the policy's `check(ctx, tool, args)` can read
221//! `ctx.user_ctx()` (the seeded value) and make **request-scoped** decisions — e.g. "tenant `acme`
222//! may run `Bash`, others may not" — giving per-run authorization from a static policy plus a
223//! dynamic seed, with no policy ever crossing the wire.
224//!
225//! **The policy is only reachable in some modes.**
226//! [`PermissionMode::Bypass`](paigasus_helikon_core::PermissionMode) allows every call before the
227//! policy runs, and [`PermissionMode::DontAsk`](paigasus_helikon_core::PermissionMode) denies
228//! every call the same way (both short-circuit); a seed-driven per-tenant policy is therefore only
229//! consulted under `Default`, `AcceptEdits`, or `Plan`. A worker that combines `Bypass`/`DontAsk`
230//! posture with a permission policy makes that policy dead code for tool calls — by design, but
231//! easy to combine by accident.
232//!
233//! **Temporal history is a persistence boundary.** Tool outputs are redacted *before* they are
234//! recorded in the workflow history (Temporal's durable storage), when `redact_output = true`
235//! (the default). A worker that calls `without_output_redaction()` writes **unredacted** tool
236//! output into permanent history — a posture choice made loudly, not a default.
237//!
238//! # Retry Semantics and Tool Idempotency
239//!
240//! ## Model errors
241//!
242//! Per [ADR-10](https://smk1085.github.io/paigasus-helikon/contributing/adrs.html), the runtime
243//! never retries model errors (`ModelError` variants). Model failures are **non-retryable** at the
244//! activity level and cause the run to fail with [`RunError::Agent(...)`](paigasus_helikon_core::RunError)
245//! carrying the provider's error message as a plain string. This differs from `TokioRunner`: the
246//! typed `AgentError::Model` variant is **not** reconstructed across the durable boundary — the
247//! activity failure payload carries only the message, not the original `ModelError`'s structure.
248//!
249//! If your application needs model retries, wrap your model in `RetryingModel` (from
250//! [`paigasus_helikon_runtime_tokio`](https://docs.rs/paigasus-helikon-runtime-tokio)) before
251//! registering it with the worker. That's the sanctioned retry path — retries are an
252//! application-layer concern, not a runtime concern.
253//!
254//! ## Tool errors
255//!
256//! Tool-level errors never fail the activity: they return as part of the
257//! [`ToolCallOutcome`](paigasus_helikon_core::ToolCallOutcome) and are fed back to the model as
258//! data (the model can see what went wrong and adapt). Only infrastructure-level panics fail the
259//! activity attempt.
260//!
261//! ## Crash-retry vs error-retry
262//!
263//! The `model_retry_policy` and `tool_retry_policy` on the worker builder control **crash
264//! recovery**, not application-level retries:
265//!
266//! - **Crash recovery**: If the activity worker crashes or times out mid-execution, Temporal
267//!   retries the activity on a live worker. The workflow replays its history deterministically,
268//!   returns the recorded result for activities that completed, and retries only the in-flight
269//!   activity. This is the crash-resume acceptance criterion.
270//! - **Application-level errors**: These are marked non-retryable at the activity level (per
271//!   ADR-10 for models, or returned as outcomes for tools) and do not trigger retries.
272//!
273//! **Tool idempotency under crash-retry:** Because a crashed tool activity retries on a live
274//! worker, **tool authors must implement idempotency** if a tool call cannot safely run twice.
275//! Set `max_attempts: 1` on the tool's retry policy to opt out of crash-recovery (the run fails
276//! instead of resuming), accepting the trade-off: the run is no longer durable against crashes
277//! mid-tool-call.
278//!
279//! # Heartbeats
280//!
281//! [`worker::TemporalAgentWorkerBuilder::heartbeat_interval`] is opt-in (off by default,
282//! preserving v0 behavior byte-for-byte when unset). When set, the worker's `call_model` and
283//! `invoke_tool` activities emit a liveness `record_heartbeat` on that interval (floored to 1s),
284//! and the workflow sets `heartbeat_timeout = 2 × interval` on those two activities'
285//! `ActivityOptions` (`render_instructions` never gets one — it is fast and does no network I/O).
286//! This **speeds reclamation of a crashed worker's in-flight attempt** well below the activity's
287//! full `start_to_close` bound.
288//!
289//! **Honest caveat — a starved live worker can trip the same timeout.** A `heartbeat_timeout`
290//! fires whenever the ticker stops polling, which happens both when the worker crashes (the
291//! intended win) *and* when a live worker's async executor is starved by a blocking/CPU-bound
292//! tool `invoke` (e.g. a synchronous `std::process::Command`, or heavy compute with no `.await`).
293//! In that case Temporal re-dispatches the tool call to another worker, narrowing the double-run
294//! window from the activity's full `start_to_close` timeout (default 300s) down to roughly
295//! `2 × interval`. **Recommendation: offload blocking or CPU-bound tool work via
296//! `tokio::task::spawn_blocking`** so the executor keeps polling the heartbeat ticker. This
297//! interacts directly with the **tool idempotency under crash-retry** warning above — enabling
298//! heartbeats is a latency/reclamation *tuning* choice with that trade-off, not a free win.
299//!
300//! # Payload Budget and Conversation Size
301//!
302//! The durable driver builds every `ModelRequest` from the **full conversation** — including all
303//! history since the run started. This means each `call_model` activity payload includes the
304//! entire conversation-so-far, causing payload size to grow quadratically with the number of turns.
305//! Temporal's default gRPC payload limit is ~2–4 MB, with server-side warnings around 512 KB.
306//!
307//! **Practical budget for v0:** A single activity payload should stay under **~1.5 MB of JSON**.
308//! This typically allows **15–20 turns** with tool outputs averaging **≤50 KB each**. A single
309//! tool result larger than ~1.5 MB will fail its activity outright.
310//!
311//! **Strategies to fit within the budget:**
312//! - Bound tool output size via the tool implementation (e.g., truncate summaries, paginate results).
313//! - Limit `max_turns` in [`RunConfig`](paigasus_helikon_core::RunConfig) to stay within the turn budget.
314//! - Monitor your actual payload sizes in Temporal's Web UI to verify your use case fits.
315//!
316//! **The `Ctx` seed adds to this budget too.** When a client sets
317//! [`runner::TemporalRunnerConfig::with_ctx_seed`], that seed is serialized into the activity
318//! input on **every** `render_instructions` and `invoke_tool` call (once per turn and once per
319//! tool call, not once per run) — keep it small (ids, claims), not bulk data.
320//!
321//! Payload codecs (custom serialization), claim-check blob offloading, and conversation
322//! compaction are named follow-up work, not silent gaps.
323//!
324//! # Upgrade Discipline and Determinism
325//!
326//! The workflow's deterministic core is [`paigasus_helikon_core::loop_state::transition`], which
327//! lives in a separately versioned crate. Replaying an in-flight workflow against a worker with a
328//! **different version of `paigasus-helikon-core` or `paigasus-helikon-runtime-temporal`** can
329//! cause non-determinism errors (the workflow's replayed decisions don't match the new code's
330//! logic).
331//!
332//! **SMA-455 wire additions (additive, not an unqualified "replay-breaking" change).** The
333//! worker-posture, `Ctx`-seed, and heartbeat features added a `#[serde(default)] ctx_seed` field
334//! to [`payloads::WorkflowInput`] and an optional `heartbeat_timeout` to the model/tool
335//! `ActivityOptions`. Both are additive by construction: `#[serde(default)]` means a
336//! `WorkflowInput` serialized by an older worker still deserializes on a newer one (the field
337//! defaults to `None`), and the changed activity-input tuple only affects **newly-scheduled**
338//! `render_instructions`/`invoke_tool` activities — an activity that already completed replays
339//! its recorded result from history rather than re-executing against the new code. This is a
340//! reasoned, honest claim, not a guarantee proven against every upgrade path; **drain-before-
341//! upgrade / blue-green task queues (below) remain the conservative, recommended path** regardless
342//! of how additive a given change looks on paper.
343//!
344//! **Operational guidance for v0 (machinery deferred to a future release):**
345//!
346//! 1. **Drain in-flight runs before redeploying.** Agent runs are typically minutes-to-hours, not
347//!    months. Before deploying a new worker version with a bumped core/temporal crate:
348//!    - Wait for existing workflows to complete, or
349//!    - Use blue-green task queues: point the old worker to `"queue-v1"` and the runner to
350//!      `"queue-v2"`, run new workflows on v2 while old ones drain from v1, then decommission v1.
351//! 2. **Check the CHANGELOG.** Any release of this crate whose transition behavior changed is
352//!    flagged as replay-breaking.
353//! 3. **Production path:** [Temporal Worker Versioning (Build IDs)](https://docs.temporal.io/workers#worker-versioning)
354//!    is the long-term solution for zero-downtime updates; support is pending in the Rust SDK.
355//!
356//! # Links
357//!
358//! - [API reference (docs.rs)](https://docs.rs/paigasus-helikon-runtime-temporal)
359//! - [Guide & concepts](https://smk1085.github.io/paigasus-helikon/) — see [Runtimes](https://smk1085.github.io/paigasus-helikon/concepts/runtimes.html)
360//! - [Temporal documentation](https://docs.temporal.io)
361//! - [Source & issues](https://github.com/SMK1085/paigasus-helikon)
362
363/// Thin Temporal activity layer over the SDK-free driver-facing inner
364/// functions, plus the process-local per-agent registry a durable worker
365/// resolves by name (never serialized — see [`driver::AgentPlan`]'s docs on
366/// why). Private: every externally-relevant type it defines is re-exported
367/// or consumed through [`worker`].
368mod activities;
369/// The pure durable-loop step machine.
370///
371/// [`driver::DurableDriver`] wraps [`paigasus_helikon_core::transition`] with
372/// the bookkeeping (conversation, accumulated events, cumulative usage) a
373/// Temporal workflow needs to drive an agent run one activity result at a
374/// time, without any Temporal SDK dependency of its own.
375pub mod driver;
376/// Error types for the Temporal-backed durable runtime.
377pub mod error;
378/// Wire-format payload types exchanged between the Temporal workflow and its
379/// activities.
380pub mod payloads;
381/// The client-side [`paigasus_helikon_core::Runner`] implementation:
382/// [`runner::TemporalRunner`] starts the durable workflow, awaits its outcome
383/// (with cooperative cancellation), and mirrors `TokioRunner`'s session
384/// semantics at the run boundary.
385pub mod runner;
386/// Temporal worker construction: builds a [`worker::TemporalAgentWorker`]
387/// that serves one or more registered [`paigasus_helikon_core::LlmAgent`]s'
388/// activities on a task queue.
389pub mod worker;
390/// The durable agent-loop workflow driven by a
391/// [`crate::worker::TemporalAgentWorker`]. Internal: the public entry points
392/// are [`worker::TemporalAgentWorker`] (worker side) and
393/// [`runner::TemporalRunner`] (client side).
394mod workflow;