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::builder assembles an Agent; agent.run(ctx, input) returns an event stream you collect with RunResultStreaming. Core carrier types (RunContext, AgentInput, HookRegistry, MemorySession, CancellationToken, TracerHandle) live in paigasus_helikon::core.
  • Providers. paigasus_helikon::openai::OpenAiModel (feature openai) and paigasus_helikon::anthropic::AnthropicModel (feature anthropic). Switching providers is a one-line Model swap on the builder.
  • Tools. The #[tool] attribute and tools! macro (feature macros) turn an async fn into a registered, JSON-Schema-typed tool. The tools feature adds sandboxed Read/Write/Edit/Bash tools; tools-web adds the WebFetch/WebSearch network tools.
  • Sessions. MemorySession ships in core; paigasus_helikon::sessions_sqlite (feature sessions-sqlite) is a persistent SQLite Session backend.
  • Runtime. paigasus_helikon::runtime_tokio (feature runtime-tokio) is the default ephemeral Tokio runner. paigasus_helikon::runtime_axum (feature runtime-axum) is the self-hosted HTTP/SSE/WebSocket agent server built on axum — see Axum Server Runtime. paigasus_helikon::runtime_temporal (feature runtime-temporal) is a durable runner backed by the Temporal Rust SDK, and paigasus_helikon::runtime_agentcore (feature runtime-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 (feature mcp) is an rmcp-based MCP client/server wrapper. Guardrails, hooks, and permissions hang off the HookRegistry carried in every RunContext.
  • Evals. paigasus_helikon::evals (feature evals) is an evaluation harness: JSONL datasets, an Evaluator trait with four built-ins, a MockModel for 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-memory MemorySession, an empty HookRegistry, a default TracerHandle, and a fresh CancellationToken. 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_output is 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 run drives model calls, tool dispatch, and turn assembly.
  • Tools — the #[tool] attribute, the tools! macro, and ToolContext in 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.

FeatureRe-exported asSurface
(always on)paigasus_helikon::corepaigasus-helikon-core — traits, agent loop, event stream, carrier types
macrospaigasus_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
anthropicpaigasus_helikon::anthropicAnthropicModel adapter
mcppaigasus_helikon::mcprmcp-based MCP client/server
toolspaigasus_helikon::toolssandboxed Read/Write/Edit/Bash tools
tools-web(extends tools)adds the WebFetch / WebSearch network tools
runtime-tokiopaigasus_helikon::runtime_tokioephemeral Tokio runner
runtime-axumpaigasus_helikon::runtime_axumself-hosted HTTP/SSE/WebSocket agent server — see Axum Server Runtime
runtime-temporalpaigasus_helikon::runtime_temporaldurable Temporal-backed runner — see Runtimes
runtime-agentcorepaigasus_helikon::runtime_agentcoreAWS Bedrock AgentCore container shim — see Runtimes
sessions-sqlitepaigasus_helikon::sessions_sqliteSQLite Session backend
evalspaigasus_helikon::evalsevaluation 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 facade
  • paigasus-helikon-macros#[tool] + tools!
  • paigasus-helikon-providers-openai
  • paigasus-helikon-providers-anthropic
  • paigasus-helikon-providers-bedrock
  • paigasus-helikon-providers-gemini
  • paigasus-helikon-sessions-sqlite
  • paigasus-helikon-sessions-postgres
  • paigasus-helikon-sessions-redis
  • paigasus-helikon-runtime-tokio
  • paigasus-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-mcp
  • paigasus-helikon-tools
  • paigasus-helikon-evals — evaluation harness: datasets, evaluators, MockModel, SQLite/Parquet trace sinks
  • paigasus-helikon-cli — published binary crate (helikon / paigasus-helikon binaries); its lib target is internal and carries no stability guarantee, publishing only so cargo install paigasus-helikon-cli resolves

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

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 a BoxStream of ModelEvents. Capability differences (streaming, tools, structured output, vision, …) are surfaced through ModelCapabilities rather 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. Reports name(), description(), a JSON schema(), and an effect() (ToolEffect::ReadOnly / Write / SideEffect, used by the permission layer). async fn invoke(&self, ctx: &ToolContext<Ctx>, args: Value) returns a ToolOutput.
  • Session — conversation persistence as an append-only event log, not a flat message list. Three methods: append, events (optionally since a SequenceId), and snapshot (a ConversationSnapshot projection 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 check returns a GuardrailVerdict (Pass or Tripwire); a tripwire halts the run.
  • Hook<Ctx> — a lifecycle interceptor. async fn on_event(&self, ctx, event: &HookEvent) returns a HookDecision (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. Reports name() / description(); async fn run(&self, ctx: RunContext<Ctx>, input: AgentInput) returns a BoxStream<'static, AgentEvent>.
  • Runner<Ctx> — the pluggable execution backend (the durability seam). run / run_streamed drive an agent to a RunResult / RunResultStreaming, with resume / resume_streamed defaulting 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> — a canUseTool-style authorizer returning a PermissionDecision (Allow / Deny / AskUser / Replace). Sits behind the PermissionMode enum and first-class DenyRules.
  • ApprovalHandler — resolves a PermissionDecision::AskUser into an ApprovalOutcome. Non-generic (it needs no Ctx).
  • Instructions<Ctx> — renders the system prompt for a turn. Implemented for String, &'static str, and any Fn(&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-driven Agent. Construct via LlmAgent::builder::<Ctx>(), then .name(...), .model(...), .instructions(...), .tools(...), .handoffs(...), .output_type::<T>(), and .build().
  • MemorySession — an in-memory Session backed by a Mutex<Vec<SessionEvent>>, for tests and ephemeral runs. (For persistent storage, see paigasus-helikon-sessions-sqlite.)
  • Workflow agentsSequentialAgent (compose with .then(...) / .then_keyed(...)), ParallelAgent, and LoopAgent, each implementing Agent<Ctx> so they nest like any other agent.
  • RunContext<Ctx> — the per-run state shared across loop, tools, guardrails, and hooks (user context, Session handle, HookRegistry, TracerHandle, CancellationToken). Build with RunContext::new(user_ctx, session, hooks, tracer, cancel) (all five arguments explicit) or use RunContext::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(...). RunConfig carries per-run knobs (max_turns, timeout, parallel_tool_call_limit, max_agent_depth). RunResult<T> (and its streaming counterpart RunResultStreaming) aggregate the run's final_output, events, and TokenUsage.
  • Event and item carriersAgentEvent is the unified stream variant emitted by every Agent (lifecycle, raw TokenDelta / ReasoningDelta / ToolCallDelta, semantic MessageOutput / ToolCallItem / ToolOutputItem, HandoffItem, control signals, and terminal RunCompleted / RunFailed). Item / ContentPart are the conversation-message carriers; ModelEvent, ModelRequest, and ToolDef cross the model boundary; SessionEvent is the log entry a Session persists.

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 for turn.
  • 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 configured output_type.
  • RepairingOutput { turn, usage } — the one allowed repair turn after a failed structured-output validation.
  • Compacting and NeedsApproval { 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 as TransitionInput::ModelResponse.
  • ExecuteTools { calls } — fan out the tool calls, feed outcomes back as TransitionInput::ToolResults.
  • Handoff — read the target/transcript from ApplyingHandoff and 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 with MaxTurnsExceeded. Default 16. Honored by the core loop driver, including a bare agent.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. Default 8; exceeding it fails with MaxAgentDepthExceeded.
  • timeout: Option<Duration> — wall-clock deadline for the whole run. Honored only by a runtime backend such as the runtime-tokio runner; a bare agent.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:

  1. Your own tools — defined against the Tool trait, usually via the #[tool] attribute macro, and registered with the tools! macro.
  2. Ready-made sandboxed tools — filesystem and shell tools shipped in paigasus-helikon-tools (feature tools), plus network tools behind tools-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 enclosing LoopAgent.
  • 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 an anyhow::Error works).

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 tools name is overloaded: with the macros feature paigasus_helikon::tools is the tools! macro; with the tools feature 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 PermissionPolicy must 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 setrlimit so 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 EgressProxy is now implemented (SMA-437) but requires a live deployment — see docs/runbooks/forkd-live-validation.md in the repository. Do not enable microvm in 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 when ForkdBackend::builder(…).build() is called without .enforce_egress(…).
  • Isolation::Proxied (enforced) — all HTTP/S egress is domain-filtered at the EgressProxy (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.

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):

AxisMechanismGuarantee
FilesystemLandlock (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.
Networkseccomp-bpfsocket(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.
Syscallsseccomp-bpfA small deny-list of dangerous syscalls (ptrace, mount, pivot_root, chroot, setns, unshare, kexec_load, bpf, perf_event_open) always returns EPERM.
ResourcesetrlimitConfigured 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.

AxisMechanismGuarantee
FilesystemSeatbelt (sandbox-exec)Write-only containment: writes outside the sandbox root are denied at the OS layer; reads are unrestricted.
NetworkSeatbelt (sandbox-exec)All sockets denied by default (including AF_UNIX). Pass .allow_network(true) to allow all outbound traffic.
SyscallsNo syscall filter; Isolation::None.
ResourcesetrlimitSame 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 OsKernel label 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)",
}

HostBackend is NOT a security boundary. A command it runs can read and write anything the parent process can. Pair it with a PermissionPolicy or a DenyRule::tool("Bash") for approval-level control, or use OsSandboxBackend for 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 via ForkdBackend). The command runs inside a KVM guest; host filesystem and syscalls are inaccessible by construction. Network is separately gated — see Isolation::Proxied below.
  • 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, so Proxied is an operator attestation — the same trust model Virtualized uses for the hypervisor boundary. See ForkdBackendBuilder::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 via WebFetchTool::builder().
  • WebSearchTool (name "WebSearch") — runs a query through a swappable SearchBackend. Built via WebSearchTool::builder(backend); the crate provides BraveBackend and TavilyBackend implementations, with each hit modeled as a SearchResult.

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

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: accumulated messages, the tools the model may call this turn, and a ModelSettings of provider-tuning knobs.
  • ModelSettingstemperature, top_p, max_output_tokens, a tool_choice (ToolChoice), a response_format (ResponseFormat), and OpenAI Responses' previous_response_id.
  • ResponseFormatText, JsonObject, or JsonSchema { 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 terminal Finish { reason } (a FinishReason).
  • ModelCapabilities — the per-instance capability flags below.
  • ModelErrorUnavailable, 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 MistralResponseFormat::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:

  • $ref references are inlined recursively (cycles and excessive depth are terminated with a permissive {"type": "object"}).
  • oneOf/anyOf/allOf combinators (e.g. serde-generated tagged enums) are collapsed into a flat object whose properties union the variants' fields and whose shared tag key becomes an enum of 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 ModelRequest sets a ResponseFormat::JsonObject or ResponseFormat::JsonSchema while tools is non-empty or tool_choice is anything other than None, 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>;
}
}
  • append writes events to the end of the log.
  • events reads the log; since is exclusiveSome(SequenceId(n)) returns events strictly after position n, None returns the whole log.
  • snapshot returns a ConversationSnapshot — the canonical messages: Vec<Item> view that providers consume. Both shipped backends implement it as project over events(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:

VariantCarries
UserMessagecontent: Vec<ContentPart>
AssistantMessagecontent: Vec<ContentPart>, agent: String
ToolCalledcall_id, name, args: serde_json::Value
ToolReturnedcall_id, content: Vec<ContentPart>
HandoffOccurredfrom: String, to: String
Compactedsummary: 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 HeuristicTokenCounterceil(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:

  1. The new events' character estimate is added to a running cheap counter.
  2. When the cheap estimate suggests the threshold may be exceeded, the wrapper reads the inner session, projects it with project, and calls TokenCounter::count for the authoritative figure.
  3. If tokens > threshold, the wrapper sends the current projected messages plus a trailing UserMessage(prompt) to the model, collects the TokenDelta stream into a summary string. If the summary is whitespace-only the result is logged at warn! and skipped — no Compacted marker is appended. Otherwise SessionEvent::Compacted { summary, original_count } is appended to the inner session.
  4. 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 the Compacted marker) still propagate out of append.

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:

TestWhat it verifies
run_append_readEvents written by append are returned by events in order
run_watermark_exclusiveevents(Some(SequenceId(n))) returns only positions strictly after n
run_projectionsnapshot() equals project(&events(None))
run_concurrent_writers16 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's AgentInput, and seeds a SessionRecorder with 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 AgentEvent stream, accumulating assistant messages, tool calls/results, and handoffs as SessionEvents.
  • After the run, it drains the recorder and calls session.append(...). Persistence here is best-effort: an append error is logged via tracing and never propagated, so the run's outcome is unaffected. drain also synthesizes a ToolReturned for 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 transferHandoff: hand the conversation to another agent and let it finish the run. SwarmAgent builds 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 invocationAgentAsTool: call another agent like a tool, get its result back, and keep going.
  • Deterministic orchestrationSequentialAgent, 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…UseWhat crosses the boundary
Route to a specialist and let it own the rest of the runHandoffThe active agent switches; the specialist produces the final output
Call a sub-agent, read its answer, and keep reasoningAgentAsToolThe sub-agent's final_output comes back as a tool result
Run agents in a fixed order, each reading the lastSequentialAgentEach step's final text lands in state[key]
Fan out independent work concurrentlyParallelAgentEach branch writes state[key]; outputs merge into one JSON object
Refine until a sub-agent signals "done"LoopAgentSame as sequential, plus an escalate signal that stops the loop
Let a pool of specialists hand off among themselves until one answersSwarmAgentHandoffItem 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 predecessorsGraphAgentEach 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 LlmAgent drives a single run and its events.
  • Tools — defining the Tool<Ctx> types AgentAsTool plugs into.
  • CLI — the agents.toml sidecar's handoffs field builds one-way Handoff chains (cycles rejected at validation, unlike a full-mesh SwarmAgent).

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 whose ToolEffect is Write), Plan (deny any tool whose ToolEffect is not ReadOnly), Bypass (allow all — deny rules still apply), and DontAsk (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_mode refuses to loosen Bypass (it may only tighten to DontAsk), and DontAsk is terminal. Bypass and DontAsk both propagate to sub-agents.
  • DenyRule — a first-class rule evaluated before mode, so it overrides even Bypass. 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> — the canUseTool trait. Its async check returns a PermissionDecision: Allow, Deny { reason }, AskUser { prompt }, or Replace { args } (sanitize the call's arguments before execution).
  • ApprovalHandler — resolves an AskUser decision out of band. Its decide returns an ApprovalOutcome (Allow or Deny { reason }) — it cannot recursively ask. With no approval handler installed, AskUser resolves 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 / and rm -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/stdout forms) 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 Default mode 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 an ApprovalHandler (the runner will prompt before blocking). To disable the guards entirely, call RunContext::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:

  1. Splits the command string on &&, ||, ;, |, |&, &, and newlines.
  2. Strips fixed wrappers — timeout, nice, nohup, stdbuf, env, command, sudo, doas — and their flags.
  3. Unquotes the program token.
  4. 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 is git (fail-closed on a mixed compound command).
  • AllowRule::read("src/**") / AllowRule::edit("src/**") — allow Read / Edit+Write whose path matches 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. kind is a GuardrailKind (InputPolicy, OutputPolicy, or Other { reason }); info is 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 kind GuardrailKind::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. sanitize PreToolUse args).
  • ReplaceOutput { value } — rewrite the value the runner just observed (e.g. redact PostToolUse output).
  • 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:

  1. Key-name patterns. Lines matching KEY=value, KEY: value, or export KEY=value where KEY ends (case-insensitively) in _API_KEY, _TOKEN, _SECRET, _PASSWORD, or _CREDENTIAL have the value portion replaced with ***.
  2. 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, and eval are 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 -c re-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 /etc or /usr are protected only against writes, not rm -rf.
  • Relative redirect targets such as > ../../etc/passwd are 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, not X-API-KEY) and does not scan JSON object keys.

How they compose

For a single tool call, the layers run in this order:

  1. The PreToolUse hook fires — it may deny or ReplaceInput the args.
  2. The permission pipeline authorizes the (possibly rewritten) call: deny rules › guard rules › allow rules › mode › policy › AskUser.
  3. The tool runs; the PostToolUse hook fires — it may ReplaceOutput.
  4. 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. configure is an FnOnce(&mut tokio::process::Command) that sets args, env, and cwd before the spawn.
  • McpServerHandle::child_process(transport) — bring a fully built rmcp::transport::TokioChildProcess for explicit lifecycle control.
  • McpServerHandle::streamable_http(uri) — dial a streamable-HTTP server. For auth headers or retry tuning, build a StreamableHttpClientTransportConfig and use McpServerHandle::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), where factory: Fn() -> Ctx, or
  • McpAgentServer::with_default_ctx(agent) when Ctx: 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.

EvaluatorScoresSkips when
ExactMatchTrimmed string equality against expected (optionally case-insensitive via .case_insensitive()); structural JSON equality when expected is non-string JSON.expected is absent.
JsonSchemaConformanceParses the final output as JSON and validates it against a constructor-supplied JSON Schema (draft 2020-12).Never — independent of the case.
LlmJudgeWraps 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.
ToolUseTrajectoryExtracts 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 concurrencyEvalRun 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 (feature trace-sqlite) writes eval_runs / eval_cases / eval_events tables via an embedded sqlx migration. Events are persisted in the canonical SessionEvent form (via core's SessionRecorder), not the raw AgentEvent UI-stream shape — a stabler, audit-grade schema shared with the session backends.
  • ParquetTraceSink (feature trace-parquet) writes <dir>/<run_id>-events.parquet and <dir>/<run_id>-scores.parquet with 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:

MethodPurpose
.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:

  1. Define an output struct that derives serde::Deserialize and schemars::JsonSchema.
  2. Call .output_type::<T>() on the builder. T is inferred into the LlmAgent<Ctx, M, T> type parameter, and the builder captures T's JSON Schema.
  3. Drain the run with .collect_typed::<T>() instead of .collect(). result.final_output is then a T.

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:

CrateFeatureWhat it isWhen to use it
paigasus-helikon-runtime-tokioruntime-tokioDefault ephemeral in-process runnerA single process, no crash-resume needs, no network exposure required
paigasus-helikon-runtime-axumruntime-axumSelf-hosted HTTP/SSE/WebSocket agent serverYou need a network-accessible agent server with replayable runs, and you own the deployment
paigasus-helikon-runtime-temporalruntime-temporalDurable runner backed by the Temporal Rust SDKA run must survive a worker crash or restart — long-running, multi-tool-call agents where losing progress is expensive
paigasus-helikon-runtime-agentcoreruntime-agentcoreAWS Bedrock AgentCore container shimYou 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:

ProtocolBindEndpoint(s)Contract highlight
HTTP (default)0.0.0.0:8080POST /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:8000POST /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 to TokioRunner under the hood (no crash-resume of its own; pair with runtime-temporal's Runner if 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):

MethodPathPurpose
POST/agents/{name}/runsStart a run — one-shot (default), SSE (?stream=sse), or async (?mode=async)
GET/agents/{name}/runs/{id}/eventsReplay a run's event log over WebSocket
GET/agentsList all registered agents and their descriptions
GET/openapi.jsonOpenAPI 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:

QueryStatusBody
(none)200 OKRunResponse JSON — full event list + final output, after run completes
?stream=sse200 OKtext/event-stream — each AgentEvent as an SSE frame, streamed live
?mode=async202 AcceptedAsyncAccepted 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 202 immediately and the run continues in a background task. The log survives connection close.
  • Cancellation: one-shot and SSE responses hold a CancellationToken drop-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_failed event before closing, so a streaming client always observes a terminal frame. (One-shot mode instead returns HTTP 500 on a start error, or 200 with a partial result when a started run ends without a terminal event.)

Completed runs are retained for a configurable period and count:

Builder methodDefaultEffect
.run_retention(Duration)5 minutesHow long completed runs stay in the registry
.max_retained_runs(usize)1 024Cap on retained completed runs (oldest evicted first)
.max_sessions(usize)4 096Cap 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 ContextProvider and call .with_permission_mode(PermissionMode::Deny) to prevent agents from escalating tool permissions at runtime.
  • Supply a custom ApprovalHandler that enforces your tenant's access-control list.
  • Attach a HookRegistry for 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-core is 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 core and on nothing else in the workspace (-tools carries a path-only dev-dep on -providers-openai for an example; it is stripped from the published manifest).
  • paigasus-helikon-macros is a proc-macro crate; its #[tool] expansion targets core types in the consumer's crate.
  • paigasus-helikon is the facade: it re-exports core unconditionally and the sibling crates behind Cargo features. Application crates normally depend on the facade alone and turn on the features they need.
  • paigasus-helikon-cli consumes core and the sibling crates it needs (-evals, -runtime-tokio, -providers-openai, -providers-anthropic, -mcp) directly — not the facade. It publishes to crates.io at 0.1.0 as a binary crate; its lib target is internal (missing_docs opted out) and carries no stability guarantee, publishing only so cargo install paigasus-helikon-cli resolves.

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.

CrateConcernStateVersion
paigasus-helikon-coreTrait surface, agent loop, event stream, carrier types — the dependency rootpublished0.5.4
paigasus-helikonFacade — re-exports core always, siblings behind featurespublished0.4.14
paigasus-helikon-macros#[tool] attribute and tools! proc macrospublished0.2.2
paigasus-helikon-providers-openaiOpenAI model adapter (OpenAiModel)published0.2.9
paigasus-helikon-providers-anthropicAnthropic model adapter (AnthropicModel)published0.1.10
paigasus-helikon-providers-bedrockAmazon Bedrock Converse API model adapter (BedrockModel)published0.1.0
paigasus-helikon-providers-geminiGoogle Gemini model adapter (GeminiModel; Developer API + Vertex AI)published0.1.0
paigasus-helikon-sessions-sqliteSQLite-backed Session backendpublished0.1.11
paigasus-helikon-sessions-postgresPostgreSQL-backed Session backend (PostgresSession)published0.1.0
paigasus-helikon-sessions-redisRedis Streams-backed Session backend (RedisSession)published0.1.0
paigasus-helikon-runtime-tokioDefault ephemeral Tokio runnerpublished0.1.9
paigasus-helikon-runtime-axumSelf-hosted Axum HTTP/SSE/WebSocket agent server (AgentServer builder, 6 endpoints, replayable runs)published0.1.0
paigasus-helikon-runtime-temporalDurable Temporal-backed runner (TemporalRunner; crash-resume via Temporal history replay)published0.1.0
paigasus-helikon-runtime-agentcoreAWS Bedrock AgentCore container shim (AgentCoreServer; HTTP + MCP protocol contract)published0.1.0
paigasus-helikon-mcpMCP integration — rmcp client and server wrapperspublished0.1.3
paigasus-helikon-toolsSandboxed Read/Write/Edit/Bash tools (+ WebFetch/WebSearch behind web)published0.1.5
paigasus-helikon-evalsEvaluation harness — datasets, evaluators, MockModel, SQLite/Parquet trace sinkspublished0.1.0
paigasus-helikon-clihelikon / paigasus-helikon CLI binaries; lib target is internal, no stability guaranteepublished (binary crate)0.1.0
paigasus-helikon-sessions-testkitShared Session conformance test harness (internal — never published)internal — publish = false0.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:::

FeatureRe-exportCrate pulled in
(always on)paigasus_helikon::corepaigasus-helikon-core
macrospaigasus_helikon::macros, paigasus_helikon::tool, paigasus_helikon::toolspaigasus-helikon-macros
openai (alias providers-openai)paigasus_helikon::openaipaigasus-helikon-providers-openai
anthropicpaigasus_helikon::anthropicpaigasus-helikon-providers-anthropic
bedrockpaigasus_helikon::bedrockpaigasus-helikon-providers-bedrock
geminipaigasus_helikon::geminipaigasus-helikon-providers-gemini
mcppaigasus_helikon::mcppaigasus-helikon-mcp
toolspaigasus_helikon::toolspaigasus-helikon-tools
tools-webadds WebFetch/WebSearchenables paigasus-helikon-tools/web
sessions-sqlitepaigasus_helikon::sessions_sqlitepaigasus-helikon-sessions-sqlite
sessions-postgrespaigasus_helikon::sessions_postgrespaigasus-helikon-sessions-postgres
sessions-redispaigasus_helikon::sessions_redispaigasus-helikon-sessions-redis
runtime-tokiopaigasus_helikon::runtime_tokiopaigasus-helikon-runtime-tokio
runtime-axumpaigasus_helikon::runtime_axumpaigasus-helikon-runtime-axum
runtime-temporalpaigasus_helikon::runtime_temporalpaigasus-helikon-runtime-temporal
runtime-agentcorepaigasus_helikon::runtime_agentcorepaigasus-helikon-runtime-agentcore
evalspaigasus_helikon::evalspaigasus-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

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.

FlagDefaultMeaning
--agents PATHagents.tomlThe sidecar file to load.
--agent NAMEalphabetically-first agentWhich 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.

FlagDefaultMeaning
<dataset>(required, positional)Path to the JSONL dataset (one EvalCase per line).
--agent NAME(required)Which sidecar agent to evaluate.
--agents PATHagents.tomlThe sidecar file to load.
--jsonoffPrint the full EvalReport as JSON instead of a plain-text table.
--fail-under FnoneAdditionally fail (exit non-zero) if the mean non-skipped score is below F.
--trace sqlite:PATHnoneRecord 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.

FlagDefaultMeaning
--agent NAME(required)Which sidecar agent to serve.
--agents PATHagents.tomlThe sidecar file to load.
--http ADDRnone (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.provider is one of "openai", "anthropic", or "mock". The first two resolve OPENAI_API_KEY / ANTHROPIC_API_KEY from the environment at build time and fail fast with a clear error if the key is missing. "mock" loads a recorded script file (the same ScriptFile format the paigasus-helikon-evals crate uses — see Observability & Evaluation) — no network, no credentials, fully deterministic. Note: the budgeting agent above uses "mock" purely to keep this example runnable without an API key; swap in "openai"/"anthropic" for a live agent.
  • instructions is either an inline string (as above) or { file = "path.md" }, loaded relative to the sidecar's directory.
  • tools reference [tools.<name>] entries by name. Each tool is a sandboxed Rhai script exposing fn run(args), given either as script = "path.rhai" or inline = '''...''' (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.
  • handoffs names other agents in the same file, wired into Handoff::to(...) chains exactly like the Handoff pattern in the core library.
  • [eval] configures helikon eval run: evaluators names built-in evaluators (exact_match, tool_trajectory, json_schema, llm_judge); json_schema/llm_judge need 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/Handoff graph, the sidecar validates handoffs at 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 in agents.toml; if you need a true multi-directional pool, use SwarmAgent from 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:

CommandEffect
/agentsList every agent declared in the sidecar, marking the current one.
/switch NAMESwitch the active agent for the next turn.
/reloadForce an immediate re-parse of the sidecar file.
/quit / /exitLeave the REPL.
any other textA 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 NAME to pick a specific starting agent.

See also

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.