Introduction
paigasus-helikon is a Rust SDK for building agentic AI systems. It separates slow-moving primitives (types, traits, message protocols) from fast-moving parts (provider SDKs, execution runtimes, tool catalogs), so downstream projects can pick the surface they need without dragging in the rest.
The SDK does not pick a deployment story, a hosting story, or an observability stack for you. Bring your own.
What's here
The single-facade crate paigasus-helikon re-exports paigasus-helikon-core unconditionally and pulls in sibling crates behind kebab-case Cargo features. The shipped surface covers an end-to-end single-agent loop plus the first multi-agent pieces:
- Agent loop.
LlmAgent::builderassembles anAgent;agent.run(ctx, input)returns an event stream you collect withRunResultStreaming. Core carrier types (RunContext,AgentInput,HookRegistry,MemorySession,CancellationToken,TracerHandle) live inpaigasus_helikon::core. - Providers.
paigasus_helikon::openai::OpenAiModel(featureopenai) andpaigasus_helikon::anthropic::AnthropicModel(featureanthropic). Switching providers is a one-lineModelswap on the builder. - Tools. The
#[tool]attribute andtools!macro (featuremacros) turn anasync fninto a registered, JSON-Schema-typed tool. Thetoolsfeature adds sandboxed Read/Write/Edit/Bash tools;tools-webadds theWebFetch/WebSearchnetwork tools. - Sessions.
MemorySessionships incore;paigasus_helikon::sessions_sqlite(featuresessions-sqlite) is a persistent SQLiteSessionbackend. - Runtime.
paigasus_helikon::runtime_tokio(featureruntime-tokio) is the default ephemeral Tokio runner.paigasus_helikon::runtime_axum(featureruntime-axum) is the self-hosted HTTP/SSE/WebSocket agent server built on axum — see Axum Server Runtime.paigasus_helikon::runtime_temporal(featureruntime-temporal) is a durable runner backed by the Temporal Rust SDK, andpaigasus_helikon::runtime_agentcore(featureruntime-agentcore) is an AWS Bedrock AgentCore container shim — see Runtimes for both. - Multi-agent and MCP.
Handoff::to(..)wires triage-style delegation between agents;paigasus_helikon::mcp(featuremcp) is an rmcp-based MCP client/server wrapper. Guardrails, hooks, and permissions hang off theHookRegistrycarried in everyRunContext. - Evals.
paigasus_helikon::evals(featureevals) is an evaluation harness: JSONL datasets, anEvaluatortrait with four built-ins, aMockModelfor deterministic replay, and SQLite/Parquet trace sinks — see Observability & Evaluation.
Eighteen crates are published to crates.io with rustdoc on docs.rs.
A minimal run:
use paigasus_helikon::core::{ Agent, AgentInput, LlmAgent, RunContext, RunResultStreaming, }; use paigasus_helikon::openai::OpenAiModel; #[tokio::main] async fn main() -> anyhow::Result<()> { let model = OpenAiModel::chat("gpt-5-mini").build()?; let agent = LlmAgent::builder::<()>() .name("budget-assistant") .model(model) .instructions("You are a budgeting assistant. ...") .build(); let ctx: RunContext<()> = RunContext::ephemeral(()); let input = AgentInput::from_user_text("How am I doing on my dining budget this month?"); let stream = agent.run(ctx, input).await?; let result = RunResultStreaming::new(stream).collect().await?; println!("{}", result.final_output); Ok(()) }
Start at Quickstart, then read Core primitives for the trait surface.
Tracked work lives in Linear under the project Paigasus Helikon (issues are prefixed SMA-).
Quickstart
A complete, copy-pasteable agent: a personal-finance budgeting assistant that calls two tools to look up the user's spending and budget, then advises in plain text. It exercises the tool-calling loop end-to-end against OpenAI.
1. Add the dependency
[dependencies]
paigasus-helikon = { version = "0.3", features = ["openai", "macros"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
schemars = "1"
The openai feature pulls in paigasus_helikon::openai::OpenAiModel; the macros feature pulls in the #[tool] attribute and the tools! macro. paigasus_helikon::core is always available.
2. Set your API key
The OpenAI adapter reads the key from the environment.
export OPENAI_API_KEY=sk-...
3. Write main.rs
Each #[tool] function takes a &ToolContext<()> and a single Deserialize + JsonSchema argument struct, and returns Result<T, ToolError> where T is Serialize + JsonSchema. The doc comment on the function becomes the tool description the model sees; the doc comments on the argument fields become the JSON-Schema field descriptions.
use paigasus_helikon::core::{ Agent, AgentInput, LlmAgent, RunContext, RunResultStreaming, ToolContext, ToolError, }; use paigasus_helikon::openai::OpenAiModel; use paigasus_helikon::{tool, tools}; #[derive(serde::Deserialize, schemars::JsonSchema)] struct LookupSpendingArgs { /// Spending category, e.g. "Dining". category: String, /// Month in YYYY-MM form. month: String, } #[derive(serde::Serialize, schemars::JsonSchema)] struct LookupSpendingOut { /// Total spent in the category this month, in dollars. total: f64, /// Number of transactions. count: u32, } /// Look up the user's total spending and transaction count for a category in a month. #[tool] async fn lookup_spending( _ctx: &ToolContext<()>, args: LookupSpendingArgs, ) -> Result<LookupSpendingOut, ToolError> { // Read `month` so the field is not flagged unused; the canned ledger ignores it. let _ = args.month; let out = match args.category.to_lowercase().as_str() { "dining" => LookupSpendingOut { total: 312.40, count: 18, }, "groceries" => LookupSpendingOut { total: 540.10, count: 9, }, _ => LookupSpendingOut { total: 0.0, count: 0, }, }; Ok(out) } #[derive(serde::Deserialize, schemars::JsonSchema)] struct BudgetStatusArgs { /// Spending category, e.g. "Dining". category: String, } #[derive(serde::Serialize, schemars::JsonSchema)] struct BudgetStatusOut { /// Monthly budget for the category, in dollars. budget: f64, /// Amount spent so far, in dollars. spent: f64, /// Remaining budget (negative = over budget). remaining: f64, } /// Look up the user's monthly budget, amount spent, and remaining balance for a category. #[tool] async fn budget_status( _ctx: &ToolContext<()>, args: BudgetStatusArgs, ) -> Result<BudgetStatusOut, ToolError> { let out = match args.category.to_lowercase().as_str() { "dining" => BudgetStatusOut { budget: 250.0, spent: 312.40, remaining: -62.40, }, "groceries" => BudgetStatusOut { budget: 600.0, spent: 540.10, remaining: 59.90, }, _ => BudgetStatusOut { budget: 0.0, spent: 0.0, remaining: 0.0, }, }; Ok(out) } #[tokio::main] async fn main() -> anyhow::Result<()> { let model = OpenAiModel::chat("gpt-5-mini").build()?; // ← provider-specific line let agent = LlmAgent::builder::<()>() .name("budget-assistant") .model(model) .instructions( "You are a budgeting assistant. Use the tools to look up the user's spending \ and budget for the relevant category, then tell them whether they are on \ track and suggest one concrete action.", ) .tools(tools![lookup_spending, budget_status]) .build(); let ctx: RunContext<()> = RunContext::ephemeral(()); let input = AgentInput::from_user_text("How am I doing on my dining budget this month?"); let stream = agent.run(ctx, input).await?; let result = RunResultStreaming::new(stream).collect().await?; println!("{}", result.final_output); Ok(()) }
What each piece does:
OpenAiModel::chat("gpt-5-mini").build()?constructs the model adapter. The id is an example — swap it for any available model if the API rejects it.LlmAgent::builder::<()>()opens the agent builder; the()type parameter is the per-run context state (here, the unit type — no shared state)..name,.model,.instructions, and.tools(tools![...])configure it;.build()finalizes.RunContext::ephemeral(())is the one-liner for the all-defaults case: an in-memoryMemorySession, an emptyHookRegistry, a defaultTracerHandle, and a freshCancellationToken. Use consuming setters (.with_session(...),.with_hooks(...),.with_tracer(...),.with_cancel(...)) to override individual parts.AgentInput::from_user_text(...)builds the initial user turn.agent.run(ctx, input).await?returns an event stream;RunResultStreaming::new(stream).collect().await?drains it to a final result.result.final_outputis the agent's plain-text answer.
4. Run it
OPENAI_API_KEY=sk-... cargo run --features openai,macros
The same code ships as a runnable example in the workspace:
OPENAI_API_KEY=sk-... cargo run -p paigasus-helikon \
--features openai,macros --example budget_assistant_openai
Switching providers
The provider lives entirely in the one model-construction line. To run the identical agent against Anthropic, enable the anthropic feature and swap:
#![allow(unused)] fn main() { use paigasus_helikon::anthropic::AnthropicModel; let model = AnthropicModel::messages("claude-sonnet-4-6").build()?; }
Everything downstream — the #[tool] functions, the builder, RunContext, the stream collection — is unchanged.
Next steps
- The agent loop — how
rundrives model calls, tool dispatch, and turn assembly. - Tools — the
#[tool]attribute, thetools!macro, andToolContextin depth.
Workspace layout
Paigasus Helikon is a Cargo workspace of 19 crates under the paigasus-helikon-*
namespace. As a consumer you rarely depend on more than two of them directly: the
paigasus-helikon-core trait crate, or — more commonly — the paigasus-helikon facade,
which re-exports core plus the optional sibling crates behind Cargo features.
This page is about how to depend on the SDK. For the per-crate version and ownership table, see Crates reference.
The facade
paigasus-helikon re-exports paigasus-helikon-core unconditionally as
paigasus_helikon::core, and each optional sibling crate behind a kebab-case feature. With no
features you still get the full core surface: the traits (Agent, Model, Tool, Session)
plus the carrier and impl types (LlmAgent, RunContext, the event stream). Features add the
concrete adapters and runtimes on top.
Feature names are kebab-case in Cargo.toml; the re-export module aliases are snake-case.
| Feature | Re-exported as | Surface |
|---|---|---|
| (always on) | paigasus_helikon::core | paigasus-helikon-core — traits, agent loop, event stream, carrier types |
macros | paigasus_helikon::macros, plus paigasus_helikon::tool and paigasus_helikon::tools | #[tool] attribute + tools! macro |
openai (alias providers-openai) | paigasus_helikon::openai (also providers_openai) | OpenAiModel adapter |
anthropic | paigasus_helikon::anthropic | AnthropicModel adapter |
mcp | paigasus_helikon::mcp | rmcp-based MCP client/server |
tools | paigasus_helikon::tools | sandboxed Read/Write/Edit/Bash tools |
tools-web | (extends tools) | adds the WebFetch / WebSearch network tools |
runtime-tokio | paigasus_helikon::runtime_tokio | ephemeral Tokio runner |
runtime-axum | paigasus_helikon::runtime_axum | self-hosted HTTP/SSE/WebSocket agent server — see Axum Server Runtime |
runtime-temporal | paigasus_helikon::runtime_temporal | durable Temporal-backed runner — see Runtimes |
runtime-agentcore | paigasus_helikon::runtime_agentcore | AWS Bedrock AgentCore container shim — see Runtimes |
sessions-sqlite | paigasus_helikon::sessions_sqlite | SQLite Session backend |
evals | paigasus_helikon::evals | evaluation harness — datasets, evaluators, MockModel, SQLite/Parquet trace sinks |
In addition, paigasus_helikon::schema::strict re-exports the JSON-Schema strict-mode normalizer
from core, regardless of features.
tools name clash
paigasus_helikon::tools resolves to two different items when both macros and tools are
enabled: the tools! function-like macro (from macros) and the sandboxed-tools module
(from the tools crate). They live in separate namespaces, so Rust disambiguates by use site — a
macro invocation tools![...] versus a path tools::SomeTool. Be explicit about which you mean.
Published vs stub crates
Eighteen crates carry real implementations and are published on crates.io / docs.rs:
paigasus-helikon-core— the dependency root (traits, agent loop, carrier types)paigasus-helikon— the facadepaigasus-helikon-macros—#[tool]+tools!paigasus-helikon-providers-openaipaigasus-helikon-providers-anthropicpaigasus-helikon-providers-bedrockpaigasus-helikon-providers-geminipaigasus-helikon-sessions-sqlitepaigasus-helikon-sessions-postgrespaigasus-helikon-sessions-redispaigasus-helikon-runtime-tokiopaigasus-helikon-runtime-axum— self-hosted HTTP/SSE/WebSocket agent server (see Axum Server Runtime)paigasus-helikon-runtime-temporal— durable Temporal-backed runner (see Runtimes)paigasus-helikon-runtime-agentcore— AWS Bedrock AgentCore container shim (see Runtimes)paigasus-helikon-mcppaigasus-helikon-toolspaigasus-helikon-evals— evaluation harness: datasets, evaluators,MockModel, SQLite/Parquet trace sinkspaigasus-helikon-cli— published binary crate (helikon/paigasus-helikonbinaries); its lib target is internal and carries no stability guarantee, publishing only socargo install paigasus-helikon-cliresolves
Zero name-claim stubs remain in the workspace. The one crate that rounds out the
workspace's 19 without publishing is paigasus-helikon-sessions-testkit, an internal
Session conformance test harness (publish = false by design).
Picking your surface
Depend on core alone when you only need the trait definitions — for example a crate that
implements its own Model or Tool<Ctx> and doesn't pull in any provider:
[dependencies]
paigasus-helikon-core = "0.5"
Depend on the facade for everything else, selecting only the features you use. A typical single-agent app with OpenAI, the tool macros, and SQLite-backed sessions:
[dependencies]
paigasus-helikon = { version = "0.4", features = ["openai", "macros", "sessions-sqlite"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
schemars = "1"
That pulls paigasus-helikon-providers-openai, paigasus-helikon-macros, and
paigasus-helikon-sessions-sqlite transitively; everything else stays out of your dependency
graph. Imports then come from the facade's re-export modules:
#![allow(unused)] fn main() { use paigasus_helikon::core::{Agent, AgentInput, LlmAgent, MemorySession, RunContext}; use paigasus_helikon::openai::OpenAiModel; use paigasus_helikon::{tool, tools}; }
Swap providers by swapping one feature and one import: anthropic /
paigasus_helikon::anthropic::AnthropicModel instead of openai / OpenAiModel. Add tools
(or tools-web) for the sandboxed file/shell tools, mcp for MCP servers, and runtime-tokio
for the runner boundary.
Next steps
- Quickstart — a complete first agent.
- Core primitives — the seven traits
coredefines. - Model providers —
OpenAiModelandAnthropicModel. - Crates reference — per-crate versions and ownership.
Core Primitives
The seven object-safe traits — Model, Tool<Ctx>, Session, Guardrail<Ctx>, Hook<Ctx>, Agent<Ctx>, Runner<Ctx> — and the concrete carrier types they share. The trait surface lives in the paigasus-helikon-core crate, the dependency root every other crate builds on.
These seven were chosen as the minimum viable surface. Other primitives users may expect — Memory, KnowledgeBase, Toolset, Plugin — are either compositions of these seven (a "toolset" is just a function returning Vec<Arc<dyn Tool<Ctx>>>) or premature.
The seven traits
Each is object-safe by design, so applications hold heterogeneous registries behind Arc<dyn _>.
Model— the single canonical async interface to an LLM provider.async fn invoke(&self, request: ModelRequest, cancel: CancellationToken)returns aBoxStreamofModelEvents. Capability differences (streaming, tools, structured output, vision, …) are surfaced throughModelCapabilitiesrather than split into separate traits. One trait covers OpenAI Chat Completions, OpenAI Responses, Anthropic Messages, Bedrock Converse, and Gemini.Tool<Ctx>— a callable an agent can invoke. Reportsname(),description(), a JSONschema(), and aneffect()(ToolEffect::ReadOnly/Write/SideEffect, used by the permission layer).async fn invoke(&self, ctx: &ToolContext<Ctx>, args: Value)returns aToolOutput.Session— conversation persistence as an append-only event log, not a flat message list. Three methods:append,events(optionallysinceaSequenceId), andsnapshot(aConversationSnapshotprojection of the log). The event-log shape buys deterministic replay for evals, event-sourced durability, and an audit trail.Guardrail<Ctx>— an input/output safety check that runs in parallel with the agent.async fn checkreturns aGuardrailVerdict(PassorTripwire); a tripwire halts the run.Hook<Ctx>— a lifecycle interceptor.async fn on_event(&self, ctx, event: &HookEvent)returns aHookDecision(Allow,Deny,ReplaceInput,ReplaceOutput,InjectSystemMessage). Hooks are observation and side effects — distinct from permissions (authorization) and guardrails (content).Agent<Ctx>— one trait for both LLM-driven and workflow agents. Reportsname()/description();async fn run(&self, ctx: RunContext<Ctx>, input: AgentInput)returns aBoxStream<'static, AgentEvent>.Runner<Ctx>— the pluggable execution backend (the durability seam).run/run_streameddrive an agent to aRunResult/RunResultStreaming, withresume/resume_streameddefaulting on top. Object-safe: methods take&dyn Agent<Ctx>.
Auxiliary traits
Beyond the seven, core ships a few narrower traits that support them:
PermissionPolicy<Ctx>— acanUseTool-style authorizer returning aPermissionDecision(Allow/Deny/AskUser/Replace). Sits behind thePermissionModeenum and first-classDenyRules.ApprovalHandler— resolves aPermissionDecision::AskUserinto anApprovalOutcome. Non-generic (it needs noCtx).Instructions<Ctx>— renders the system prompt for a turn. Implemented forString,&'static str, and anyFn(&RunContext<Ctx>) -> String, so an agent's instructions can be static text or dynamic per-run.
Concrete carrier and implementation types
The traits ship with concrete types that carry data across their boundaries and a handful of ready-to-use implementations:
LlmAgent<Ctx, M, T>and its typestate builder — the LLM-drivenAgent. Construct viaLlmAgent::builder::<Ctx>(), then.name(...),.model(...),.instructions(...),.tools(...),.handoffs(...),.output_type::<T>(), and.build().MemorySession— an in-memorySessionbacked by aMutex<Vec<SessionEvent>>, for tests and ephemeral runs. (For persistent storage, seepaigasus-helikon-sessions-sqlite.)- Workflow agents —
SequentialAgent(compose with.then(...)/.then_keyed(...)),ParallelAgent, andLoopAgent, each implementingAgent<Ctx>so they nest like any other agent. RunContext<Ctx>— the per-run state shared across loop, tools, guardrails, and hooks (user context,Sessionhandle,HookRegistry,TracerHandle,CancellationToken). Build withRunContext::new(user_ctx, session, hooks, tracer, cancel)(all five arguments explicit) or useRunContext::ephemeral(user_ctx)as the shorthand for the all-defaults case (in-memory session, empty hooks, default tracer, fresh cancel token); individual parts can then be overridden with the consuming setters.with_session(...),.with_hooks(...),.with_tracer(...),.with_cancel(...).RunConfigcarries per-run knobs (max_turns,timeout,parallel_tool_call_limit,max_agent_depth).RunResult<T>(and its streaming counterpartRunResultStreaming) aggregate the run'sfinal_output,events, andTokenUsage.- Event and item carriers —
AgentEventis the unified stream variant emitted by everyAgent(lifecycle, rawTokenDelta/ReasoningDelta/ToolCallDelta, semanticMessageOutput/ToolCallItem/ToolOutputItem,HandoffItem, control signals, and terminalRunCompleted/RunFailed).Item/ContentPartare the conversation-message carriers;ModelEvent,ModelRequest, andToolDefcross the model boundary;SessionEventis the log entry aSessionpersists.
Canonical reference
The workspace is published on crates.io, and rustdoc HTML is live on docs.rs at https://docs.rs/paigasus-helikon-core. Each trait carries a worked rustdoc example; the published rustdoc is the canonical reference for exact signatures and carrier-type fields.
Agent Loop & State Machine
The agent loop is an explicit enum LoopState, driven by a pure transition function and surfaced as a typed AgentEvent stream. The state machine is data: transition is async-free, allocation-light, and never touches IO. An async driver runs the side effects between transitions. This is what makes the loop resumable — a durable runner can persist the state plus the conversation and rehydrate at any transition boundary.
All of this lives in paigasus-helikon-core.
The state machine
LoopState has one variant per high-level phase:
CallingModel { turn, usage }— about to invoke the model forturn.ExecutingTools { calls, turn, usage }— the model requested tool calls; run them concurrently.ApplyingHandoff { target, transcript, usage }— delegate to another agent, threading the transcript.Finalizing { turn, usage }— a constrained turn that asks the model to emit the configuredoutput_type.RepairingOutput { turn, usage }— the one allowed repair turn after a failed structured-output validation.CompactingandNeedsApproval { pending }— reserved phases, not yet driven.Done(FinalOutput)— terminal success.Failed(AgentError)— terminal failure.
transition is the whole machine:
#![allow(unused)] fn main() { pub fn transition( state: &LoopState, input: TransitionInput, ctx: &TransitionCtx<'_>, ) -> TransitionOutcome; }
It takes the current state, the most recent TransitionInput (Start, ModelResponse, or ToolResults), and a TransitionCtx (the tool defs, ModelSettings, max_turns, the accumulated conversation, the optional OutputType, and any handoff descriptors). It returns a TransitionOutcome:
#![allow(unused)] fn main() { pub struct TransitionOutcome { pub next_state: LoopState, pub events: Vec<AgentEvent>, pub next_action: NextAction, pub conversation_appends: Vec<Item>, } }
next_action tells the driver what side effect to run before the next step:
CallModel { request }— call the model, feed the result back asTransitionInput::ModelResponse.ExecuteTools { calls }— fan out the tool calls, feed outcomes back asTransitionInput::ToolResults.Handoff— read the target/transcript fromApplyingHandoffand run the target agent.Terminate— the state is terminal; stop driving.
A typical run walks CallingModel → ExecutingTools → CallingModel → … → Done. Each tool round bumps turn; reaching RunConfig::max_turns fails the run with AgentError::MaxTurnsExceeded. With an output_type configured, the loop adds a Finalizing (and, on a validation miss, one RepairingOutput) turn before Done. See Structured Output for that path.
The event stream
Agent::run returns a BoxStream<'static, AgentEvent>. The outer Result only covers failure to start the stream; a fatal error mid-run arrives as an AgentEvent::RunFailed inside the stream, not as an Err.
AgentEvent is a single, serializable enum spanning lifecycle, raw deltas, post-aggregation semantic items, transitions, control signals, and terminal outcomes. The variants:
- Lifecycle:
RunStarted { agent },TurnStarted { turn }. - Raw deltas (for low-latency UIs):
TokenDelta { text },ReasoningDelta { text },ToolCallDelta { call_id, name, args_delta }. - Semantic items (carry a full
Item):MessageOutput { item },ToolCallItem { item },ToolOutputItem { item },HandoffItem { from, to }. - Transitions:
AgentUpdated { agent }. - Control:
GuardrailTriggered { kind, info },ApprovalRequested { call_id, tool, args },PermissionDenied { tool, reason },RepairStarted { attempt },StructuredOutputFailed { schema_errors, final_text }. - Terminal:
RunCompleted { usage },RunFailed { error }.
The raw TokenDelta / ToolCallDelta events stream as the provider emits them; the MessageOutput / ToolCallItem / ToolOutputItem events carry the same content re-aggregated into a complete Item once the turn settles.
Driving a run to completion
The common case: call run, then drain the stream into a RunResult with RunResultStreaming.
use paigasus_helikon::core::{ Agent, AgentInput, LlmAgent, RunContext, RunResultStreaming, }; use paigasus_helikon::openai::OpenAiModel; #[tokio::main] async fn main() -> anyhow::Result<()> { let agent = LlmAgent::builder::<()>() .name("budget-assistant") .model(OpenAiModel::chat("gpt-5-mini").build()?) .instructions("You are a budgeting assistant.") .build(); let ctx: RunContext<()> = RunContext::ephemeral(()); let input = AgentInput::from_user_text("How am I doing on my dining budget this month?"); let stream = agent.run(ctx, input).await?; let result = RunResultStreaming::new(stream).collect().await?; println!("{}", result.final_output); Ok(()) }
RunResultStreaming::new(stream).collect().await? drains every event, returning a RunResult whose final_output: String is the concatenated text of the last MessageOutput assistant message, alongside the full events vector and aggregated usage. A terminal RunFailed surfaces as Err(RunError). For structured output, use collect_typed::<T>() instead.
Consuming events live
To react as the run unfolds — for example streaming tokens to a console — match on the stream directly instead of collecting:
#![allow(unused)] fn main() { use futures_util::StreamExt; use paigasus_helikon::core::AgentEvent; let mut stream = agent.run(ctx, input).await?; while let Some(event) = stream.next().await { match event { AgentEvent::TokenDelta { text } => print!("{text}"), AgentEvent::RunFailed { error } => anyhow::bail!("run failed: {error}"), _ => {} } } }
You can drain the stream yourself and still build a RunResult later, or mix the two — RunResultStreaming::collect is just one consumer of the same AgentEvent stream.
Tuning the loop
RunConfig controls the run; pass it through a Runner, or set it on the agent via the builder. The knobs:
max_turns: u32— model turns before the loop fails withMaxTurnsExceeded. Default16. Honored by the core loop driver, including a bareagent.run().parallel_tool_call_limit: Option<NonZeroUsize>— cap on concurrently-executing tool calls.None= unbounded. Honored by the core loop driver.max_agent_depth: u32— maximum agent-nesting depth across handoff chains and agent-as-tool sub-runs. Default8; exceeding it fails withMaxAgentDepthExceeded.timeout: Option<Duration>— wall-clock deadline for the whole run. Honored only by a runtime backend such as theruntime-tokiorunner; a bareagent.run()has no timer and cannot time out.
The builders are chainable:
#![allow(unused)] fn main() { use std::num::NonZeroUsize; use std::time::Duration; use paigasus_helikon::core::RunConfig; let config = RunConfig::new() .with_timeout(Duration::from_secs(30)) .with_parallel_tool_call_limit(NonZeroUsize::new(4).unwrap()) .with_max_agent_depth(4); }
Cancellation is not a RunConfig field — the canonical CancellationToken lives on RunContext. Cancellation and timeout are best-effort: a genuine terminal event that already occurred wins over a late cancel or timeout, so a caller cannot assume that calling cancel() always yields RunError::Cancelled.
Tools
A tool is a function the model can call mid-run. Tools come in two layers:
- Your own tools — defined against the
Tooltrait, usually via the#[tool]attribute macro, and registered with thetools!macro. - Ready-made sandboxed tools — filesystem and shell tools shipped in
paigasus-helikon-tools(featuretools), plus network tools behindtools-web.
For a runnable end-to-end agent, see the quickstart.
The Tool trait
Tool<Ctx> (in paigasus_helikon::core) is object-safe so applications can hold a
heterogeneous registry as Vec<Arc<dyn Tool<Ctx>>>. A tool reports its name,
description, and argument schema to the model, and runs in invoke:
#[async_trait]
pub trait Tool<Ctx>: Send + Sync
where
Ctx: Send + Sync + 'static,
{
fn name(&self) -> &str;
fn description(&self) -> &str;
fn schema(&self) -> &serde_json::Value;
fn output_schema(&self) -> Option<&serde_json::Value> { None }
fn effect(&self) -> ToolEffect { ToolEffect::SideEffect }
async fn invoke(
&self,
ctx: &ToolContext<Ctx>,
args: serde_json::Value,
) -> Result<ToolOutput, ToolError>;
}
effect returns a ToolEffect (ReadOnly, Write, or the default SideEffect).
It drives PermissionMode decisions: Plan allows only ReadOnly, and
AcceptEdits auto-approves Write. An undeclared tool is treated as
side-effecting, so Plan mode blocks it. A successful call returns a ToolOutput
whose content is the raw JSON the tool produced.
You can implement Tool by hand, but for an async fn the #[tool] macro is the
ergonomic path.
Defining a tool with #[tool]
#[tool] (re-exported as paigasus_helikon::tool under the macros feature) turns
an async fn into a value implementing Tool<Ctx>. The argument struct derives
serde::Deserialize + schemars::JsonSchema; the return struct derives
serde::Serialize + schemars::JsonSchema. The function's /// doc comment
becomes the tool description shown to the model, and the function name becomes the
tool name.
use paigasus_helikon::core::{ToolContext, ToolError};
use paigasus_helikon::{tool, tools};
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct LookupSpendingArgs {
/// Spending category, e.g. "Dining".
category: String,
/// Month in YYYY-MM form.
month: String,
}
#[derive(serde::Serialize, schemars::JsonSchema)]
struct LookupSpendingOut {
/// Total spent in the category this month, in dollars.
total: f64,
/// Number of transactions.
count: u32,
}
/// Look up the user's total spending and transaction count for a category in a month.
#[tool]
async fn lookup_spending(
_ctx: &ToolContext<()>,
args: LookupSpendingArgs,
) -> Result<LookupSpendingOut, ToolError> {
let out = match args.category.to_lowercase().as_str() {
"dining" => LookupSpendingOut { total: 312.40, count: 18 },
"groceries" => LookupSpendingOut { total: 540.10, count: 9 },
_ => LookupSpendingOut { total: 0.0, count: 0 },
};
Ok(out)
}
The full example lives at
crates/paigasus-helikon/examples/budget_assistant_openai.rs.
ToolContext<Ctx>
The first parameter is &ToolContext<Ctx> — a narrower view of the run's
RunContext. Ctx is your application context type (() when you need none).
ToolContext deliberately excludes the session handle so tools cannot bypass the
runner's persistence. It exposes:
user_ctx() -> &Arc<Ctx>— your application context.state() -> &SessionState— run-scoped state shared across sub-agents.actions() -> &ActionsHandle— e.g.ctx.actions().escalate()to stop an enclosingLoopAgent.permission_mode() -> PermissionMode— a tool may branch on this.tracer(),cancel(),agent_depth(),max_agent_depth().
ToolError
invoke returns Result<_, ToolError>. The variants:
InvalidArgs { schema_errors }— arguments did not match the schema. This is the one recoverable variant: the runner may feed the errors back to the model once.Denied { reason }— the tool refused (a safety-boundary violation, e.g. a path outside the sandbox, or an unsatisfiable precondition). Not recoverable.Other(anyhow::Error)— escape hatch for arbitrary failures (#[from], so?on ananyhow::Errorworks).
Registering tools with tools!
tools! (re-exported as paigasus_helikon::tools under the macros feature) boxes
a comma-separated list of tool values into Vec<Arc<dyn Tool<Ctx>>>. Pass the bare
tool values — do not pre-wrap with Arc. Every tool in one invocation must
implement Tool<Ctx> for the same Ctx.
#![allow(unused)] fn main() { let agent = LlmAgent::builder::<()>() .name("budget-assistant") .model(model) .instructions("You are a budgeting assistant. Use the tools …") .tools(tools![lookup_spending, budget_status]) .build(); }
The builder also has a singular .tool(t) for registering one tool at a time.
The
toolsname is overloaded: with themacrosfeaturepaigasus_helikon::toolsis thetools!macro; with thetoolsfeature it is the sandboxed-tools crate module. They live in different namespaces, so Rust resolves each correctly.
The ready-made sandboxed toolset (tools feature)
paigasus-helikon-tools (facade feature tools) ships filesystem and shell tools
that an agent can use to inspect and modify a project. The four exported tool types
report these names to the model: ReadTool ("Read"), WriteTool ("Write"),
EditTool ("Edit"), and BashTool ("Bash").
use paigasus_helikon_tools::{BashTool, EditTool, HostBackend, ReadTool, Sandbox, WriteTool};
let sandbox = Sandbox::open(".")?;
let agent = LlmAgent::builder::<()>()
.name("sandbox-explorer")
.model(model)
.instructions("You can inspect the sandbox with Read/Write/Edit/Bash. …")
.tool(ReadTool::<()>::new(sandbox.clone()))
.tool(WriteTool::<()>::new(sandbox.clone()))
.tool(EditTool::<()>::new(sandbox.clone()))
.tool(BashTool::<()>::new(HostBackend::builder(sandbox).build()))
.build();
ReadTool, WriteTool, and EditTool take a Sandbox via ::new(sandbox).
BashTool takes an Arc<dyn ExecutionBackend> — use HostBackend::builder(sandbox).build()
for the default unconfined backend, OsSandboxBackend::builder(sandbox).build()
(Linux + macOS, feature os-sandbox) for OS-enforced containment, or
ForkdBackend::builder(controller_url).bearer_token(token).snapshot(tag).build()?
(Linux KVM, feature microvm, experimental) for microVM-level isolation — unlike the
other backends it takes a forkd controller URL, not a Sandbox; add
.egress_policy(…).enforce_egress(proxy_endpoint) to reach Isolation::Proxied on the
network axis via EgressProxy (see the runbook). BashToolBuilder exposes
allow_commands and deny_commands for command-level filtering. The full example is
crates/paigasus-helikon-tools/examples/explore_sandbox.rs.
Confinement model
A Sandbox is a directory opened as an OS-confined capability via cap-std
(Sandbox::open(root)). ReadTool (ReadOnly), WriteTool (Write), and
EditTool (Write) operate strictly inside it — they cannot escape via ..,
absolute paths, or symlinks; an attempt yields ToolError::Denied.
BashTool is different: its containment depends on the ExecutionBackend it is
given (see Containment vs approval below). With the
default HostBackend it is a cwd-pinned shell, not a security sandbox — the
cap-std containment does not extend to a spawned child process, so a command can
read and write anything this process can (absolute paths, .., ~, and the
network). Its effect is SideEffect, and in PermissionMode::Default with no
PermissionPolicy installed it runs ungated: gate it with a PermissionPolicy or a
DenyRule::tool("Bash") (as explore_sandbox.rs demonstrates), or use
OsSandboxBackend for OS-enforced containment, or ForkdBackend (feature
microvm, experimental) for microVM-level isolation with optional Isolation::Proxied
network enforcement via EgressProxy.
Containment vs approval
BashTool separates three independent axes that are often conflated:
- Containment — what OS-kernel mechanisms prevent the spawned process from
accessing resources it was not granted. Enforced by the
ExecutionBackend. - Approval — whether a human or a
PermissionPolicymust authorise the call before it runs. Enforced by the runner's permission pipeline. - Resource-capping — CPU time, file-size, and address-space limits applied via
setrlimitso a runaway command does not exhaust the host.
These are orthogonal: you can grant full filesystem access (no containment) while requiring human approval (strict approval), or jail a command to a tmpdir (containment) and run it without asking (no approval required). Choose each axis independently.
Execution backends
BashTool delegates execution to a value implementing ExecutionBackend. Swap the
backend to change the containment tier without touching any other part of your agent.
ForkdBackend (Linux KVM; feature microvm) — microVM containment, experimental
The strongest containment tier on the filesystem and syscall axes: each command runs
inside a KVM-isolated Firecracker microVM orchestrated by the forkd daemon. The
ForkdBackend itself is a portable REST client (no Linux-kernel dependency in the
client crate); the daemon side requires Linux + /dev/kvm. Platform availability is
checked at runtime when controller requests are made (for example on run()), not
at compile time.
Experimental. The fork → exec → destroy REST flow is implemented and mock-tested (SMA-416). Network egress enforcement via
EgressProxyis now implemented (SMA-437) but requires a live deployment — seedocs/runbooks/forkd-live-validation.mdin the repository. Do not enablemicrovmin production without completing the deployment checklist in the runbook.
Network containment — layered model (SMA-437). The microvm network guarantee
now has two states depending on whether the layered egress enforcement is deployed:
Isolation::None(default) — no network enforcement is in place. The microVM can reach any host the host network allows. This is the state whenForkdBackend::builder(…).build()is called without.enforce_egress(…).Isolation::Proxied(enforced) — all HTTP/S egress is domain-filtered at theEgressProxy(application layer). Meaningful only in the layered deployment: a per-VM netns default-deny (iptables) that routes all egress through the proxy, so non-proxy-aware clients, UDP/53 DNS, QUIC/HTTP-3, and raw TCP cannot escape. The backend itself cannot verify the host's netns rules; this tier reflects an operator attestation via.enforce_egress(proxy_endpoint)(the same trust model the kernel/hypervisor tiers use for their respective boundaries). A reachability probe to the proxy is run at build time and fails closed if the proxy is unreachable.
To reach Isolation::Proxied:
// Requires the layered deployment described in the runbook.
let backend = ForkdBackend::builder("https://controller:8889")
.bearer_token(token)
.snapshot("helikon")
.egress_policy(EgressPolicy::deny_all().allow_domains(["example.com"]))
.enforce_egress("proxy-host:8443") // attest + probe
.build()?;
assert_eq!(backend.guarantees().network, Isolation::Proxied);
ForkdBackend::guarantees() — un-enforced (default):
SandboxGuarantees {
filesystem: Isolation::Virtualized,
network: Isolation::None, // default — deploy EgressProxy to reach Proxied
syscalls: Isolation::Virtualized,
label: "forkd (firecracker microvm — experimental)",
}
ForkdBackend::guarantees() — with .enforce_egress():
SandboxGuarantees {
filesystem: Isolation::Virtualized,
network: Isolation::Proxied, // layered netns default-deny + EgressProxy deployed
syscalls: Isolation::Virtualized,
label: "forkd (firecracker microvm — experimental)",
}
GC / reconciliation (SMA-447). A fork that commits a microVM on the controller
but whose id the client never learns (a decode error, or a client-side timeout after
commit) leaks that VM — there is no id to DELETE. Build the concrete backend with
build_backend() (instead of build()) and call reconcile() from your own
scheduler or shutdown hook: it lists the controller's sandboxes and reaps the ones of
this backend's snapshot tag that are strictly older than reap_age (builder option,
default 300s), returning a ReconcileReport { scanned, reaped, failed, skipped_unageable }. Detection is age-only and stateless, so it is safe across
multiple SDK processes sharing one controller — but you must set reap_age above
your longest expected run plus clock skew, or a still-running command could be reaped.
OsSandboxBackend (Linux + macOS; feature os-sandbox) — recommended for untrusted commands
The strongest containment tier available on the current platform. Built in the parent
process; applied in the child via a pre_exec hook. Fail-closed: build()
returns Err(OsSandboxError::Unsupported(…)) if the platform cannot enforce the
requested isolation, so a misconfigured host is never silently left unprotected.
use paigasus_helikon_tools::{BashTool, OsSandboxBackend, Sandbox};
let sandbox = Sandbox::open("./workspace")?;
// Fail-closed: `build()` errors if the OS cannot enforce the requested isolation,
// so containment is never silently downgraded. Propagate the error rather than
// dropping to an unconfined backend. A caller that genuinely wants a degraded mode
// can match on the error and opt in to `HostBackend` explicitly — but that is a
// deliberate, security-relevant choice, not a default.
let backend = OsSandboxBackend::builder(sandbox).build()?;
let tool = BashTool::<()>::new(backend);
What OsSandboxBackend enforces varies by platform:
Linux (kernel ≥ 5.13; x86_64 or aarch64):
| Axis | Mechanism | Guarantee |
|---|---|---|
| Filesystem | Landlock (LSM, kernel ≥ 5.13) | Read+write only under the sandbox root; read-only for a system path set (/usr, /bin, /lib, …). Attempts to write outside the root fail at the OS layer — not just at the shell level. |
| Network | seccomp-bpf | socket(AF_INET) and socket(AF_INET6) return EPERM by default. AF_UNIX (local sockets) is allowed. Pass .allow_network(true) to lift the IP egress restriction. |
| Syscalls | seccomp-bpf | A small deny-list of dangerous syscalls (ptrace, mount, pivot_root, chroot, setns, unshare, kexec_load, bpf, perf_event_open) always returns EPERM. |
| Resource | setrlimit | Configured via .rlimits(ResourceLimits { … }); defaults apply a CPU backstop and a 1 GiB file-size cap. |
No Linux namespaces or privileged capabilities are needed — the entire mechanism is unprivileged.
OsSandboxBackend::guarantees() on Linux returns:
SandboxGuarantees {
filesystem: Isolation::OsKernel,
network: Isolation::OsKernel, // or Isolation::None if .allow_network(true)
syscalls: Isolation::OsKernel,
label: "os-sandbox (landlock+seccomp)",
}
macOS (any version that ships /usr/bin/sandbox-exec):
The macOS backend uses Seatbelt (sandbox-exec), Apple's sandbox MAC framework.
sandbox-exec is Apple-deprecated but ships on every macOS release. The posture is
write-focused: filesystem write operations are denied outside the sandbox root,
while reads are unrestricted (weaker than Linux's read+write containment). Network is
all-or-nothing: denied by default, which also blocks AF_UNIX local sockets; pass
.allow_network(true) to permit all socket families. Seatbelt is an operation MAC,
not a syscall filter, so syscalls is None.
| Axis | Mechanism | Guarantee |
|---|---|---|
| Filesystem | Seatbelt (sandbox-exec) | Write-only containment: writes outside the sandbox root are denied at the OS layer; reads are unrestricted. |
| Network | Seatbelt (sandbox-exec) | All sockets denied by default (including AF_UNIX). Pass .allow_network(true) to allow all outbound traffic. |
| Syscalls | — | No syscall filter; Isolation::None. |
| Resource | setrlimit | Same as Linux — configured via .rlimits(ResourceLimits { … }). |
OsSandboxBackend::guarantees() on macOS returns:
SandboxGuarantees {
filesystem: Isolation::OsKernel, // write-only; reads unrestricted
network: Isolation::OsKernel, // or Isolation::None if .allow_network(true)
syscalls: Isolation::None,
label: "os-sandbox (seatbelt)",
}
macOS containment is weaker than Linux. The
OsKernellabel on the filesystem axis means OS-enforced, but only for writes. A sandboxed command can still read arbitrary files. Use the Linux backend (or a dedicated Linux CI environment) when read isolation is required.
Domain-level network egress filtering (route outbound traffic through a
policy-enforcing EgressProxy rather than blocking at the socket layer) is available
for the microvm tier (SMA-437). See ForkdBackend and Isolation::Proxied above.
HostBackend (all platforms) — default, unconfined
The default backend. Pins the working directory to the sandbox root and scrubs the environment to a configurable allowlist, but spawned commands have the same OS access as the parent process.
use paigasus_helikon_tools::{BashTool, HostBackend, Sandbox};
let backend = HostBackend::builder(Sandbox::open("./workspace")?)
.timeout(std::time::Duration::from_secs(10))
.env_allowlist(["PATH", "HOME"])
.build();
let tool = BashTool::<()>::new(backend);
HostBackend::guarantees() returns:
SandboxGuarantees {
filesystem: Isolation::None,
network: Isolation::None,
syscalls: Isolation::None,
label: "host (no containment)",
}
HostBackendis NOT a security boundary. A command it runs can read and write anything the parent process can. Pair it with aPermissionPolicyor aDenyRule::tool("Bash")for approval-level control, or useOsSandboxBackendfor OS-enforced containment.
guarantees() tiers
ExecutionBackend::guarantees() returns a SandboxGuarantees struct with an
Isolation value on each axis:
Isolation::None— no OS enforcement; the command has the same access as the parent process on that axis.Isolation::OsKernel— enforced by an OS kernel mechanism. The exact mechanism and strength depend on the platform: on Linux, Landlock LSM for filesystem and seccomp-bpf for network and syscalls (read+write containment); on macOS, Seatbelt for filesystem (write-only; reads unrestricted) and network. A violating operation returns an OS error — the command cannot bypass it from userspace.Isolation::Virtualized— enforced by a VM boundary (Firecracker microVM viaForkdBackend). The command runs inside a KVM guest; host filesystem and syscalls are inaccessible by construction. Network is separately gated — seeIsolation::Proxiedbelow.Isolation::Proxied— egress filtered at a CONNECT/HTTP proxy (EgressProxy) enforcing a domain allow/deny policy. Meaningful only in the layered deployment: a per-VM netns default-deny that forces all guest TCP through the proxy (UDP/QUIC cannot reach the proxy and are dropped at L3/L4). Without the netns rules, raw TCP and UDP escape. The backend cannot verify the host's netns configuration, soProxiedis an operator attestation — the same trust modelVirtualizeduses for the hypervisor boundary. SeeForkdBackendBuilder::enforce_egress.
The label field is a short human-readable string ("host (no containment)" /
"os-sandbox (landlock+seccomp)" on Linux / "os-sandbox (seatbelt)" on macOS /
"microvm (forkd/firecracker) [skeleton]") that BashTool surfaces in its tool
description so the model knows what tier it is operating under.
Network tools (tools-web feature)
The facade feature tools-web (the tools crate's own web feature) adds two
network tools, re-exported from paigasus_helikon_tools:
WebFetchTool(name"WebFetch") — fetches an HTTP(S) URL, extracts the main article, and returns Markdown. Built viaWebFetchTool::builder().WebSearchTool(name"WebSearch") — runs a query through a swappableSearchBackend. Built viaWebSearchTool::builder(backend); the crate providesBraveBackendandTavilyBackendimplementations, with each hit modeled as aSearchResult.
WebFetchTool enforces an optional host allow/deny list and a default-on SSRF
guard: it blocks private, loopback, link-local (including the cloud-metadata IP),
CGNAT, and IPv6 ULA addresses, and it re-validates resolved IPs at connect time to
close the DNS-rebinding window. Both web tools report SideEffect.
See also
- Quickstart — a complete first agent.
paigasus-helikon-toolson docs.rs andpaigasus-helikon-macros.
Model Providers
Every LLM in Helikon sits behind one trait: Model, from paigasus-helikon-core.
There are no per-provider traits. Capability differences (streaming, tool calling,
structured output, vision, prompt caching, …) are surfaced through a flag struct,
ModelCapabilities, rather than split interfaces. Four adapters ship today —
OpenAiModel, AnthropicModel, BedrockModel, and GeminiModel — and switching
between them is a single line.
The Model trait
#![allow(unused)] fn main() { use async_trait::async_trait; use futures_core::stream::BoxStream; use paigasus_helikon::core::{ CancellationToken, Model, ModelCapabilities, ModelError, ModelEvent, ModelRequest, }; #[async_trait] pub trait Model: Send + Sync { async fn invoke( &self, request: ModelRequest, cancel: CancellationToken, ) -> Result<BoxStream<'static, Result<ModelEvent, ModelError>>, ModelError>; fn capabilities(&self) -> ModelCapabilities; fn provider(&self) -> &str; // GenAI `gen_ai.provider.name`, e.g. "openai" fn model(&self) -> &str; // GenAI `gen_ai.request.model`, e.g. "gpt-4o" } }
invoke and capabilities are the only methods an implementor must define.
provider and model are provided methods with defaults ("unknown" and ""
respectively); the shipped adapters override them so traces carry the real
provider and model id.
invoke takes a ModelRequest and returns a stream of ModelEvents. The
agent loop drives this trait; you rarely call invoke yourself — you hand a
Model to an LlmAgent and let the loop run it.
Carrier types
These live in paigasus_helikon::core (re-exported from
paigasus-helikon-core) and cross the model boundary:
ModelRequest— the request envelope: accumulatedmessages, thetoolsthe model may call this turn, and aModelSettingsof provider-tuning knobs.ModelSettings—temperature,top_p,max_output_tokens, atool_choice(ToolChoice), aresponse_format(ResponseFormat), and OpenAI Responses'previous_response_id.ResponseFormat—Text,JsonObject, orJsonSchema { name, schema, strict }. Used to request structured output; see Structured Output.ModelEvent— the streaming union:TokenDelta,ReasoningDelta,ToolCallDelta,Usage { input_tokens, output_tokens, cached_input_tokens, reasoning_tokens }, and the terminalFinish { reason }(aFinishReason).ModelCapabilities— the per-instance capability flags below.ModelError—Unavailable,RateLimited,ContextLengthExceeded,Refused,Transport,Other. The loop does not auto-retry on these.
ModelRequest, ModelSettings, ModelEvent, ModelCapabilities,
ResponseFormat, ToolChoice, FinishReason, and ModelError are all
#[non_exhaustive], so new fields and variants are additive.
ModelCapabilities
A Copy flag struct, stable per Model instance, that tells the loop what the
provider can do: streaming, tools, parallel_tool_calls,
structured_output, server_managed_state, reasoning, vision, audio,
prompt_caching. Construct from empty() (or default()) with chained const
builders:
#![allow(unused)] fn main() { use paigasus_helikon::core::ModelCapabilities; let caps = ModelCapabilities::empty() .with_streaming() .with_tools() .with_structured_output(); }
The shipped adapters
Four provider adapters ship today. All are published on crates.io and reached
through the facade behind a feature flag. Each exposes a Model implementation
plus a builder.
OpenAI — paigasus-helikon-providers-openai
Reached as paigasus_helikon::openai::OpenAiModel behind the openai feature
(alias providers-openai). Covers the Chat Completions and Responses APIs. The
builder reads OPENAI_API_KEY from the environment.
#![allow(unused)] fn main() { use paigasus_helikon::openai::OpenAiModel; let model = OpenAiModel::chat("gpt-5-mini").build()?; }
OpenAiModel::chat(id) returns an OpenAiModelBuilder; build() yields a
Result<OpenAiModel, BuildError>.
Anthropic — paigasus-helikon-providers-anthropic
Reached as paigasus_helikon::anthropic::AnthropicModel behind the anthropic
feature. Covers the Messages API. The builder reads ANTHROPIC_API_KEY from the
environment, and the crate also exports the Anthropic-specific settings types
CacheStrategy and ExtendedThinking.
#![allow(unused)] fn main() { use paigasus_helikon::anthropic::AnthropicModel; let model = AnthropicModel::messages("claude-sonnet-4-6").build()?; }
AnthropicModel::messages(id) returns an AnthropicModelBuilder; build()
yields a Result<AnthropicModel, BuildError>.
Bedrock — paigasus-helikon-providers-bedrock
Reached as paigasus_helikon::bedrock::BedrockModel behind the bedrock
feature. Covers the Converse streaming API — tool use and multi-turn
conversations across Anthropic Claude, Amazon Nova, Amazon Titan, Meta Llama,
Mistral, and Cohere model families hosted on AWS Bedrock. Structured output
(forced-tool synthesis) is supported on Anthropic Claude, Amazon Nova, and
Mistral; Titan, Llama, and Cohere degrade to plain text responses.
Disambiguation: this is the Bedrock Converse model provider. It is distinct from
paigasus-helikon-runtime-agentcore, which is the Bedrock AgentCore runtime host — a separate crate implementing the AgentCore container contract; see Runtimes.
Construction
The simplest path loads AWS configuration from the standard credential chain:
use paigasus_helikon::bedrock::BedrockModel;
let model = BedrockModel::from_env("anthropic.claude-3-5-sonnet-20241022-v2:0").await?;
from_env is async because it may fetch IMDS or SSO tokens. The credential
chain is lazy — auth failures surface at invoke() time, not here.
For explicit configuration, use the synchronous builder:
use aws_config::BehaviorVersion;
use paigasus_helikon::bedrock::BedrockModel;
let sdk_cfg = aws_config::defaults(BehaviorVersion::v2026_01_12())
.region(aws_config::Region::new("us-east-1"))
.load()
.await;
let model = BedrockModel::converse("amazon.nova-pro-v1:0")
.sdk_config(&sdk_cfg)
.build()?;
BedrockModel::converse(id) returns a BedrockModelBuilder; build() yields a
Result<BedrockModel, BuildError>. You can also inject a pre-constructed
aws_sdk_bedrockruntime::Client via .client(c) for full control.
Family-gated structured output
The bedrock provider detects the model family from the Bedrock model ID. For
families that support Bedrock's forced-tool-choice — Anthropic, AmazonNova,
and Mistral — ResponseFormat::JsonSchema is synthesized as a hidden tool
call: the model is forced to invoke an internal reserved tool whose input schema
is the user-supplied JSON Schema, and the result is returned as a structured
JSON response. For other families (AmazonTitan, Llama, Cohere, Unknown)
a JsonSchema format degrades silently to a text response.
Cross-region inference profile prefixes (us., eu., ap., apac.) are
stripped before family detection, so model IDs such as
us.anthropic.claude-3-7-sonnet-20250219-v1:0 are correctly identified as
Anthropic.
Reasoning content surfaced by the model (e.g. extended-thinking responses from
Claude) is delivered as ModelEvent::ReasoningDelta in the stream — no extra
configuration is required on this provider.
Tool-input schema rewriter
Bedrock's Converse API validator rejects JSON Schemas containing $ref/$defs,
oneOf/anyOf/allOf, or meta-keywords such as $schema, format, and
examples. The crate exposes rewrite_tool_schema(schema, Ruleset::Strict),
which runs automatically on every tool spec before the request is sent:
$refreferences are inlined recursively (cycles and excessive depth are terminated with a permissive{"type": "object"}).oneOf/anyOf/allOfcombinators (e.g. serde-generated tagged enums) are collapsed into a flatobjectwhosepropertiesunion the variants' fields and whose shared tag key becomes anenumof variant names.- Unsupported meta-keywords are stripped.
This means schemars-derived schemas for tagged enums and generic structs pass Bedrock's validator without any manual schema adjustment.
Credentials and TLS
The provider uses the standard AWS credential chain via aws-config
(environment variables, ~/.aws/credentials, SSO, IMDS, ECS task role, …) and
the aws-lc-rs TLS backend — the same crypto provider the workspace already
uses via reqwest/async-openai. Do not enable the AWS SDK ring feature
alongside this crate; a second CryptoProvider panics.
Model ids are illustrative — swap them for any model your account can reach if the provider rejects the id.
Gemini — paigasus-helikon-providers-gemini
Reached as paigasus_helikon::gemini::GeminiModel behind the gemini feature.
Covers the Gemini streaming API — tool use, structured output, and
multi-turn conversations against Google's Gemini model family. Structured output
is native (responseSchema) — the provider does not use forced-tool synthesis.
Note: Gemini rejects requests that combine structured output with active function calling. If your
ModelRequestsets aResponseFormat::JsonObjectorResponseFormat::JsonSchemawhiletoolsis non-empty ortool_choiceis anything other thanNone, the provider returns a conflict error before sending.
Transports
Two transports are available:
Developer API — authenticates with an API key from the environment:
#![allow(unused)] fn main() { use paigasus_helikon::gemini::GeminiModel; // Reads GEMINI_API_KEY or GOOGLE_API_KEY. let model = GeminiModel::from_env("gemini-2.5-flash")?; }
Or explicitly via the builder:
#![allow(unused)] fn main() { use paigasus_helikon::gemini::GeminiModel; let model = GeminiModel::developer("gemini-2.5-flash") .api_key("your-api-key") .build()?; }
Vertex AI — authenticates with a bearer token or TokenProvider:
use paigasus_helikon::gemini::GeminiModel;
// vertex-adc feature: reads GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_LOCATION (default: "global").
// ADC credentials are discovered from the gcloud credential chain.
let model = GeminiModel::vertex_from_env("gemini-2.5-flash").await?;
Or with an explicit static bearer token:
#![allow(unused)] fn main() { use paigasus_helikon::gemini::GeminiModel; let model = GeminiModel::vertex("gemini-2.5-flash", "my-project", "us-central1") .bearer_token("ya29.your-token") .build()?; }
Enable the vertex-adc feature for Application Default Credentials via gcp_auth:
[dependencies]
paigasus-helikon-providers-gemini = { version = "0.1", features = ["vertex-adc"] }
JSON-Schema sanitizer
A JSON-Schema sanitizer runs automatically on every schema passed to
responseSchema. It inlines $ref references, converts [T, "null"] type
arrays to nullable: true, replaces const with single-item enum, renames
oneOf to anyOf, strips unsupported keywords ($schema,
additionalProperties, unevaluatedProperties, patternProperties,
examples, default, $defs/definitions), and drops format values Gemini
doesn't recognize for their type (keeping e.g. enum/date-time). Schemars-
derived schemas for typical Rust structs and enums pass without manual adjustment.
Limitations
Remote-URL images, audio parts, and non-text tool-result parts are silently
dropped during request translation. Reasoning streaming (ModelEvent::ReasoningDelta)
is not yet emitted by this provider.
Model ids are illustrative — swap them for any model your account can reach.
Switching providers is one line
Because all adapters implement the same Model trait, the only code that
changes between providers is the construction line. Everything downstream — the
agent, its tools, the run context, the streamed result — is identical. Compare
the two budgeting-assistant examples (budget_assistant_openai.rs and
budget_assistant_anthropic.rs); the agent, the #[tool] functions, and the
run are byte-for-byte the same, and the diff is one line:
// OpenAI
let model = OpenAiModel::chat("gpt-5-mini").build()?;
// Anthropic — same agent, same tools, same run
let model = AnthropicModel::messages("claude-sonnet-4-6").build()?;
// Bedrock (async construction — loads the AWS credential chain)
let model = BedrockModel::from_env("anthropic.claude-3-5-sonnet-20241022-v2:0").await?;
// Gemini Developer API
let model = GeminiModel::from_env("gemini-2.5-flash")?;
#![allow(unused)] fn main() { let agent = LlmAgent::builder::<()>() .name("budget-assistant") .model(model) // ← the only thing that differs is how `model` was built .instructions("You are a budgeting assistant. ...") .tools(tools![lookup_spending, budget_status]) .build(); }
.model(model) accepts anything that implements Model, so your own adapter
slots in the same way — implement invoke and capabilities on a type and the
loop will drive it.
Enabling the providers
[dependencies]
paigasus-helikon = { version = "0.3", features = ["openai", "macros"] }
# or, for Anthropic:
# paigasus-helikon = { version = "0.3", features = ["anthropic", "macros"] }
# or, for Bedrock:
# paigasus-helikon = { version = "0.3", features = ["bedrock", "macros"] }
# or, for Gemini:
# paigasus-helikon = { version = "0.3", features = ["gemini", "macros"] }
Feature names are kebab-case (openai, anthropic); the pub use aliases are
snake_case (openai, anthropic, providers_openai). See
Workspace Layout for the full feature
map and Crates for the published crate list.
Retrying transient errors
Provider calls can fail transiently — rate limits (RateLimited), 503s
(Unavailable), or dropped connections (Transport). Per ADR-10 the agent loop
never auto-retries; retry is an opt-in composition-layer concern.
paigasus-helikon-runtime-tokio provides a RetryingModel<M> decorator: it
wraps any Model and retries those transient variants with exponential backoff
and jitter. It is configured by wrapping the model (not via RunConfig),
and is disabled unless you wrap.
use std::time::Duration;
use paigasus_helikon_runtime_tokio::{RetryPolicy, RetryingModel};
let policy = RetryPolicy::new()
.max_attempts(4)
.base_delay(Duration::from_millis(250));
let resilient = RetryingModel::new(model, policy);
Retry covers connection establishment: a retryable error that arrives before
any content has streamed is retried; once tokens or tool-call deltas have been
emitted, a later error is surfaced rather than retried (output can't be
un-emitted). RateLimited { retry_after_ms } waits at least the provider's
hint, and backoff sleeps abort promptly on run cancellation.
Sessions
A session models conversation persistence as an append-only event log, not a flat message list. The log shape buys deterministic replay for evals, an audit trail for regulated deployments, and event-sourcing-style durability — at the cost of a projection step before a provider can read it.
The Session trait
Session lives in paigasus-helikon-core (re-exported as
paigasus_helikon::core::Session). It is an async_trait with three methods:
#![allow(unused)] fn main() { #[async_trait] pub trait Session: Send + Sync { async fn append(&self, events: &[SessionEvent]) -> Result<(), SessionError>; async fn events(&self, since: Option<SequenceId>) -> Result<Vec<SessionEvent>, SessionError>; async fn snapshot(&self) -> Result<ConversationSnapshot, SessionError>; } }
appendwrites events to the end of the log.eventsreads the log;sinceis exclusive —Some(SequenceId(n))returns events strictly after positionn,Nonereturns the whole log.snapshotreturns aConversationSnapshot— the canonicalmessages: Vec<Item>view that providers consume. Both shipped backends implement it asprojectoverevents(None).
A SequenceId(pub u64) is a monotonic position in one log. SessionError is a
#[non_exhaustive] enum with Unavailable, a type-erased
Backend(Box<dyn Error + Send + Sync>) variant, and an Other(anyhow::Error)
escape hatch. Backends wrap their own error type with the
SessionError::backend(e) helper.
The event log
Each entry is a SessionEvent — a #[non_exhaustive], serde-tagged enum
(#[serde(tag = "type", rename_all = "snake_case")]). Every variant carries a
ts: jiff::Timestamp recording when it was logged:
| Variant | Carries |
|---|---|
UserMessage | content: Vec<ContentPart> |
AssistantMessage | content: Vec<ContentPart>, agent: String |
ToolCalled | call_id, name, args: serde_json::Value |
ToolReturned | call_id, content: Vec<ContentPart> |
HandoffOccurred | from: String, to: String |
Compacted | summary: String, original_count: u64 |
Constructor helpers stamp ts = Timestamp::now() for you:
SessionEvent::user_message(content),
SessionEvent::assistant_message(content, agent),
SessionEvent::tool_called(call_id, name, args),
SessionEvent::tool_returned(call_id, content),
SessionEvent::handoff_occurred(from, to), and
SessionEvent::compacted(summary, original_count).
Projection
project(events: &[SessionEvent]) -> ConversationSnapshot folds the log into a
message list. Most variants map one-to-one to an Item; HandoffOccurred is
audit-only and yields no message; Compacted drops the original_count
preceding events' messages and emits the summary as an Item::System.
Provider caveat: a Compacted summary renders as Item::System, and both
shipped provider translators reshape system messages — Anthropic hoists them to
the top-level system field, OpenAI concatenates them at the top of the
conversation. The summary text reaches the model, but as a top-level
instruction, not a positional cutover.
Shipped backends
MemorySession (in core)
MemorySession is an in-memory backend backed by a Mutex<Vec<SessionEvent>>,
re-exported as paigasus_helikon::core::MemorySession. One instance is one
session — there is no session_id. It is the right default for tests and
ephemeral runs:
#![allow(unused)] fn main() { use std::sync::Arc; use paigasus_helikon::core::MemorySession; let session = Arc::new(MemorySession::new()); }
SqliteSession (paigasus-helikon-sessions-sqlite)
For persistent or multi-session storage, the sessions-sqlite feature pulls in
paigasus-helikon-sessions-sqlite (re-exported as
paigasus_helikon::sessions_sqlite). SqliteSession stores logs in a single
SQLite database; many sessions share one sqlx::SqlitePool and are isolated by
session_id. The constructors are async and return
Result<_, SessionError>:
#![allow(unused)] fn main() { use std::sync::Arc; use paigasus_helikon::sessions_sqlite::SqliteSession; use sqlx::sqlite::{ SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous, }; use std::time::Duration; let opts = SqliteConnectOptions::new() .filename("sessions.db") .create_if_missing(true) .journal_mode(SqliteJournalMode::Wal) .synchronous(SqliteSynchronous::Normal) .busy_timeout(Duration::from_secs(30)); let pool = SqlitePoolOptions::new().connect_with(opts).await?; // `open` runs embedded migrations as a side effect, then opens the session. let session = Arc::new(SqliteSession::open(pool, "user-42").await?); }
SqliteSession::open runs the embedded migrations on every call (one
round-trip). When you manage many sessions against an already-migrated pool,
call SqliteSession::migrate(&pool).await? once at startup, then use
SqliteSession::open_without_migrate(pool, session_id) (synchronous, no
Result) on the hot path. SqliteSession::session_id() returns the id the
instance reads and writes.
Appends serialize through SQLite's database-level write lock (BEGIN IMMEDIATE
plus a (session_id, sequence) primary key), so the backend is safe for
concurrent writers. synchronous = NORMAL is the recommended pairing with WAL
for multi-writer workloads (durability-for-throughput trade: a power loss may
drop the newest commits, never corrupt). busy_timeout caps how long one
append waits for the write lock — under sustained contention the worst-placed
writer waits out the entire backlog ahead of it, so size the timeout against
writers × appends × worst-case transaction latency, not a single
transaction. An append that exhausts it fails cleanly with
SessionError::Backend ("database is locked"), persisting nothing; the
session stays usable.
PostgresSession (paigasus-helikon-sessions-postgres)
For durable, production-grade storage, the sessions-postgres feature pulls in
paigasus-helikon-sessions-postgres (re-exported as
paigasus_helikon::sessions_postgres). PostgresSession stores each session's
event log in a session_events table with a JSONB payload column. Many
sessions share one sqlx::PgPool and are isolated by session_id. The
constructors are async and return Result<_, SessionError>:
use std::sync::Arc;
use paigasus_helikon::sessions_postgres::PostgresSession;
let pool = sqlx::PgPool::connect("postgres://user:pass@localhost/mydb").await?;
// Migrate once at process start (idempotent; serialized by advisory lock).
PostgresSession::migrate(&pool).await?;
// Hot path: skip the _sqlx_migrations round-trip.
let session = Arc::new(PostgresSession::open_without_migrate(pool, "user-42"));
PostgresSession::open runs migrations and opens the session in one call.
PostgresSession::migrate(&pool) + open_without_migrate(pool, id) separates
the one-time migration from the per-session constructor for high-throughput paths.
PostgresSession::session_id() returns the id the instance reads and writes.
Concurrent writers are serialized per-session via pg_advisory_xact_lock —
each append takes an advisory lock keyed on hashtextextended(session_id, 0)
inside a single transaction, then computes COALESCE(MAX(sequence), -1) + 1 to
allocate contiguous sequence numbers; the lock auto-releases at COMMIT. Because
hashtextextended produces a 64-bit key, the full advisory-lock key space is
used and writers to different sessions effectively never collide — distinct from
the 32-bit hashtext, which could occasionally serialize unrelated sessions.
TLS: the crate's sqlx dependency uses tls-rustls-aws-lc-rs, matching the
workspace-wide CryptoProvider. A ring-backed TLS variant would cause a
dual-CryptoProvider panic under cargo test --workspace --all-features and is
intentionally absent.
RedisSession (paigasus-helikon-sessions-redis)
For low-latency shared storage, the sessions-redis feature pulls in
paigasus-helikon-sessions-redis (re-exported as
paigasus_helikon::sessions_redis). RedisSession stores each session's events
in a Redis Stream at key helikon:session:{id}:events. Each entry carries seq
(monotonic integer), kind (variant tag), payload (JSON), and ts
(nanoseconds since Unix epoch).
use std::sync::Arc;
use paigasus_helikon::sessions_redis::RedisSession;
// Convenience constructor — plaintext connection.
let session = Arc::new(RedisSession::connect("redis://127.0.0.1/", "user-42").await?);
For TLS or custom retry behaviour, supply a pre-built ConnectionManager:
use paigasus_helikon::sessions_redis::RedisSession;
let client = redis::Client::open("rediss://user:pass@redis.example.com:6380/")?;
let conn = redis::aio::ConnectionManager::new(client).await?;
let session = Arc::new(RedisSession::new(conn, "user-42"));
Each append call runs an atomic Lua script that reserves the next sequence
numbers from a per-session counter (INCRBY on helikon:session:{id}:seq) and
issues one XADD per event. Because Redis executes Lua atomically (single-threaded
command loop), two concurrent callers can never produce the same sequence number
or leave gaps. Sourcing seq from a monotonic counter rather than XLEN keeps
sequence numbers unique even if the stream is later trimmed.
TLS: the crate's redis dependency enables no rustls feature. Ring-backed
rustls features in redis would register a second CryptoProvider and
reproduce the dual-provider panic under --all-features. TLS is therefore
BYO-connection: build a TLS-configured ConnectionManager with the
workspace's aws-lc-rs provider and pass it to RedisSession::new.
Operational note: RedisSession never calls XADD … MAXLEN or XTRIM, so
the stream grows unboundedly. The per-session sequence counter keeps seq values
monotonic and unique even across a trim, but trimming still drops the trimmed
events from history (events() reads the stream itself). Run the session
keyspace under maxmemory-policy noeviction — a key-eviction policy that silently
dropped stream entries (or the counter key) would lose event data. Use
CompactingSession<RedisSession> to bound growth within the event log.
Compaction
Long-running conversations accumulate events; the growing projected context
eventually exceeds the provider's context window. CompactingSession<S> (in
paigasus-helikon-core, re-exported as
paigasus_helikon::core::CompactingSession) is a transparent wrapper over any
Session that fires automatic LLM-based summarisation when a token-count
estimate exceeds a configurable threshold.
Token counting
TokenCounter is a pluggable trait:
#![allow(unused)] fn main() { pub trait TokenCounter: Send + Sync { fn count(&self, items: &[Item]) -> usize; } }
The default implementation is HeuristicTokenCounter —
ceil(total_chars / 4), where total_chars is the count of Unicode scalar
values (str::chars().count()) across every ContentPart::Text and
ContentPart::Reasoning field, recursing into nested ToolResult content, and
also counting Item::System running-summary text (necessary so the
post-compaction count is measured correctly). Item::ToolCall name and args
(compact JSON) also contribute, as does ContentPart::ToolUse .name + .args
(the equivalent nested-in-content form emitted by Anthropic-style providers).
Image and audio source parts contribute zero.
The heuristic is deterministic and dependency-free. Swap it with a
model-specific tokenizer by passing a custom impl TokenCounter to the builder.
Building a CompactingSession
#![allow(unused)] fn main() { use std::sync::Arc; use paigasus_helikon::core::{CompactingSession, MemorySession}; let session = CompactingSession::builder(MemorySession::new(), model) .threshold(4096) // fire compaction when estimated tokens exceed this .build()?; }
builder accepts any S: Session by value and any Arc<dyn Model>. Optional
setters: .token_counter(Arc<dyn TokenCounter>), .model_settings(...), and
.prompt(String) to override the built-in summarisation instruction. The
builder rejects threshold == 0.
How compaction fires
On every append:
- The new events' character estimate is added to a running cheap counter.
- When the cheap estimate suggests the threshold may be exceeded, the wrapper
reads the inner session, projects it with
project, and callsTokenCounter::countfor the authoritative figure. - If
tokens > threshold, the wrapper sends the current projected messages plus a trailingUserMessage(prompt)to the model, collects theTokenDeltastream into a summary string. If the summary is whitespace-only the result is logged atwarn!and skipped — noCompactedmarker is appended. OtherwiseSessionEvent::Compacted { summary, original_count }is appended to the inner session. - The user's events are always persisted first. Model/summary-generation
failures are logged at
warn!and swallowed, but inner-session read/write failures during compaction (reading the log, or appending theCompactedmarker) still propagate out ofappend.
The cheap running counter is initialised to usize::MAX, so the very first
append to a freshly constructed wrapper always runs the authoritative read.
This is what makes resume correct: a CompactingSession wrapping an
already-populated durable backend (the typical Postgres or Redis use-case)
compacts the existing backlog on the first append, rather than silently treating
the session as empty.
Compaction model: full-history running summary
CompactingSession maintains a full-history running summary. When the
projection reaches a Compacted marker it drops every message that preceded the
marker and emits the summary as a single Item::System. A later compaction does
the same against the messages that accumulated after the previous marker, so the
conversation is always represented as one System summary followed by the most
recent events.
A keep-recent-window mode (summarise an older prefix while keeping the last
K turns verbatim) is explicitly out of scope — it requires changes to
project() and is deferred to a future ticket.
Convergence. Compaction lowers the projected count below threshold only
when the model's summary is itself shorter than threshold. If the summary is
still over threshold, a guard prevents an infinite loop: compaction is skipped
when the projected snapshot is empty (messages.is_empty()) or when its sole
message is already an Item::System running summary — a lone System has
nothing useful to collapse and re-compacting it would loop forever. Two operational
constraints documented on the type: set threshold comfortably below the
summarisation model's context window (the wrapper sends the full projected
history in the summarisation call), and choose a model that reliably produces
summaries materially shorter than threshold.
Provider caveat. The compaction summary projects to Item::System (see
Projection above). Both shipped provider translators reshape
system messages: Anthropic hoists them to the top-level system field; OpenAI
concatenates them at the top of the conversation. The summary reaches the model
as a top-level instruction, not as a positional cutover in the message stream.
Concurrency. CompactingSession assumes a single logical writer per
session — the normal runner model where one run owns the session and appends
serially. The inner backend remains fully durable and concurrency-safe; the
compaction bookkeeping is not atomic against a concurrent append through the
same wrapper. Concurrent writers should share the same inner backend directly,
not a single wrapper instance.
Backend conformance
Every shipped Session backend passes the same conformance suite from the
internal paigasus-helikon-sessions-testkit crate:
| Test | What it verifies |
|---|---|
run_append_read | Events written by append are returned by events in order |
run_watermark_exclusive | events(Some(SequenceId(n))) returns only positions strictly after n |
run_projection | snapshot() equals project(&events(None)) |
run_concurrent_writers | 16 concurrent tasks × 10 appends each — every event present exactly once |
MemorySession, SqliteSession, and the forthcoming Postgres and Redis
backends all run run_all. Adding a new backend means passing this suite before
it ships.
Plugging a session into a run
RunContext accepts any Arc<dyn Session> via the .with_session(...) setter.
The quickest path is RunContext::ephemeral(()), which already installs an
in-memory MemorySession. To substitute a persistent backend, call
.with_session(...) on the ephemeral context:
#![allow(unused)] fn main() { use std::sync::Arc; use paigasus_helikon::core::{MemorySession, RunContext}; // Default: in-memory session. let ctx: RunContext<()> = RunContext::ephemeral(()); // Persistent: swap the session backend. // let ctx: RunContext<()> = RunContext::ephemeral(()) // .with_session(Arc::new(SqliteSession::open(pool, "user-42").await?)); }
Any Session impl drops in via .with_session(Arc::new(your_backend)). Swap
MemorySession for SqliteSession::open(pool, "user-42").await? to persist
across process restarts. Tools do not see the session handle — persistence is
the runner's job, not a tool's.
Run-lifecycle persistence
Loading prior history and writing new events is wired by the runner, not by
the agent loop. TokioRunner (the runtime-tokio feature) does it around each
run:
- Before the run, it calls
session.snapshot(), prepends those messages to the run'sAgentInput, and seeds aSessionRecorderwith the new turn. A read failure is a hard error — the run cannot faithfully resume from an unreadable session. - During the run, the recorder observes the agent's
AgentEventstream, accumulating assistant messages, tool calls/results, and handoffs asSessionEvents. - After the run, it drains the recorder and calls
session.append(...). Persistence here is best-effort: an append error is logged viatracingand never propagated, so the run's outcome is unaffected.drainalso synthesizes aToolReturnedfor any tool call interrupted mid-flight, so the log always projects to a provider-valid conversation.
Running an Agent directly (without a runner) executes against the session in
the RunContext but performs no automatic load-or-persist — that lifecycle
belongs to the runner. See Agent loop and
Multi-agent patterns for how runs are driven.
Multi-Agent Patterns
Seven shipped composition primitives let you build systems out of more than
one Agent. They split into three families:
- Control transfer —
Handoff: hand the conversation to another agent and let it finish the run.SwarmAgentbuilds on the same mechanism: a pool of agents with handoffs auto-injected full-mesh, ending as soon as whichever member answers instead of handing off further. - Sub-agent invocation —
AgentAsTool: call another agent like a tool, get its result back, and keep going. - Deterministic orchestration —
SequentialAgent,ParallelAgent,LoopAgent,GraphAgent: drive a fixed topology of sub-agents — in order, concurrently, in a bounded loop, or across a dependency-gated DAG.
All of these are in paigasus-helikon-core and re-exported through
paigasus_helikon::core. SequentialAgent, ParallelAgent, LoopAgent,
SwarmAgent, and GraphAgent each implement the same Agent<Ctx> trait as
LlmAgent, so they nest and compose freely — a SequentialAgent step can
itself be a ParallelAgent, and so on.
Choosing a pattern
| You want to… | Use | What crosses the boundary |
|---|---|---|
| Route to a specialist and let it own the rest of the run | Handoff | The active agent switches; the specialist produces the final output |
| Call a sub-agent, read its answer, and keep reasoning | AgentAsTool | The sub-agent's final_output comes back as a tool result |
| Run agents in a fixed order, each reading the last | SequentialAgent | Each step's final text lands in state[key] |
| Fan out independent work concurrently | ParallelAgent | Each branch writes state[key]; outputs merge into one JSON object |
| Refine until a sub-agent signals "done" | LoopAgent | Same as sequential, plus an escalate signal that stops the loop |
| Let a pool of specialists hand off among themselves until one answers | SwarmAgent | HandoffItem events between members; the first member to answer (not hand off) produces the final output |
| Run a fixed DAG where each node waits on specific predecessors | GraphAgent | Each node's final text lands in state[key]; sink node(s) deterministically merge into the final output |
Handoff is one-way: control does not return to the routing agent.
AgentAsTool is request/response: control always returns. The orchestration
agents are deterministic — the topology is fixed in code, not chosen by a model.
Handoff — transfer control
A Handoff<Ctx> names a candidate agent the conversation may be transferred to.
Add handoffs to an LlmAgent via .handoffs(...); the agent loop injects one
synthetic transfer_to_<slug> tool per handoff, and a model call to one switches
the active agent. The slug lowercases the target's name and collapses
non-alphanumeric runs to _ ("investing specialist" → transfer_to_investing_specialist).
Construct with Handoff::to(agent) (owned agent) or Handoff::shared(arc)
(a pre-wrapped Arc<dyn Agent<Ctx>>).
use paigasus_helikon::core::{ Agent, AgentInput, Handoff, LlmAgent, RunContext, RunResultStreaming, }; use paigasus_helikon::openai::OpenAiModel; #[tokio::main] async fn main() -> anyhow::Result<()> { let budgeting = LlmAgent::builder::<()>() .name("budgeting specialist") .description("Answers questions about monthly budgets and cutting spending.") .model(OpenAiModel::chat("gpt-5-mini").build()?) .instructions("You are a budgeting specialist. Give concrete, friendly advice.") .build(); let investing = LlmAgent::builder::<()>() .name("investing specialist") .description("Answers questions about investing, portfolios, and retirement.") .model(OpenAiModel::chat("gpt-5-mini").build()?) .instructions("You are an investing specialist. Give concrete, prudent advice.") .build(); let triage = LlmAgent::builder::<()>() .name("triage") .model(OpenAiModel::chat("gpt-5-mini").build()?) .instructions( "Classify the user's personal-finance question and transfer to the right \ specialist. Do not answer yourself — always hand off.", ) .handoffs([Handoff::to(budgeting), Handoff::to(investing)]) .build(); let ctx: RunContext<()> = RunContext::ephemeral(()); let input = AgentInput::from_user_text("How should I start investing $5,000?"); // With handoffs the terminal agent is dynamic, so collect as a string. let stream = triage.run(ctx, input).await?; let result = RunResultStreaming::new(stream).collect().await?; println!("{}", result.final_output); Ok(()) }
The target's description is shown to the model as the transfer tool's
description, so a clear, action-oriented .description(...) is what makes routing
work. Two handoffs whose names slug to the same transfer_to_* tool are rejected
with a collision error before the first model call, rather than silently
mis-routing.
When a handoff fires, the stream emits a HandoffItem { from, to } followed by
AgentUpdated { agent }, then the target agent's events. The terminal
RunCompleted.usage sums the parent and the post-handoff sub-run. A handoff
chain is bounded by RunConfig::max_agent_depth; exceeding it fails the run with
AgentError::MaxAgentDepthExceeded.
AgentAsTool — call a sub-agent and get its result back
AgentAsTool<Ctx> adapts any Agent<Ctx> into a Tool<Ctx>. The parent agent
calls it like any other tool, receives the wrapped agent's final_output as a
ToolOutput, and keeps reasoning. Construct with AgentAsTool::new(agent) or
AgentAsTool::shared(arc); override the exposed name and description with
.with_name(...) / .with_description(...) (they default to the wrapped agent's
own). The tool advertises a single string argument, input.
#![allow(unused)] fn main() { use paigasus_helikon::core::{AgentAsTool, LlmAgent}; use paigasus_helikon::openai::OpenAiModel; let summarizer = LlmAgent::builder::<()>() .name("summarizer") .description("Condenses a block of text to three bullet points.") .model(OpenAiModel::chat("gpt-5-mini").build()?) .instructions("Summarize the input as three concise bullets.") .build(); let writer = LlmAgent::builder::<()>() .name("writer") .model(OpenAiModel::chat("gpt-5-mini").build()?) .instructions("Draft replies. Call the `summarizer` tool when you need a digest.") .tools(vec![std::sync::Arc::new(AgentAsTool::new(summarizer))]) .build(); }
The sub-run is isolated: it gets a fresh in-memory session and empty hooks, so
the wrapped agent's internal turns never touch the parent's session log, and it
runs under its own RunConfig (the parent's max_turns / timeout do not cross
the boundary). What does cross: the user context, tracer, cancel token, the
nesting-depth counter (still capped by max_agent_depth), and the parent's
permission configuration (mode, policy, deny rules, approval handler) — so a
Plan or policy decision applies to the wrapped agent's tools too.
Use AgentAsTool over Handoff when the calling agent must stay in control and
incorporate the sub-agent's answer (a tool the model invokes mid-reasoning),
rather than delegate the whole remaining conversation.
Orchestration — Sequential / Parallel / Loop
These three drive a fixed list of sub-agents and coordinate them through the
run-scoped SessionState: after each sub-agent completes, its final text is
written to state[key], where key defaults to the sub-agent's name. A later
step can read it through a dynamic Instructions closure. Each merges child event
streams, folds child usage into a running total, and emits one outer
RunStarted / RunCompleted.
SequentialAgent
SequentialAgent::new(name, description) then chain steps with .then(agent).
Steps run in order and fail fast on the first failure. Use
.then_keyed(key, agent) when two steps share a name (to avoid a state-key
collision), or .then_shared(arc) for a pre-wrapped agent.
#![allow(unused)] fn main() { use paigasus_helikon::core::{RunContext, SequentialAgent}; let pipeline = SequentialAgent::new("research-then-write", "Research, then draft.") .then(researcher) .then( LlmAgent::builder::<()>() .name("writer") .model(OpenAiModel::chat("gpt-5-mini").build()?) // Read the previous step's output back out of shared state. // `state().get` returns `Option<serde_json::Value>`; the workflow // agents store each child's final text as a JSON string, so pull the // inner `&str` out rather than `Display`-formatting a quoted `Value`. .instructions(|ctx: &RunContext<()>| { let notes = ctx .state() .get("researcher") .and_then(|v| v.as_str().map(str::to_owned)) .unwrap_or_default(); format!("Write a short brief from these notes:\n{notes}") }) .build(), ); }
ParallelAgent
ParallelAgent::new(name, description) then add branches with .add(agent),
.branch(key, agent), or .add_shared(arc). Branches run concurrently —
cooperatively, via futures stream selection rather than OS threads, so it suits
IO-bound model.invoke calls; a CPU-bound branch would starve its siblings
between .await points. Branch keys must be unique (a duplicate key fails the run
before any branch starts, since concurrent writers to the same key would race).
Failure is collect-all: a failed branch lets its siblings finish, then one
aggregate RunFailed is emitted. The final_output is deterministic — a terminal
MessageOutput carrying a sorted-key JSON object {key: branch_output}. Read
individual branch results from state[key].
#![allow(unused)] fn main() { use paigasus_helikon::core::ParallelAgent; let fan_out = ParallelAgent::new("multi-lens", "Three independent takes.") .branch("optimist", optimist_agent) .branch("pessimist", pessimist_agent) .branch("realist", realist_agent); }
LoopAgent
LoopAgent::new(name, description, max_iterations) then add sub-agents with
.then(agent), .then_keyed(key, agent), or .then_shared(arc). It repeats the
sub-agents (in order) up to max_iterations. After each sub-agent, the loop
checks its ActionsHandle: if a tool escalated, the loop emits
RunCompleted and stops (success). Escalate means "no more iterations" — the
active sub-agent always finishes its current run first; the check happens after
each sub-agent, so a mid-pass escalate stops before the rest of that pass runs.
Exhausting the budget without an escalate emits RunFailed with
AgentError::MaxIterationsExceeded.
#![allow(unused)] fn main() { use paigasus_helikon::core::LoopAgent; // Refine up to 5 times: a critic escalates once the draft passes review. let refine = LoopAgent::new("draft-and-critique", "Iterate until the critic is satisfied.", 5) .then(drafter) .then(critic); }
SwarmAgent — a pool with full-mesh handoffs
SwarmAgent<Ctx> wires a pool of LlmAgent members together with a
Handoff to every other member auto-injected (a transfer_to_<slug> tool
per pair). Execution is the ordinary handoff-driven LlmAgent loop — the
swarm doesn't run a second driver — and it ends the moment the active
member produces a final output instead of handing off: first to answer
wins.
Members must be LlmAgents — they're the only agent type that can call
transfer tools (a dyn Agent "terminal member" that can receive but never
initiate a handoff is a possible future extension, not shipped today).
Bounding convergence. A handoff nests: the parent derives a child run
with its own turn budget and an incremented agent_depth, checked against
RunConfig::max_agent_depth (default 8). A ping-ponging swarm that never
converges exhausts that depth and fails with
AgentError::MaxAgentDepthExceeded — the underlying hard bound. On top of
that, the swarm exposes its own .max_handoffs(n) budget: it counts
HandoffItem events on its own returned stream and, on exceeding n,
cancels the run and fails with AgentError::MaxHandoffsExceeded { limit }.
Leave it unset and max_agent_depth is the only bound.
#![allow(unused)] fn main() { use paigasus_helikon::core::SwarmAgent; let swarm = SwarmAgent::builder() .name("support_swarm") .description("A personal-finance support swarm covering budgeting and investing.") .member(triage) // LlmAgent<Ctx, M1, T1> — heterogeneous per-member M/T .member(budgeting) // LlmAgent<Ctx, M2, T2> .member(investing) .entry("triage") // optional; defaults to the first member added .max_handoffs(6) // optional convergence budget (see above) .build()?; // SwarmBuildError on empty/duplicate-name/unknown-entry }
The runnable version of this example is
crates/paigasus-helikon/examples/swarm_finance.rs.
GraphAgent — a declared DAG
GraphAgent<Ctx> runs a declared directed acyclic graph of agents: nodes are
agents, edges are dependencies, and a node runs only once all of its
predecessors have completed. Ready nodes are scheduled and polled
concurrently as their predecessors finish (a dynamic Kahn wavefront) —
more bookkeeping than ParallelAgent's static fan-out, but the same
cooperative, non-tokio concurrency model. Each completed node's final text
is written to state[key] (key = the node's name, mirroring
ParallelAgent's branch-key convention) and appended to each successor's
input as a labeled context message.
Termination is deterministic regardless of completion timing: once every
node has run, the graph synthesizes one final assistant message from the
sink nodes (nodes with no outgoing edges) — a single sink's text goes
through verbatim; multiple sinks merge into a JSON object keyed by node name,
the same convention ParallelAgent uses.
Failure is collect-all, like ParallelAgent: a failed node marks its
transitive descendants as skipped (independent branches still run to
completion), then one aggregate RunFailed names both the failed and the
skipped nodes. No new AgentEvent variant is added for a skip — it's
surfaced only in that aggregate error.
#![allow(unused)] fn main() { use paigasus_helikon::core::GraphAgent; let graph = GraphAgent::builder() .name("finance_report") .description("Fans spending and income analysis into a combined summary.") .node("spending", spending_agent) // impl Agent<Ctx> + 'static (or via .shared_node) .node("income", income_agent) .node("summary", summary_agent) .edge("spending", "summary") // spending → summary .edge("income", "summary") // income → summary .build()?; // GraphBuildError on cycle/unknown/duplicate/empty }
The runnable version of this example (a spending/income → summary diamond) is
crates/paigasus-helikon/examples/graph_report.rs.
See also
- Core Primitives — the
Agent<Ctx>trait these all share. - The Agent Loop — how
LlmAgentdrives a single run and its events. - Tools — defining the
Tool<Ctx>typesAgentAsToolplugs into. - CLI — the
agents.tomlsidecar'shandoffsfield builds one-wayHandoffchains (cycles rejected at validation, unlike a full-meshSwarmAgent).
Permissions, Guardrails & Hooks
Three governance layers ship in paigasus-helikon-core, each addressing a different concern:
- Permissions authorize tool calls (may this tool run with these args?).
- Guardrails validate input and output content (is this text safe?).
- Hooks intercept lifecycle events (observe, deny, or rewrite around each step).
They are orthogonal. A tool call passes the permission pipeline; a hook can still veto or rewrite it; guardrails gate the surrounding text. All three are typed traits — no stringly-typed policy DSL.
Permissions
A tool call is authorized by the pipeline deny rules › guard rules › allow rules › mode › policy › AskUser, evaluated in that order. The pieces:
PermissionMode— a#[non_exhaustive]enum:Default(defer to policy; permissive when no policy),AcceptEdits(auto-approve tools whoseToolEffectisWrite),Plan(deny any tool whoseToolEffectis notReadOnly),Bypass(allow all — deny rules still apply), andDontAsk(deny-by-default headless lockdown — the policy is never invoked; only an allow rule can permit a call). Mode is tighten-only:RunContext::with_permission_moderefuses to loosenBypass(it may only tighten toDontAsk), andDontAskis terminal.BypassandDontAskboth propagate to sub-agents.DenyRule— a first-class rule evaluated before mode, so it overrides evenBypass. Matches by exact tool name (DenyRule::tool("Bash")), by Bash sub-command program (DenyRule::bash_command("rm")), or by filesystem path glob (DenyRule::read(".env")/DenyRule::edit("dist/**")— see Allow rules & filesystem path rules below). See also Guard rules below for the higher-level Bash-command matcher.PermissionPolicy<Ctx>— thecanUseTooltrait. Its asynccheckreturns aPermissionDecision:Allow,Deny { reason },AskUser { prompt }, orReplace { args }(sanitize the call's arguments before execution).ApprovalHandler— resolves anAskUserdecision out of band. Itsdecidereturns anApprovalOutcome(AlloworDeny { reason }) — it cannot recursively ask. With no approval handler installed,AskUserresolves to deny.
Permissions attach to the RunContext:
#![allow(unused)] fn main() { use std::sync::Arc; use async_trait::async_trait; use paigasus_helikon::core::{ ApprovalHandler, ApprovalOutcome, DenyRule, PermissionDecision, PermissionMode, PermissionPolicy, RunContext, }; // A policy that asks before any tool touching the network. struct AskOnNetwork; #[async_trait] impl PermissionPolicy<()> for AskOnNetwork { async fn check( &self, _ctx: &RunContext<()>, tool: &str, _args: &serde_json::Value, ) -> PermissionDecision { if tool == "WebFetch" { PermissionDecision::AskUser { prompt: format!("Allow {tool}?") } } else { PermissionDecision::Allow } } } // An approval handler that auto-approves (a real one would prompt a human). struct AutoApprove; #[async_trait] impl ApprovalHandler for AutoApprove { async fn decide( &self, _tool: &str, _prompt: &str, _args: &serde_json::Value, ) -> ApprovalOutcome { ApprovalOutcome::Allow } } let ctx: RunContext<()> = RunContext::ephemeral(()) .with_permission_mode(PermissionMode::AcceptEdits) .with_deny_rules(vec![DenyRule::tool("Bash")]) .with_permission_policy(Arc::new(AskOnNetwork)) .with_approval_handler(Arc::new(AutoApprove)); }
with_permission_mode, with_deny_rules, with_permission_policy, and with_approval_handler are consuming builder methods on RunContext; the corresponding readers are permission_mode, deny_rules, permission_policy, and approval_handler. A tool's ToolEffect (ReadOnly, Write, or SideEffect) is what AcceptEdits and Plan mode test against — see Tools.
Guard rules & the destructive-command breaker
Guard rules sit above the permission pipeline. A GuardRule has an action — Deny or Ask — and is evaluated before permission mode. Even PermissionMode::Bypass does not skip them.
An always-on built-in set, GuardRule::destructive_defaults(), is installed on every RunContext automatically. It blocks two classes of command:
- Recursive removes at catastrophic paths.
rm -rf /andrm -rf ~(the home directory) are blocked by default. - Writes to protected system paths. Commands that write to
/etc,/usr,/bin,/sbin,/sys,/boot,/dev, or/are blocked. A device-node allowlist exempts the common redirect target> /dev/null(and other/dev/null,/dev/stderr,/dev/stdoutforms) so ordinary output suppression is never blocked.
The default action for a matched rule is Ask, which resolves to Deny when no ApprovalHandler is installed — the common headless/CI configuration.
Behavior change. In
Defaultmode with no policy and no approval handler — the typical unattended setup — a command matching a destructive guard now resolves to Deny rather than running silently. To restore interactive behavior, install anApprovalHandler(the runner will prompt before blocking). To disable the guards entirely, callRunContext::without_default_guards():#![allow(unused)] fn main() { let ctx = RunContext::ephemeral(()) .without_default_guards(); }
Operator-aware deny matching
DenyRule::bash_command("rm") matches when any sub-command of a compound command resolves to that program. The matcher:
- Splits the command string on
&&,||,;,|,|&,&, and newlines. - Strips fixed wrappers —
timeout,nice,nohup,stdbuf,env,command,sudo,doas— and their flags. - Unquotes the program token.
- Follows
bash -c '…'/sh -c '…'re-entry to a bounded depth.
As a result, echo ok && rm -rf ., sudo rm -rf /, and bash -c 'rm -rf /' are all caught by a single DenyRule::bash_command("rm").
BashTool's own deny_commands/allow_commands lists use the same matcher, with a defined composition rule:
- deny list — the command is denied if any sub-command's program is denied.
- allow list — the command is permitted only if every sub-command's program is in the list.
Allow rules & filesystem path rules
AllowRule is the positive counterpart of DenyRule. A matching allow rule
resolves the call to Allow after the deny and guard steps but before
mode — in every mode — and canUseTool is not consulted for it. It is a
global, all-modes, per-tool pre-approval, so prefer the narrow forms:
AllowRule::tool("WebSearch")— allow a tool by name.AllowRule::bash_command("git")— allow a Bash call only when every sub-command's program isgit(fail-closed on a mixed compound command).AllowRule::read("src/**")/AllowRule::edit("src/**")— allowRead/Edit+Writewhosepathmatches a gitignore-style glob.
DenyRule gains the same path forms: DenyRule::read(".env") blocks reads of
.env at any depth; DenyRule::edit("dist/**") blocks writes under dist.
Under DontAsk, allow rules are the only way a call is permitted:
#![allow(unused)] fn main() { let ctx = RunContext::ephemeral(()) .with_allow_rules(vec![ AllowRule::tool("WebSearch"), AllowRule::edit("src/**"), ]) .with_permission_mode(PermissionMode::DontAsk); }
Path rules are advisory, not a sandbox. Core has no filesystem root, so a
path rule is a lexical match on the path argument (.. is collapsed and
matching is case-insensitive, but the real boundary is the cap-std root in
paigasus-helikon-tools). Pattern syntax: a pattern without a / matches at
any depth (.env, *.pem); a pattern with a / is anchored to the root
(src/**). Because an allow rule short-circuits the mode, a matched
AllowRule::edit("src/**") permits a write even under Plan's read-only gate —
intended, since an allow rule is a pre-approval.
The .git/.ssh/.env write breaker
A third built-in guard joins destructive_defaults(): a write whose target has
a .git or .ssh path component, or a final component .env/.env.*, is
Ask (→ Deny when headless). Component-exact and case-insensitive, so
name.git/, .gitignore, and environment.env are unaffected while .SSH/
and .ENV still trip. It runs before mode and before allow rules, so a .git/
write is refused even under AcceptEdits and even with a matching
AllowRule::edit(".git/**"). Disabled by without_default_guards().
Guardrails
A Guardrail<Ctx> validates content flowing into or out of the agent. Its single async method check receives a GuardrailInput<'_> — either UserText(&str) (text entering the agent) or ModelOutput(&str) (text leaving it) — and returns Result<GuardrailVerdict, GuardrailError>:
GuardrailVerdict::Pass— all clear; the run continues.GuardrailVerdict::Tripwire { kind, info }— the run halts.kindis aGuardrailKind(InputPolicy,OutputPolicy, orOther { reason });infois free-form JSON. A tripwire is a successful verdict, not an error.GuardrailError— a failure of the guardrail itself. The runner treats a guardrail error as a tripwire of kindGuardrailKind::Other.
Guardrails attach to the agent, separately for input and output:
#![allow(unused)] fn main() { use async_trait::async_trait; use paigasus_helikon::core::{ Guardrail, GuardrailError, GuardrailInput, GuardrailKind, GuardrailVerdict, LlmAgent, RunContext, }; struct BlockSecrets; #[async_trait] impl Guardrail<()> for BlockSecrets { async fn check( &self, _ctx: &RunContext<()>, input: GuardrailInput<'_>, ) -> Result<GuardrailVerdict, GuardrailError> { let text = match input { GuardrailInput::UserText(t) | GuardrailInput::ModelOutput(t) => t, }; if text.contains("BEGIN PRIVATE KEY") { Ok(GuardrailVerdict::Tripwire { kind: GuardrailKind::InputPolicy, info: serde_json::json!({ "matched": "private key" }), }) } else { Ok(GuardrailVerdict::Pass) } } } let agent = LlmAgent::builder::<()>() .name("assistant") // .model(...).instructions(...) .input_guardrail(BlockSecrets) .output_guardrail(BlockSecrets) .build(); }
The builder also exposes shared_input_guardrail / shared_output_guardrail (taking a pre-wrapped Arc<dyn Guardrail<Ctx>>) and input_guardrails / output_guardrails (replacing the whole list from an iterator).
Hooks
A Hook<Ctx> observes lifecycle events and can steer the run. Its async on_event receives a &HookEvent and returns a HookDecision. Hooks are observation and side effects — distinct from permissions (authorization) and guardrails (content).
HookEvent is a #[non_exhaustive] enum covering the run lifecycle: OnRunStart, OnTurnStart { turn }, PreToolUse { tool, args }, PostToolUse { tool, output }, OnHandoff { from, to }, OnRunComplete, and OnSubagentStop { agent }. (OnRunComplete is best-effort — a cancelled run may abort a still-running completion hook.)
HookDecision is also #[non_exhaustive]:
Allow— proceed unchanged.Deny { reason }— block the event; the reason is surfaced to the agent.ReplaceInput { value }— rewrite the value the runner is about to use (e.g. sanitizePreToolUseargs).ReplaceOutput { value }— rewrite the value the runner just observed (e.g. redactPostToolUseoutput).InjectSystemMessage { text }— inject a system message into the next model call.
When several hooks fire for one event, the runner folds the decisions: the first Deny short-circuits, ReplaceInput/ReplaceOutput is last-writer-wins, and InjectSystemMessage accumulates in fire order.
Hooks attach in two places. Per-agent hooks go on the builder via hook (or shared_hook / hooks); run-level hooks go in the HookRegistry<Ctx> carried by the RunContext. Agent-level hooks fire before run-level ones.
#![allow(unused)] fn main() { use async_trait::async_trait; use std::sync::Arc; use paigasus_helikon::core::{ Hook, HookDecision, HookEvent, HookRegistry, LlmAgent, RunContext, }; struct AuditLog; #[async_trait] impl Hook<()> for AuditLog { async fn on_event(&self, _ctx: &RunContext<()>, event: &HookEvent) -> HookDecision { if let HookEvent::PreToolUse { tool, .. } = event { eprintln!("about to call tool: {tool}"); } HookDecision::Allow } } // Per-agent: let agent = LlmAgent::builder::<()>() .name("assistant") // .model(...).instructions(...) .hook(AuditLog) .build(); // Or run-level, via the registry on the RunContext: let mut registry = HookRegistry::<()>::new(); registry.push(Arc::new(AuditLog)); }
HookRegistry is the run-level container: new, push, iter, and is_empty. It is passed to RunContext via .with_hooks(registry) (or supplied automatically by RunContext::ephemeral, which installs an empty registry) and is shared (cloned) across handed-off and sub-agent contexts.
Secret redaction
On by default, tool output is scrubbed of secret-shaped strings before it re-enters the model context and the session trajectory. Redaction runs as the final transform on PostToolUse output — after any user PostToolUse hook.
Two matchers run in sequence:
- Key-name patterns. Lines matching
KEY=value,KEY: value, orexport KEY=valuewhereKEYends (case-insensitively) in_API_KEY,_TOKEN,_SECRET,_PASSWORD, or_CREDENTIALhave the value portion replaced with***. - Known-secret value scan. Literal occurrences of known secret values — the parent process's secret-named env vars, plus any strings registered via
RunContext::with_extra_secrets(…)— are replaced with***. A length floor (≥ 8 characters, common English words excluded) prevents over-matching from corrupting ordinary output.
To add application-specific secrets to the scan:
#![allow(unused)] fn main() { let ctx = RunContext::ephemeral(()) .with_extra_secrets(vec!["my-api-key-value".to_string()]); }
To disable redaction entirely:
#![allow(unused)] fn main() { let ctx = RunContext::ephemeral(()) .without_output_redaction(); }
Scope & limitations
The v1 Bash guard and deny-matching are pragmatic, not based on a full POSIX shell parser. Known limitations:
- Command substitution is not parsed. Tokens inside
$(…)or backtick expressions are not inspected. find -exec/find -delete,xargs, andevalare not followed into their arguments; only the top-level program name is matched.- Variable-indirect command strings (
eval "$VAR",$CMD arg) are not resolved — the matcher sees literal pre-expansion tokens. - Shell-expanded globs and variables in the command string are not expanded before matching.
bash -cre-entry is followed to a bounded depth only; deeply nested shells are not fully traced.rm -rf <protected-prefix>— only/(root) and~(home directory) are guarded against recursive removal. Subtrees such as/etcor/usrare protected only against writes, notrm -rf.- Relative redirect targets such as
> ../../etc/passwdare not canonicalized; only absolute protected paths are matched on the write-guard. - Redaction limitations: only the first
KEY=/KEY:occurrence per line is processed; value-scan matching is case-sensitive; key-name matching requires underscore form (X_API_KEY, notX-API-KEY) and does not scan JSON object keys.
How they compose
For a single tool call, the layers run in this order:
- The
PreToolUsehook fires — it may deny orReplaceInputthe args. - The permission pipeline authorizes the (possibly rewritten) call:
deny rules › guard rules › allow rules › mode › policy › AskUser. - The tool runs; the
PostToolUsehook fires — it mayReplaceOutput. - Secret redaction runs as the final transform on the output before it enters the model context.
Input guardrails gate user text before the loop begins; output guardrails gate the final model text before it is returned. See The Agent Loop for where each seam sits in the run, and Multi-Agent Patterns for how Bypass mode and the shared registry propagate across handoffs.
MCP Integration
The paigasus-helikon-mcp crate (facade feature mcp, re-exported as paigasus_helikon::mcp) wraps rmcp, the official Rust MCP SDK, in both directions:
- Client — connect to an external MCP server and re-expose its tools to an agent as core
Tool<Ctx>implementations. - Server — wrap any
Agent<Ctx>and serve it as a single MCP tool.
Two transports are supported: a stdio child process and streamable HTTP. SSE transports are not supported — rmcp removed them in 0.11.0 and the 2025-03-26 MCP spec revision deprecated HTTP+SSE in favor of streamable HTTP.
The whole crate is published on crates.io / docs.rs. McpServerHandle implements the core ToolSource<Ctx> trait, so you can register handles directly on the agent builder with .mcp_servers([fs, ...]) (or .tool_source(handle)) and finalize with .build_resolved().await?, which discovers and merges the remote tools in one step. A duplicate tool name across sources fails the build with ToolSourceError::DuplicateName. The explicit .tools(handle.tools::<Ctx>()) path remains valid as a lower-level alternative when you need direct access to the Vec<Arc<dyn Tool<Ctx>>>.
Client: external tools into an agent
McpServerHandle is the connection handle. Construct it with one of three transport entry points, each returning an McpConnectBuilder:
McpServerHandle::stdio(command, configure)— spawn a child process speaking MCP over stdio.configureis anFnOnce(&mut tokio::process::Command)that sets args, env, and cwd before the spawn.McpServerHandle::child_process(transport)— bring a fully builtrmcp::transport::TokioChildProcessfor explicit lifecycle control.McpServerHandle::streamable_http(uri)— dial a streamable-HTTP server. For auth headers or retry tuning, build aStreamableHttpClientTransportConfigand useMcpServerHandle::streamable_http_with_config.
McpConnectBuilder carries two options — .tool_prefix(prefix) and .lazy(bool) (see below) — and .connect() runs the MCP initialize handshake and fetches the tool list in one paginated sweep.
#![allow(unused)] fn main() { async fn demo() -> Result<(), Box<dyn std::error::Error>> { use paigasus_helikon_mcp::McpServerHandle; let fs = McpServerHandle::stdio(tokio::process::Command::new("npx"), |cmd| { cmd.args(["-y", "@modelcontextprotocol/server-filesystem", "/data"]); }) .tool_prefix("fs_") .connect() .await?; // Discovery already happened at connect, so `tools()` is synchronous. let tools = fs.tools::<()>(); // Vec<Arc<dyn Tool<()>>> // .tools(tools) on an LlmAgent::builder, then run the agent. let _ = tools; Ok(()) } }
Ergonomic alternative — register the handle directly on the builder:
#![allow(unused)] fn main() { async fn demo() -> Result<(), Box<dyn std::error::Error>> { use paigasus_helikon_core::LlmAgent; use paigasus_helikon_mcp::McpServerHandle; let fs = McpServerHandle::stdio(tokio::process::Command::new("npx"), |cmd| { cmd.args(["-y", "@modelcontextprotocol/server-filesystem", "/data"]); }) .connect() .await?; // `.mcp_servers([...])` accepts any `ToolSource<Ctx>` — `McpServerHandle` implements it. // `.build_resolved().await?` resolves all sources and merges their tools. // A duplicate tool name across sources returns `ToolSourceError::DuplicateName`. let _agent = LlmAgent::builder::<()>() .name("assistant") .model(/* any Model impl */) .mcp_servers([fs]) .build_resolved() .await?; Ok(()) } }
tools::<Ctx>() adapts each remote tool to a McpTool<Ctx>. Ctx is a phantom — MCP tools never read the user context, so one handle serves agents of any context type. Each adapted tool reports the remote tool's name (with the configured prefix), description, input schema, and an effect derived from the server's annotations: a read_only_hint == true becomes ToolEffect::ReadOnly, everything else ToolEffect::SideEffect. Server-declared annotations are untrusted metadata, so ToolEffect::Write is never produced — an MCP tool can never unlock AcceptEdits auto-approval (see Permissions, Guardrails & Hooks).
When invoked, a tool issues an MCP tools/call and maps the result into a ToolOutput: a server's structured_content is passed through as-is; a single text result becomes a JSON string; multiple content blocks become a JSON array. An MCP error result (is_error) surfaces as ToolError. Calls race the run's CancellationToken, so a hung server can't outlive a cancelled agent run.
McpServerHandle is cheap to clone; the connection (and any stdio child process) lives until the last clone is dropped — including the clones held inside the tools themselves — or handle.close() is called.
Lazy mode
McpConnectOptions::new().lazy(true) (via the builder's .lazy(true)) trades eager schemas for a smaller initial prompt. In lazy mode every adapted tool advertises a placeholder schema ({"type": "object", "additionalProperties": true}) and an extra search_tools meta-tool is appended to the returned list. search_tools takes {"query": string}, matches the query case-insensitively against the cached tool names and descriptions, and returns {"tools": [...], "total_matches": N} — each entry carrying the matched tool's real name, description, and full input schema. The list is capped at 20 entries; total_matches always reports the pre-cap count, and "truncated": true is added to the envelope only when more than 20 matched. The prefix applies to the meta-tool too, so a "fs_" prefix yields fs_search_tools. This keeps a server with thousands of tools from flooding the agent's tool list.
Server: an agent over MCP
McpAgentServer<Ctx> wraps one Agent<Ctx> and serves it as a single MCP tool. The tool's name is the agent's name (sanitized to [a-zA-Z0-9_-]+), its description is the agent's description, and its input schema is {"input": string}; calling it runs the agent and returns the final text output.
Each request needs a fresh user context, so a context factory is required before serving:
McpAgentServer::new(agent)then.with_ctx(factory), wherefactory: Fn() -> Ctx, orMcpAgentServer::with_default_ctx(agent)whenCtx: Default.
Builder methods configure the rest: .name(..) and .version(..) set the MCP Implementation reported at initialize, .instructions(..) sets the optional MCP instructions, and .with_run_config(RunConfig) configures each request's agent run. RunConfig::timeout is enforced at this boundary (core's collect() has no timer); on expiry the call returns an MCP error result rather than hanging. A client disconnect or notifications/cancelled propagates into the run's CancellationToken.
#![allow(unused)] fn main() { use async_trait::async_trait; use futures_core::stream::BoxStream; use paigasus_helikon_core::{Agent, AgentError, AgentEvent, AgentInput, RunContext}; struct MyAgent; #[async_trait] impl Agent<()> for MyAgent { fn name(&self) -> &str { "assistant" } fn description(&self) -> &str { "answers questions" } async fn run(&self, _ctx: RunContext<()>, _input: AgentInput) -> Result<BoxStream<'static, AgentEvent>, AgentError> { Ok(Box::pin(futures_util::stream::empty())) } } async fn demo() -> Result<(), Box<dyn std::error::Error>> { use paigasus_helikon_mcp::McpAgentServer; let server = McpAgentServer::with_default_ctx(MyAgent) .name("my-agent-server") .version("0.1.0"); // Block on stdio until the client disconnects: server.serve_stdio().await?; Ok(()) } }
For HTTP, serve_streamable_http(addr) binds a port and mounts the MCP endpoint at /mcp. To embed the agent in an existing hyper/axum router instead, streamable_http_service() returns a tower StreamableHttpService<AgentMcpHandler<Ctx>, LocalSessionManager> you can nest_service yourself. serve_transport(transport) is the escape hatch for any other rmcp server transport (in-process duplex transports, custom sockets).
Errors
The crate's own error type is McpError (#[non_exhaustive]): variants cover the connect/initialize handshake (Connect), child-process spawn (Spawn), in-flight requests (Service), HTTP bind (Bind), and abnormal server termination (Serve), plus an Other (#[error(transparent)]) catch-all for anything else. Client-side tool failures never surface as McpError — they map to core's ToolError, because an agent only ever sees the Tool trait. (rmcp's own protocol-error type, which it aliases McpError, is referred to here as ErrorData to avoid the name clash.)
Runtime note
Connect child-process transports from a multi-thread tokio runtime (#[tokio::main]'s default). Under a current-thread runtime the initialize handshake can stall against the child's pipe I/O.
See Tools for the Tool<Ctx> trait these adapters implement, and the crate reference for version and feature details.
Observability & Evaluation
Two separable concerns share this chapter. Observability is shipped:
the agent loop emits OpenTelemetry-compatible spans following GenAI semantic
conventions, and you bring your own collector. Evaluation is shipped
too, in paigasus-helikon-evals: JSONL datasets, an Evaluator trait with
four built-ins, a MockModel for deterministic replay, and SQLite/Parquet
trace sinks for offline analysis.
Observability
Helikon does not embed a tracing backend. The agent loop emits spans through the
tracing crate; you choose the exporter, collector, and dashboard. This is the
"bring your own observability stack" stance — wire the spans into whatever OTel
pipeline you already run (Langfuse, Jaeger, Honeycomb, an OTLP collector, or a
plain fmt subscriber for local debugging).
TracerHandle — per-run trace attributes
TracerHandle (re-exported as paigasus_helikon::core::TracerHandle) is the
carrier for run-scoped trace attributes that the loop stamps onto the run and
turn spans. It holds three optional Langfuse-flavored fields: a session_id, a
user_id, and a list of tags.
An empty handle comes from TracerHandle::default(); a populated one is built
through TracerHandle::builder(), which returns a TracerHandleBuilder:
#![allow(unused)] fn main() { use paigasus_helikon::core::TracerHandle; let tracer = TracerHandle::builder() .with_session_id("demo-session") .with_user_id("demo-user") .with_tag("example") .with_tag("prod") .build(); assert_eq!(tracer.session_id(), Some("demo-session")); assert_eq!(tracer.user_id(), Some("demo-user")); assert_eq!(tracer.tags(), &["example", "prod"]); }
The handle is passed to RunContext via .with_tracer(tracer) — or use
RunContext::ephemeral(()).with_tracer(tracer) when you want all other defaults.
The loop reads it back via RunContext::tracer and emits the configured
session.id, user.id, and tags onto the trace. TracerHandleBuilder is a
consuming builder — its with_* methods take and return self.
Exporting to an OTel backend
Spans flow through tracing, so any tracing-subscriber layer collects them.
The langfuse_tracing example
(crates/paigasus-helikon/examples/langfuse_tracing.rs, run with the
runtime-tokio feature) shows the full path: build an OTLP SpanExporter,
install it as a tracing-opentelemetry layer, then run the agent through
TokioRunner so the run/turn/tool spans land in Langfuse.
The wiring (subscriber setup, abridged from the example):
#![allow(unused)] fn main() { use opentelemetry::trace::TracerProvider as _; use opentelemetry_otlp::{WithExportConfig, WithHttpConfig}; use opentelemetry_sdk::trace::{BatchSpanProcessor, SdkTracerProvider}; use tracing_subscriber::prelude::*; let otlp = opentelemetry_otlp::SpanExporter::builder() .with_http() .with_endpoint(format!("{host}/api/public/otel/v1/traces")) .with_headers(std::collections::HashMap::from([( "Authorization".to_string(), format!("Basic {auth}"), )])) .build()?; let provider = SdkTracerProvider::builder() .with_span_processor(BatchSpanProcessor::builder(otlp).build()) .build(); let tracer = provider.tracer("paigasus-helikon"); tracing_subscriber::registry() .with(tracing_opentelemetry::layer().with_tracer(tracer)) .init(); }
With the subscriber installed, the run produces the trace tree
invoke_agent → agent.turn → chat / execute_tool, with token counts on the
chat observation and the session.id / user.id / tags from the
TracerHandle on the trace. The opentelemetry*, tracing-opentelemetry, and
tracing-subscriber crates are the user's choice — they are not Helikon
dependencies, which keeps paigasus-helikon-core tracing-only and lets you
swap in any exporter.
The example's runtime-tokio feature pulls in TokioRunner, which installs the
TracerHandle on the run context for you. See
the agent loop for how the runner drives a run, and
crates reference for what each crate ships.
Evaluation
paigasus-helikon-evals runs a dataset of cases through an agent, scores
each case's outcome with one or more evaluators, and aggregates the results
into a report — in CI (helikon eval run) or from an integration test.
EvalDataset — JSONL cases
EvalDataset::from_jsonl_path/from_jsonl_str load one EvalCase per line
(blank lines skipped): an input (the user-turn text), an optional
expected value (string or JSON, for final-response comparison), an
optional expected_tools (tool-call names in order, for trajectory
comparison), and free-form metadata. A case without an explicit id gets
case-<line#>.
Evaluator and the four built-ins
#![allow(unused)] fn main() { #[async_trait::async_trait] pub trait Evaluator: Send + Sync { fn name(&self) -> &str; async fn evaluate(&self, case: &EvalCase, outcome: &CaseOutcome) -> Result<Score, EvalError>; } }
Each Score is a value in [0, 1] plus a ScoreOutcome of Passed,
Failed, or Skipped. Skipped is a distinct outcome, not a failure — an
evaluator whose required case field is absent skips rather than fails, and
skips count toward neither pass/fail nor the summary mean, so a
misconfigured dataset (e.g. no expected_tools anywhere) shows up as a
visible skip count instead of a silent no-op.
| Evaluator | Scores | Skips when |
|---|---|---|
ExactMatch | Trimmed string equality against expected (optionally case-insensitive via .case_insensitive()); structural JSON equality when expected is non-string JSON. | expected is absent. |
JsonSchemaConformance | Parses the final output as JSON and validates it against a constructor-supplied JSON Schema (draft 2020-12). | Never — independent of the case. |
LlmJudge | Wraps an Arc<dyn Model> + rubric; asks for {"score": 0..1, "reasoning": "…"} and passes at or above a threshold (default 0.7). Unparseable judge output fails (0.0) rather than erroring the run. | Never — independent of the case. |
ToolUseTrajectory | Extracts the observed tool-call name sequence and compares it to expected_tools, .exact() (position-for-position) or .in_order() (subsequence); transfer_to_* handoff calls are filtered out by default (.include_handoffs() re-enables them). | expected_tools is absent. |
MockModel and ScriptFile — deterministic replay
MockModel is a scripted Model: with_script/with_scripts hand it one
Vec<ModelEvent> per invoke call, popped in order; running out yields a
ModelError rather than looping. ScriptFile::load (and
MockModel::from_script_file) parse a JSON file of serde mirror types
(ScriptEvent/ScriptFinishReason — core's own ModelEvent/FinishReason
deliberately don't derive serde) with a "default" script set plus an
optional "cases" map keyed by case id, so one file can drive a whole
dataset deterministically.
MockModel is stateful (it pops from an internal queue), so sharing one
mock-backed agent across cases is order-dependent under concurrency. Use
EvalRun::agent_factory — build a fresh agent (and fresh MockModel) per
case, selecting scripts by case.id — whenever the model is a mock;
.agent()/.shared_agent() remain for genuinely stateless or live agents.
EvalRun
let report = EvalRun::builder()
.dataset(EvalDataset::from_jsonl_path(Path::new("triage.jsonl"))?)
.agent_factory(|case| build_agent_for(&case.id)) // fresh agent per case — see above
.default_ctx() // or .ctx_factory(...) for a non-Default Ctx
.evaluator(ExactMatch::new())
.evaluator(ToolUseTrajectory::exact())
.concurrency(4) // default 1 (sequential, deterministic order)
.run()
.await?;
assert!(report.passed());
println!("{}", report.render_table());
Each case runs on a fresh ephemeral RunContext (fresh in-memory session)
through TokioRunner by default (override with .runner(...) for another
execution backend). Results come back in dataset order regardless of
concurrency — EvalRun re-sorts by original index after the concurrent
buffer drains. EvalReport is Serialize (for --json output) and has a
plain-text render_table() for terminals; EvalReport::passed() is true iff
no case failed, where a case fails on any evaluator yielding Failed or
a run-level error (the agent run itself failed before evaluators ran).
Trace sinks
TraceSink records every case's result once the whole run completes, in
dataset order (not progressively as each case finishes — cases run
concurrently and EvalRun re-sorts by original index before any recording
starts) — feature-gated so the crate stays lean by default:
SqliteTraceSink(featuretrace-sqlite) writeseval_runs/eval_cases/eval_eventstables via an embedded sqlx migration. Events are persisted in the canonicalSessionEventform (via core'sSessionRecorder), not the rawAgentEventUI-stream shape — a stabler, audit-grade schema shared with the session backends.ParquetTraceSink(featuretrace-parquet) writes<dir>/<run_id>-events.parquetand<dir>/<run_id>-scores.parquetwith flat columnar schemas, for offline analysis with any Parquet-reading tool.
helikon eval run
The CLI's eval run subcommand wraps all of the above:
helikon eval run <dataset.jsonl> --agent NAME loads an agents.toml
sidecar, builds the named agent per case (so mock providers stay
deterministic), runs the configured [eval].evaluators, and prints
render_table() (or --json), exiting non-zero when any case failed. See
the CLI reference for the
full flag grammar and the [eval] sidecar section.
Structured Output & Builder
LlmAgent is constructed through a typestate builder. The same builder also
configures structured output: ask the agent to return a typed Rust value
instead of free text by deriving a schema for your output type and reading it
back off the result.
The typestate builder
LlmAgent::builder::<Ctx>() returns an LlmAgentBuilder. Two setters are
required before .build() is in scope: .name(…) and .model(…). The
builder tracks these with typestate markers (NoName/HasName,
NoModel/HasModel); .build() only exists on the
HasName, HasModel state, so forgetting either is a compile error, not a
runtime panic.
The setters:
| Method | Purpose |
|---|---|
.name(impl Into<String>) | Agent name. Required; transitions to HasName. |
.model(m) / .shared_model(Arc<M>) | The Model impl. Required; transitions to HasModel. |
.instructions(i) / .shared_instructions(Arc<…>) | System-prompt renderer. |
.description(impl Into<String>) | Human-readable description; used by handoff routing. |
.tool(t) / .shared_tool(Arc<…>) / .tools(iter) | Tool registry (append vs. replace). |
.handoff(a) / .shared_handoff(Arc<…>) / .handoffs(iter) | Handoff candidates. |
.hook(h) / .hooks(iter) | Lifecycle hooks. |
.input_guardrail(g) / .output_guardrail(g) (+ shared_* / plural) | Guardrails. |
.model_settings(ModelSettings) | Per-call provider knobs. |
.max_turns(u32) | Per-run turn budget. |
.output_type::<T>() | Switch the output type to T (see below). |
.build() | Finalize into LlmAgent<Ctx, M, T>. Only on HasName, HasModel. |
The * family follows a consistent convention: the singular form
(.tool, .hook, .handoff, …) appends and wraps an owned value in an
Arc; the shared_* form takes a pre-wrapped Arc without re-wrapping; the
plural form (.tools, .hooks, …) replaces the whole list from an
IntoIterator. See Core Primitives for Tool,
Handoff, and Hook; Tools for the tools![…] macro that feeds
.tools(…).
Typed output: output_type + collect_typed
A default agent returns text: RunResultStreaming::collect() yields a
RunResult<String>. To get a typed value instead:
- Define an output struct that derives
serde::Deserializeandschemars::JsonSchema. - Call
.output_type::<T>()on the builder.Tis inferred into theLlmAgent<Ctx, M, T>type parameter, and the builder capturesT's JSON Schema. - Drain the run with
.collect_typed::<T>()instead of.collect().result.final_outputis then aT.
output_type is repeatable and any-state — the last call wins, and it does not
disturb the HasName/HasModel markers.
use paigasus_helikon::anthropic::AnthropicModel; use paigasus_helikon::core::{ Agent, AgentInput, LlmAgent, RunContext, RunResultStreaming, }; #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] struct TransactionCategory { /// Spending category, e.g. "Groceries", "Dining", "Transport". category: String, /// 0.0–1.0 confidence in the category. confidence: f32, /// True if this looks like a recurring charge. recurring: bool, /// One-sentence justification. reasoning: String, } #[tokio::main] async fn main() -> anyhow::Result<()> { let model = AnthropicModel::messages("claude-sonnet-4-6").build()?; let agent = LlmAgent::builder::<()>() .name("transaction-categorizer") .model(model) .instructions( "You are a personal-finance assistant. Categorize the transaction \ into a single spending category, say whether it looks recurring, \ and express your confidence as a number between 0.0 and 1.0.", ) .output_type::<TransactionCategory>() .build(); let ctx: RunContext<()> = RunContext::ephemeral(()); let input = AgentInput::from_user_text("NETFLIX.COM 866-579-7172 CA — $15.49"); let stream = agent.run(ctx, input).await?; let result = RunResultStreaming::new(stream) .collect_typed::<TransactionCategory>() .await?; // result.final_output is a TransactionCategory. println!("{:#?}", result.final_output); Ok(()) }
The doc-comments on each field flow into the schema (schemars maps them to
description), so they double as guidance to the model. This example is the
shipped structured_output example; run it with
cargo run -p paigasus-helikon --features anthropic --example structured_output.
How it works under the hood
.output_type::<T>() stores an OutputType built by
OutputType::from_schema::<T>(). That carrier holds three things: the schema
name (derived from the schema title, defaulting to "StructuredOutput"),
T's raw schemars::Schema, and a validator closure that proves a JSON value
deserializes back into the original T.
On a finalizing turn, the agent loop derives a ResponseFormat::JsonSchema
from the OutputType — the schema, its name, and strict: true — and sets it
as the request's ModelSettings::response_format. ResponseFormat is the
provider-neutral knob (with Text and JsonObject variants for the looser
cases); each provider maps it onto its native structured-output shape. The
model's terminal text is validated against the captured validator; if it does
not parse or does not match, the loop synthesizes a repair instruction and asks
the model again before surfacing failure.
collect_typed::<T>() then deserializes the terminal assistant text into T.
If a structured run fails validation, the error surfaces as
AgentError::InvalidStructuredOutput carrying the schema errors and the
offending text; calling collect_typed on a plain-text run (or any other parse
failure) surfaces AgentError::Other.
Strict-mode schema normalization
OpenAI's strict structured-output mode imposes extra constraints on the schema:
every object needs additionalProperties: false, and every property must be
listed in required (optional fields use a nullable type rather than absence).
schemars output does not satisfy this by default, so the OpenAI provider runs
the schema through paigasus_helikon::schema::strict — the canonical
strict-mode normalizer, re-exported from
paigasus_helikon_core::schema::strict. Anthropic uses schemas as-is. You can
call the normalizer directly if you assemble a ResponseFormat::JsonSchema by
hand:
#![allow(unused)] fn main() { use paigasus_helikon::schema::strict; let normalized = strict(&raw_schema); // raw_schema: &serde_json::Value }
Note one current limitation: the normalizer does not traverse $defs/$ref.
schemars emits $defs for enums, recursive types, and types referenced more
than once, so schemas with those constructs are not fully rewritten — flat and
nested struct outputs are the well-supported shape today.
See Model Providers for how each provider maps
ResponseFormat, The Agent Loop for the run lifecycle, and
the API docs for the full LlmAgentBuilder,
OutputType, and RunResultStreaming signatures.
Runtimes
paigasus-helikon-core's Runner trait is the seam between the agent-loop state machine (core::transition, driven inline by LlmAgent::run) and where a run actually executes. Four crates implement or host that seam today, each trading off differently on durability, deployment shape, and operational complexity:
| Crate | Feature | What it is | When to use it |
|---|---|---|---|
paigasus-helikon-runtime-tokio | runtime-tokio | Default ephemeral in-process runner | A single process, no crash-resume needs, no network exposure required |
paigasus-helikon-runtime-axum | runtime-axum | Self-hosted HTTP/SSE/WebSocket agent server | You need a network-accessible agent server with replayable runs, and you own the deployment |
paigasus-helikon-runtime-temporal | runtime-temporal | Durable runner backed by the Temporal Rust SDK | A run must survive a worker crash or restart — long-running, multi-tool-call agents where losing progress is expensive |
paigasus-helikon-runtime-agentcore | runtime-agentcore | AWS Bedrock AgentCore container shim | You want a managed, serverless-ish deployment target on AWS and are fine with AgentCore's container/microVM contract |
The first two are covered in their own chapters — see Axum Server Runtime for the self-hosted HTTP/SSE/WebSocket server. This page covers the other two, both shipped in the same release (SMA-332).
paigasus-helikon-runtime-temporal — durable runner
TemporalRunner implements Runner, but instead of driving core::transition in-process, it starts a Temporal workflow and awaits its result. Each model turn and each tool call becomes a Temporal activity; Temporal's event-history replay reconstructs the loop's position after a worker crash, so a run that crashes mid-tool-call resumes from the last completed activity rather than re-executing the whole run.
Architecture in one paragraph
The client-side TemporalRunner::run() starts an internal agent-loop workflow on a configured task queue and awaits its outcome. The workflow is a thin, deterministic adapter over a pure DurableDriver (SDK-free, unit-testable) that drives core::transition exactly like the ephemeral loop does, but requests a call_model activity per model turn and one invoke_tool activity per tool call (started concurrently, bounded by parallel_tool_call_limit) instead of calling Model::invoke/Tool::invoke directly. A TemporalAgentWorker process registers the agent's tools/model/settings and serves those activities.
v0 constraint set (rejected at registration, fail-fast)
- Hooks and guardrails — arbitrary user async code cannot be made deterministic inside a workflow.
- Handoffs — agent-to-agent transfers are not supported by the durable driver.
- Nested agent-as-tool runs — permitted, but execute non-durably inside their tool activity: a crash-retry re-executes the whole nested run.
Worker-side security posture
The caller's RunContext (tenant data, auth claims, permission rules) does not cross the client→worker boundary — it isn't serializable in general. The worker fabricates its own tool-side context from its configuration, and that configuration — not the caller's — is authoritative for permission enforcement and output redaction on every tool call the worker executes. Treat Temporal's durable history as a persistence boundary: redaction applies before a tool result is recorded, exactly as it does in the ephemeral loop.
That worker-side posture is configurable, not fixed: a WorkerPosture value set via TemporalAgentWorkerBuilder::posture(...) groups the permission mode, deny/allow/guard rules, a permission policy, an approval handler, the built-in destructive guards, output redaction, and extra secrets into one builder call (WorkerPosture::default() reproduces the original fixed defaults exactly, so an unconfigured worker behaves as before). A client can also attach a small, explicit, serializable seed (TemporalRunnerConfig::with_ctx_seed) that the worker's seeded Ctx factory reconstitutes per run — request-scoped caller data, never posture — enabling a worker-registered permission policy to make per-tenant authorization decisions without ever serializing the policy itself (when the posture mode reaches the policy — Bypass and DontAsk short-circuit it). See the crate docs (§ "Worker-Side Posture and Security Boundary") for the full contract, including the fail-fast-on-bad-seed guarantee.
Retry, heartbeats, and payload notes
Model errors are non-retryable at the Temporal layer (per ADR-10 — wrap the model in runtime-tokio's RetryingModel for retries); tool errors are returned as data and never fail the activity; a crash mid-tool-call is retried by Temporal, so tools must be idempotent unless configured with max_attempts: 1. Opt-in heartbeats (TemporalAgentWorkerBuilder::heartbeat_interval) speed up reclaiming a crashed worker's in-flight call_model/invoke_tool attempt, but can also trip on a live worker whose executor is starved by a blocking tool call — offload blocking work via tokio::task::spawn_blocking. Each activity payload carries the full conversation-so-far (plus the Ctx seed, if set, on every render_instructions/invoke_tool call), so plan for roughly 1.5 MB of JSON as the practical v0 budget (~15–20 turns with tool outputs ≤ 50 KB each).
See the crate README and its crate-level docs for the full contract, worked examples, and live-validation instructions (temporal server start-dev + an env-gated integration suite covering crash-resume).
paigasus-helikon-runtime-agentcore — managed AWS deployment
AgentCoreServer doesn't implement Runner itself — like runtime-axum, it's a hosting shim: an axum app that wraps a Runner (TokioRunner by default) behind the exact HTTP contract AWS Bedrock AgentCore's Runtime expects, so the same agent can be deployed as a managed AgentCore container without hand-rolling the platform's endpoints.
AgentCore recognizes two container protocols; this crate implements both:
| Protocol | Bind | Endpoint(s) | Contract highlight |
|---|---|---|---|
| HTTP (default) | 0.0.0.0:8080 | POST /invocations (JSON or SSE), GET /ping | /ping returns exactly {"status":"Healthy"} / {"status":"HealthyBusy"}; session pinning via X-Amzn-Bedrock-AgentCore-Runtime-Session-Id (33–256 chars) |
MCP (feature mcp, default on) | 0.0.0.0:8000 | POST /mcp (stateless streamable-HTTP) | Platform-injected Mcp-Session-Id must be accepted without ever having been issued by this server |
Containers are linux/arm64 mandatory, ship from a private ECR repository, and are capped at 2 GB / 2 vCPU / 8 GB. This crate's docker/Dockerfile builds a scratch/musl image; measured on the ticket's validation run, the dependency-free echo_http example built to 1.31 MB and the model-backed agent_http example (real provider client, rustls/aws-lc-rs, statically linked) to 3.27 MB — both comfortably under the 30 MB size gate — with app-exec-to-/ping-ready cold starts of 9–11 ms, well under the 50 ms gate. (AWS's own microVM provisioning, documented at roughly 2–5 seconds, happens platform-side before the container's entrypoint ever runs, and is outside what this measurement — or this crate — can influence.) See docs/runbooks/agentcore-image-check.md for the full measurement procedure.
AWS publishes a stable CDK L2 construct for deployment: aws-cdk-lib/aws-bedrockagentcore's Runtime construct takes an AgentRuntimeArtifact.fromEcrRepository(...) and exposes .addEndpoint(...); the same Runtime accepts protocolConfiguration: ProtocolType.MCP to switch it into MCP mode. The full snippet lives in the crate README.
Client disconnects: both /invocations transports — buffered JSON and SSE — drive the run on a detached task and hold a CancellationToken drop-guard, so a client that walks away mid-run cancels the run and still gets its turn finalized into the Session. The persisted turn is partial (whatever the run had produced when it was cancelled), which is the deliberate trade: a disconnected client should not keep burning model tokens. Note that agentcore has no per-session serialization lock (unlike runtime-axum), so a cancelled run's finalize can overlap a retry of the same session id — AgentCore pins a session to a container, which makes genuinely concurrent same-session invocations unlikely enough in practice that the lock isn't worth its cost here.
Termination is abrupt — AgentCore documents no SIGTERM contract. In HTTP mode, durable conversation state belongs in a Session backend (SQLite/Postgres/Redis), never in container memory. In MCP mode this doesn't apply the same way: each MCP call gets a fresh, unshared in-memory session by construction, so MCP-mode AgentCore cannot use a persistent session backend in v0 at all — that's a known v0 limitation, not a deployment mistake to fix via configuration.
Choosing between them
- Need a network endpoint you host and operate yourself, with no crash-resume requirement?
runtime-axum. - Need a run to survive a worker crash or a long-running multi-hour agent to keep progress across restarts?
runtime-temporal. - Deploying onto AWS Bedrock AgentCore's managed Runtime platform specifically?
runtime-agentcore— note its HTTP mode still delegates toTokioRunnerunder the hood (no crash-resume of its own; pair withruntime-temporal'sRunnerif both properties are needed simultaneously, though that combination is untested as of this writing). - Everything else (tests, single-shot scripts, embedding in your own async runtime)?
runtime-tokio, the default.
Axum Server Runtime
paigasus-helikon-runtime-axum mounts one or more Agents on an axum router and exposes them over HTTP, Server-Sent Events (SSE), and WebSocket. It is the self-hosted alternative to paigasus-helikon-runtime-tokio's in-process runner — suitable when you need a network-accessible agent server with replayable runs.
Enable it via the runtime-axum facade feature:
cargo add paigasus-helikon --features openai,runtime-axum
Or depend on the crate directly:
cargo add paigasus-helikon-runtime-axum
Quick start
use std::sync::Arc;
use paigasus_helikon::runtime_axum::AgentServer;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let server = AgentServer::<()>::builder()
.with_default_context() // Ctx = () satisfies Default
.agent(Arc::new(my_agent))
.build()?;
server.serve("0.0.0.0:8080").await?;
Ok(())
}
AgentServer::builder() returns an AgentServerBuilder that lets you chain configuration. Once built, call .serve(addr) to bind and start serving, or .router() to embed the axum Router inside a larger application.
HTTP endpoints
The server exposes four routes (six endpoint modes) under a flat prefix (no configurable base path):
| Method | Path | Purpose |
|---|---|---|
POST | /agents/{name}/runs | Start a run — one-shot (default), SSE (?stream=sse), or async (?mode=async) |
GET | /agents/{name}/runs/{id}/events | Replay a run's event log over WebSocket |
GET | /agents | List all registered agents and their descriptions |
GET | /openapi.json | OpenAPI 3.1 schema (requires the openapi feature, enabled by default) |
Response shapes for POST /agents/{name}/runs
The ?stream= and ?mode= query parameters select the response transport:
| Query | Status | Body |
|---|---|---|
| (none) | 200 OK | RunResponse JSON — full event list + final output, after run completes |
?stream=sse | 200 OK | text/event-stream — each AgentEvent as an SSE frame, streamed live |
?mode=async | 202 Accepted | AsyncAccepted JSON — { "run_id": "…" } returned immediately |
All responses include an X-Run-Id response header carrying the UUID of the run.
Request body
POST /agents/{name}/runs accepts JSON in either of two shapes:
{ "input": "What is my dining budget this month?" }
or an explicit multi-turn message list:
{ "messages": [ { "type": "user_message", "content": [{ "type": "text", "text": "…" }] } ] }
Session affinity
Callers pass X-Session-Id: <opaque-string> to pin a run to a named session. The default InMemorySessionProvider maps that header to a shared MemorySession; two requests with the same X-Session-Id share history and are serialised (the second waits until the first run completes) to avoid race conditions on the shared session state.
Requests without X-Session-Id receive a fresh anonymous session that is never stored.
Replayable runs
Every run — regardless of the transport used to start it — drains into an in-memory EventLog. The key properties:
- One-shot mode subscribes to the log and blocks until the run is terminal.
- SSE mode subscribes and streams events as they arrive; a client reconnect to
GET /agents/{name}/runs/{id}/events(WebSocket) replays already-emitted events before tailing live ones. - Async mode returns
202immediately and the run continues in a background task. The log survives connection close. - Cancellation: one-shot and SSE responses hold a
CancellationTokendrop-guard so a client disconnect cancels the run. The async mode deliberately does not, so the run outlives the connection. - Stream error frames: if a run ends without a real terminal event — e.g. its runner fails to start after the run is registered, or its stream ends early — the SSE and WebSocket transports emit a final synthetic
run_failedevent before closing, so a streaming client always observes a terminal frame. (One-shot mode instead returns HTTP500on a start error, or200with a partial result when a started run ends without a terminal event.)
Completed runs are retained for a configurable period and count:
| Builder method | Default | Effect |
|---|---|---|
.run_retention(Duration) | 5 minutes | How long completed runs stay in the registry |
.max_retained_runs(usize) | 1 024 | Cap on retained completed runs (oldest evicted first) |
.max_sessions(usize) | 4 096 | Cap on tracked named sessions (oldest evicted first) |
A background sweeper task is started by .serve() / .serve_with_listener() to prune expired entries.
Provider traits
Three traits are the extension points for operator customisation:
SessionProvider
#[async_trait]
pub trait SessionProvider: Send + Sync {
async fn session(&self, id: Option<&str>) -> Result<Arc<dyn Session>, ServerError>;
}
Maps the X-Session-Id header value to a Session. The built-in InMemorySessionProvider is the default; swap it for a PostgresSession or RedisSession backend via .session_provider(Arc::new(...)) on the builder.
ContextProvider<Ctx>
#[async_trait]
pub trait ContextProvider<Ctx>: Send + Sync {
async fn build(
&self,
parts: &axum::http::request::Parts,
session: Arc<dyn Session>,
cancel: CancellationToken,
) -> Result<RunContext<Ctx>, ServerError>;
}
Builds the per-request RunContext. Implement this to inject request-scoped data into Ctx — for example, JWT-parsed tenant identity — and to tighten the permission posture for network clients (see the security note below). When Ctx: Default, use the convenience shortcut .with_default_context() on the builder instead of supplying a custom implementation.
AuthLayer
#[async_trait]
pub trait AuthLayer: Send + Sync {
async fn authenticate(&self, parts: &mut axum::http::request::Parts) -> Result<(), AuthRejection>;
}
Called before every request. Return Ok(()) to allow; return Err(AuthRejection { status, message }) to reject. On success, you may insert an identity value into parts.extensions — the ContextProvider receives the same parts, creating the auth→context bridge. When .auth(...) is not called on the builder, all requests are admitted without authentication.
Security note
The DefaultContextProvider leaves all RunContext settings at their core defaults. For production deployments:
- Implement
ContextProviderand call.with_permission_mode(PermissionMode::Deny)to prevent agents from escalating tool permissions at runtime. - Supply a custom
ApprovalHandlerthat enforces your tenant's access-control list. - Attach a
HookRegistryfor telemetry and policy enforcement.
The openapi feature
The openapi feature (enabled by default) activates the GET /openapi.json endpoint, which serves an OpenAPI 3.1 schema generated with utoipa. Disable it if you do not need the schema endpoint:
cargo add paigasus-helikon-runtime-axum --no-default-features
Embedding in a larger router
.router() returns a plain axum Router without binding any socket. Use this to nest the agent endpoints under a prefix or combine them with your own routes:
let app = axum::Router::new()
.nest("/api/v1", server.router())
.route("/healthz", axum::routing::get(|| async { "ok" }));
axum::serve(listener, app).await?;
API reference
Full per-item documentation: paigasus_helikon_runtime_axum.
Facade re-export: enable the runtime-axum feature on paigasus-helikon and import via paigasus_helikon::runtime_axum::*.
Crate overview
The workspace is 19 crates under crates/, all named paigasus-helikon-* (plus the paigasus-helikon facade itself). This page is the version-bearing map: one row per crate, what it owns, whether it is published, and how the crates depend on each other.
For orientation — how to pick crates and add them to your Cargo.toml — see workspace layout. For the rendered rustdoc, see API docs.
Dependency direction
paigasus-helikon-coreis the root: it owns the trait surface, the agent loop, the event stream, and the carrier types. It depends on no other workspace crate.- The provider, session, tool, MCP, and runtime crates each depend on
coreand on nothing else in the workspace (-toolscarries a path-only dev-dep on-providers-openaifor an example; it is stripped from the published manifest). paigasus-helikon-macrosis a proc-macro crate; its#[tool]expansion targetscoretypes in the consumer's crate.paigasus-helikonis the facade: it re-exportscoreunconditionally and the sibling crates behind Cargo features. Application crates normally depend on the facade alone and turn on the features they need.paigasus-helikon-cliconsumescoreand the sibling crates it needs (-evals,-runtime-tokio,-providers-openai,-providers-anthropic,-mcp) directly — not the facade. It publishes to crates.io at0.1.0as a binary crate; its lib target is internal (missing_docsopted out) and carries no stability guarantee, publishing only socargo install paigasus-helikon-cliresolves.
Crate table
Versions below are current as of 2026-07-06 and move every release — read each crate's Cargo.toml (or the root [workspace.dependencies] pins) for the live numbers, and the crates.io page / docs.rs for what is actually published.
| Crate | Concern | State | Version |
|---|---|---|---|
paigasus-helikon-core | Trait surface, agent loop, event stream, carrier types — the dependency root | published | 0.5.4 |
paigasus-helikon | Facade — re-exports core always, siblings behind features | published | 0.4.14 |
paigasus-helikon-macros | #[tool] attribute and tools! proc macros | published | 0.2.2 |
paigasus-helikon-providers-openai | OpenAI model adapter (OpenAiModel) | published | 0.2.9 |
paigasus-helikon-providers-anthropic | Anthropic model adapter (AnthropicModel) | published | 0.1.10 |
paigasus-helikon-providers-bedrock | Amazon Bedrock Converse API model adapter (BedrockModel) | published | 0.1.0 |
paigasus-helikon-providers-gemini | Google Gemini model adapter (GeminiModel; Developer API + Vertex AI) | published | 0.1.0 |
paigasus-helikon-sessions-sqlite | SQLite-backed Session backend | published | 0.1.11 |
paigasus-helikon-sessions-postgres | PostgreSQL-backed Session backend (PostgresSession) | published | 0.1.0 |
paigasus-helikon-sessions-redis | Redis Streams-backed Session backend (RedisSession) | published | 0.1.0 |
paigasus-helikon-runtime-tokio | Default ephemeral Tokio runner | published | 0.1.9 |
paigasus-helikon-runtime-axum | Self-hosted Axum HTTP/SSE/WebSocket agent server (AgentServer builder, 6 endpoints, replayable runs) | published | 0.1.0 |
paigasus-helikon-runtime-temporal | Durable Temporal-backed runner (TemporalRunner; crash-resume via Temporal history replay) | published | 0.1.0 |
paigasus-helikon-runtime-agentcore | AWS Bedrock AgentCore container shim (AgentCoreServer; HTTP + MCP protocol contract) | published | 0.1.0 |
paigasus-helikon-mcp | MCP integration — rmcp client and server wrappers | published | 0.1.3 |
paigasus-helikon-tools | Sandboxed Read/Write/Edit/Bash tools (+ WebFetch/WebSearch behind web) | published | 0.1.5 |
paigasus-helikon-evals | Evaluation harness — datasets, evaluators, MockModel, SQLite/Parquet trace sinks | published | 0.1.0 |
paigasus-helikon-cli | helikon / paigasus-helikon CLI binaries; lib target is internal, no stability guarantee | published (binary crate) | 0.1.0 |
paigasus-helikon-sessions-testkit | Shared Session conformance test harness (internal — never published) | internal — publish = false | 0.0.0 |
Every crate above the -sessions-testkit row publishes to crates.io — the last two stubs (-evals, -cli) ascended to real implementations in SMA-332/SMA-333, following the four-remaining-crates ascend before them (-runtime-axum, -runtime-temporal, -runtime-agentcore). paigasus-helikon-sessions-testkit is the sole publish = false crate, and it is an intentional internal test harness rather than a stub awaiting an ascend.
Facade feature → re-export map
Add the facade and turn on the features you need. Each feature gates one sibling crate behind a module on paigasus_helikon:::
| Feature | Re-export | Crate pulled in |
|---|---|---|
| (always on) | paigasus_helikon::core | paigasus-helikon-core |
macros | paigasus_helikon::macros, paigasus_helikon::tool, paigasus_helikon::tools | paigasus-helikon-macros |
openai (alias providers-openai) | paigasus_helikon::openai | paigasus-helikon-providers-openai |
anthropic | paigasus_helikon::anthropic | paigasus-helikon-providers-anthropic |
bedrock | paigasus_helikon::bedrock | paigasus-helikon-providers-bedrock |
gemini | paigasus_helikon::gemini | paigasus-helikon-providers-gemini |
mcp | paigasus_helikon::mcp | paigasus-helikon-mcp |
tools | paigasus_helikon::tools | paigasus-helikon-tools |
tools-web | adds WebFetch/WebSearch | enables paigasus-helikon-tools/web |
sessions-sqlite | paigasus_helikon::sessions_sqlite | paigasus-helikon-sessions-sqlite |
sessions-postgres | paigasus_helikon::sessions_postgres | paigasus-helikon-sessions-postgres |
sessions-redis | paigasus_helikon::sessions_redis | paigasus-helikon-sessions-redis |
runtime-tokio | paigasus_helikon::runtime_tokio | paigasus-helikon-runtime-tokio |
runtime-axum | paigasus_helikon::runtime_axum | paigasus-helikon-runtime-axum |
runtime-temporal | paigasus_helikon::runtime_temporal | paigasus-helikon-runtime-temporal |
runtime-agentcore | paigasus_helikon::runtime_agentcore | paigasus-helikon-runtime-agentcore |
evals | paigasus_helikon::evals | paigasus-helikon-evals |
Feature names are kebab-case (tools-web, runtime-tokio); the re-export module aliases are snake-case (runtime_tokio, sessions_sqlite).
Two distinct items share the path paigasus_helikon::tools. With the macros feature it is the tools! macro; with the tools feature it is the sandboxed-tools crate module. They live in different namespaces, so Rust resolves them by use site (a tools!(...) macro call vs. a tools:: path) — but be explicit about which you mean.
The facade also exposes the paigasus_helikon::schema::strict() function, the JSON-Schema strict-mode normalizer (fn strict(value: &Value) -> Value), independent of any feature.
API docs
Per-item Rust API documentation is published on docs.rs. This book covers concepts and worked examples; docs.rs is the source of truth for every type, trait, method, and feature flag. For a higher-level map of which crate owns which concern, see Crate overview.
Published crates
paigasus-helikon— the facade; re-exportscoreand the feature-gated siblings.paigasus-helikon-core— trait surface, agent loop, event stream, carrier types (the dependency root).paigasus-helikon-macros— the#[tool]attribute andtools!proc macros.paigasus-helikon-providers-openai— OpenAI model adapter.paigasus-helikon-providers-anthropic— Anthropic model adapter.paigasus-helikon-sessions-sqlite— SQLiteSessionbackend.paigasus-helikon-runtime-tokio— ephemeral Tokio runner.paigasus-helikon-runtime-axum— self-hosted HTTP/SSE/WebSocket agent server (AgentServerbuilder, 6 endpoints, replayable runs). See Axum Server Runtime.paigasus-helikon-runtime-temporal— durable Temporal-backed runner (TemporalRunner, crash-resume via Temporal history replay). See Runtimes.paigasus-helikon-runtime-agentcore— AWS Bedrock AgentCore container shim (AgentCoreServer, HTTP + MCP protocol contract). See Runtimes.paigasus-helikon-mcp—rmcp-based MCP client/server wrapper.paigasus-helikon-tools— sandboxedRead/Write/Edit/Bashtools (plusWebFetch/WebSearchbehind thewebfeature).paigasus-helikon-evals— evaluation harness: JSONL datasets, theEvaluatortrait with four built-ins,MockModel, and SQLite/Parquet trace sinks. See Observability & Evaluation.paigasus-helikon-cli—helikon/paigasus-helikonCLI binaries; publishes a lib target purely socargo install paigasus-helikon-cliresolves, but that lib is internal and carries no stability guarantee. The binaries are documented in the CLI reference rather than docs.rs.
Most users depend only on the paigasus-helikon facade and enable the features they need; the facade docs link out to each sibling. Crate versions move every release — see Crate overview for the current numbers.
Publish status
All 18 non-internal crates now publish to crates.io — the last two stubs (paigasus-helikon-evals, paigasus-helikon-cli) ascended to real implementations in SMA-332/SMA-333, following -runtime-axum, -runtime-temporal, and -runtime-agentcore before them. The lone exception is paigasus-helikon-sessions-testkit, an internal Session conformance test harness that is publish = false by design, not a stub awaiting an ascend.
Building locally
cargo doc --workspace --all-features --no-deps --open
CLI
paigasus-helikon-cli ships two binaries — helikon and a paigasus-helikon
shim alias for the same command tree — built around a single TOML "sidecar"
file (conventionally agents.toml) that declares agents, their tools, and
optional eval configuration. Ctx = () throughout: the CLI is a batteries-included
runner for declarative agents, not a library for embedding custom context types
(for that, depend on the paigasus-helikon
facade directly).
Install
cargo install paigasus-helikon-cli
This installs both binaries. They are identical — paigasus-helikon exists
so the crate name and the binary name match (the facade library and this
binary intentionally share the paigasus-helikon name; see the crate
overview for why).
helikon --help
paigasus-helikon --help # same binary, same command tree
Subcommands
helikon repl [--agents agents.toml] [--agent NAME]
helikon eval run <dataset.jsonl> --agent NAME
[--agents agents.toml] [--json] [--fail-under F]
[--trace sqlite:PATH]
helikon mcp serve --agent NAME
[--agents agents.toml] [--http ADDR]
--agents defaults to ./agents.toml everywhere, so running from a project
root that contains the sidecar needs no extra flags.
helikon repl
Starts an interactive, hot-reloading REPL against the sidecar.
| Flag | Default | Meaning |
|---|---|---|
--agents PATH | agents.toml | The sidecar file to load. |
--agent NAME | alphabetically-first agent | Which agent to start the conversation with. |
helikon eval run <dataset>
Runs a JSONL dataset against one sidecar agent and prints trajectory and final-response scores.
| Flag | Default | Meaning |
|---|---|---|
<dataset> | (required, positional) | Path to the JSONL dataset (one EvalCase per line). |
--agent NAME | (required) | Which sidecar agent to evaluate. |
--agents PATH | agents.toml | The sidecar file to load. |
--json | off | Print the full EvalReport as JSON instead of a plain-text table. |
--fail-under F | none | Additionally fail (exit non-zero) if the mean non-skipped score is below F. |
--trace sqlite:PATH | none | Record every case into a SQLite trace database at PATH (parent directory must exist). |
Exit code is non-zero when any case failed (EvalReport::passed() is false)
or, if --fail-under is set, when the mean score misses the threshold.
helikon mcp serve
Exposes one sidecar agent as an MCP server.
| Flag | Default | Meaning |
|---|---|---|
--agent NAME | (required) | Which sidecar agent to serve. |
--agents PATH | agents.toml | The sidecar file to load. |
--http ADDR | none (stdio) | Serve over streamable HTTP at ADDR instead of stdio. |
The agents.toml sidecar
One file declares every agent, the tools they can call, and (optionally) how to evaluate them:
[agents.triage]
description = "Routes personal-finance questions"
instructions = "Classify the question and route it to the right specialist."
model = { provider = "openai", id = "gpt-5-mini" } # reads OPENAI_API_KEY
tools = ["lookup_spending"]
handoffs = ["budgeting"]
[agents.budgeting]
description = "Answers budgeting questions"
instructions = "Answer using the spending lookup tool when asked about spending."
model = { provider = "mock", script = "triage_script.json" }
tools = ["lookup_spending"]
[tools.lookup_spending]
description = "Look up spending for a month"
params = { type = "object", properties = { month = { type = "string" } }, required = ["month"] }
script = "tools/lookup_spending.rhai"
[eval]
evaluators = ["exact_match", "tool_trajectory"]
# [eval.json_schema] schema = "schemas/answer.json"
# [eval.llm_judge] model = { provider = "openai", id = "gpt-5-mini" }, rubric = "…", threshold = 0.7
Key points:
model.provideris one of"openai","anthropic", or"mock". The first two resolveOPENAI_API_KEY/ANTHROPIC_API_KEYfrom the environment at build time and fail fast with a clear error if the key is missing."mock"loads a recorded script file (the sameScriptFileformat thepaigasus-helikon-evalscrate uses — see Observability & Evaluation) — no network, no credentials, fully deterministic. Note: thebudgetingagent above uses"mock"purely to keep this example runnable without an API key; swap in"openai"/"anthropic"for a live agent.instructionsis either an inline string (as above) or{ file = "path.md" }, loaded relative to the sidecar's directory.toolsreference[tools.<name>]entries by name. Each tool is a sandboxed Rhai script exposingfn run(args), given either asscript = "path.rhai"orinline = '''...'''(exactly one of the two). Scripts run under an operation-count cap and have no file or network access — a runaway or malicious script fails loudly instead of wedging the process.handoffsnames other agents in the same file, wired intoHandoff::to(...)chains exactly like theHandoffpattern in the core library.[eval]configureshelikon eval run:evaluatorsnames built-in evaluators (exact_match,tool_trajectory,json_schema,llm_judge);json_schema/llm_judgeneed their own config block when named. See Observability & Evaluation for what each evaluator scores.
Handoff cycles are rejected, not tolerated. Unlike a full Rust-API
SwarmAgent/Handoffgraph, the sidecar validateshandoffsat load time with a cycle check: an agent that (transitively) hands off back to itself fails to parse with "handoff cycle detected … declare one-way handoff chains", rather than being allowed to build (which would create reference cycles in a short-lived CLI process). Declare one-way handoff chains inagents.toml; if you need a true multi-directional pool, useSwarmAgentfrom the Rust API instead.
REPL: slash commands and hot reload
helikon repl loads the sidecar into an AgentRegistry and watches its
directory for changes (debounced ~300ms). Commands:
| Command | Effect |
|---|---|
/agents | List every agent declared in the sidecar, marking the current one. |
/switch NAME | Switch the active agent for the next turn. |
/reload | Force an immediate re-parse of the sidecar file. |
/quit / /exit | Leave the REPL. |
| any other text | A conversational turn for the current agent. |
Hot reload happens automatically too — editing and saving the sidecar (or
any file it references) triggers a reload with no /reload needed. Two
details matter in practice:
- The in-flight turn is unaffected. Each turn builds a fresh agent from whatever definitions are current when the turn starts; a reload that lands mid-turn takes effect starting with the next turn, not the one in progress.
- A parse error keeps the old definitions. If the edited sidecar fails to parse or fails validation (bad TOML, dangling tool/handoff reference, a handoff cycle), the REPL prints the error and keeps serving the last-known-good definitions — a broken save never kills the session.
- The default agent is the alphabetically first one, not the first one
written in the file (agent names are stored in a sorted map internally).
Pass
--agent NAMEto pick a specific starting agent.
See also
- Multi-Agent Patterns — the
Handoffsemantics the sidecar'shandoffsfield builds on. - Observability & Evaluation — the
evals crate that
helikon eval runand[eval]are built on. - Crate overview —
paigasus-helikon-cli's published state and dependencies.
Decisions
Architectural decisions are currently captured as design docs alongside their Linear tickets, stored under docs/superpowers/specs/ in the repository.
The SDK now ships published releases on crates.io. A formal ADR/MADR section here — promoting the decisions that affect the public API into stable, citable records — is the planned next step.