Agent Loop & State Machine
The agent loop is an explicit enum LoopState, driven by a pure transition function and surfaced as a typed AgentEvent stream. The state machine is data: transition is async-free, allocation-light, and never touches IO. An async driver runs the side effects between transitions. This is what makes the loop resumable — a durable runner can persist the state plus the conversation and rehydrate at any transition boundary.
All of this lives in paigasus-helikon-core.
The state machine
LoopState has one variant per high-level phase:
CallingModel { turn, usage }— about to invoke the model forturn.ExecutingTools { calls, turn, usage }— the model requested tool calls; run them concurrently.ApplyingHandoff { target, transcript, usage }— delegate to another agent, threading the transcript.Finalizing { turn, usage }— a constrained turn that asks the model to emit the configuredoutput_type.RepairingOutput { turn, usage }— the one allowed repair turn after a failed structured-output validation.CompactingandNeedsApproval { pending }— reserved phases, not yet driven.Done(FinalOutput)— terminal success.Failed(AgentError)— terminal failure.
transition is the whole machine:
#![allow(unused)] fn main() { pub fn transition( state: &LoopState, input: TransitionInput, ctx: &TransitionCtx<'_>, ) -> TransitionOutcome; }
It takes the current state, the most recent TransitionInput (Start, ModelResponse, or ToolResults), and a TransitionCtx (the tool defs, ModelSettings, max_turns, the accumulated conversation, the optional OutputType, and any handoff descriptors). It returns a TransitionOutcome:
#![allow(unused)] fn main() { pub struct TransitionOutcome { pub next_state: LoopState, pub events: Vec<AgentEvent>, pub next_action: NextAction, pub conversation_appends: Vec<Item>, } }
next_action tells the driver what side effect to run before the next step:
CallModel { request }— call the model, feed the result back asTransitionInput::ModelResponse.ExecuteTools { calls }— fan out the tool calls, feed outcomes back asTransitionInput::ToolResults.Handoff— read the target/transcript fromApplyingHandoffand run the target agent.Terminate— the state is terminal; stop driving.
A typical run walks CallingModel → ExecutingTools → CallingModel → … → Done. Each tool round bumps turn; reaching RunConfig::max_turns fails the run with AgentError::MaxTurnsExceeded. With an output_type configured, the loop adds a Finalizing (and, on a validation miss, one RepairingOutput) turn before Done. See Structured Output for that path.
The event stream
Agent::run returns a BoxStream<'static, AgentEvent>. The outer Result only covers failure to start the stream; a fatal error mid-run arrives as an AgentEvent::RunFailed inside the stream, not as an Err.
AgentEvent is a single, serializable enum spanning lifecycle, raw deltas, post-aggregation semantic items, transitions, control signals, and terminal outcomes. The variants:
- Lifecycle:
RunStarted { agent },TurnStarted { turn }. - Raw deltas (for low-latency UIs):
TokenDelta { text },ReasoningDelta { text },ToolCallDelta { call_id, name, args_delta }. - Semantic items (carry a full
Item):MessageOutput { item },ToolCallItem { item },ToolOutputItem { item },HandoffItem { from, to }. - Transitions:
AgentUpdated { agent }. - Control:
GuardrailTriggered { kind, info },ApprovalRequested { call_id, tool, args },PermissionDenied { tool, reason },RepairStarted { attempt },StructuredOutputFailed { schema_errors, final_text }. - Terminal:
RunCompleted { usage },RunFailed { error }.
The raw TokenDelta / ToolCallDelta events stream as the provider emits them; the MessageOutput / ToolCallItem / ToolOutputItem events carry the same content re-aggregated into a complete Item once the turn settles.
Driving a run to completion
The common case: call run, then drain the stream into a RunResult with RunResultStreaming.
use paigasus_helikon::core::{ Agent, AgentInput, LlmAgent, RunContext, RunResultStreaming, }; use paigasus_helikon::openai::OpenAiModel; #[tokio::main] async fn main() -> anyhow::Result<()> { let agent = LlmAgent::builder::<()>() .name("budget-assistant") .model(OpenAiModel::chat("gpt-5-mini").build()?) .instructions("You are a budgeting assistant.") .build(); let ctx: RunContext<()> = RunContext::ephemeral(()); let input = AgentInput::from_user_text("How am I doing on my dining budget this month?"); let stream = agent.run(ctx, input).await?; let result = RunResultStreaming::new(stream).collect().await?; println!("{}", result.final_output); Ok(()) }
RunResultStreaming::new(stream).collect().await? drains every event, returning a RunResult whose final_output: String is the concatenated text of the last MessageOutput assistant message, alongside the full events vector and aggregated usage. A terminal RunFailed surfaces as Err(RunError). For structured output, use collect_typed::<T>() instead.
Consuming events live
To react as the run unfolds — for example streaming tokens to a console — match on the stream directly instead of collecting:
#![allow(unused)] fn main() { use futures_util::StreamExt; use paigasus_helikon::core::AgentEvent; let mut stream = agent.run(ctx, input).await?; while let Some(event) = stream.next().await { match event { AgentEvent::TokenDelta { text } => print!("{text}"), AgentEvent::RunFailed { error } => anyhow::bail!("run failed: {error}"), _ => {} } } }
You can drain the stream yourself and still build a RunResult later, or mix the two — RunResultStreaming::collect is just one consumer of the same AgentEvent stream.
Tuning the loop
RunConfig controls the run; pass it through a Runner, or set it on the agent via the builder. The knobs:
max_turns: u32— model turns before the loop fails withMaxTurnsExceeded. Default16. Honored by the core loop driver, including a bareagent.run().parallel_tool_call_limit: Option<NonZeroUsize>— cap on concurrently-executing tool calls.None= unbounded. Honored by the core loop driver.max_agent_depth: u32— maximum agent-nesting depth across handoff chains and agent-as-tool sub-runs. Default8; exceeding it fails withMaxAgentDepthExceeded.timeout: Option<Duration>— wall-clock deadline for the whole run. Honored only by a runtime backend such as theruntime-tokiorunner; a bareagent.run()has no timer and cannot time out.
The builders are chainable:
#![allow(unused)] fn main() { use std::num::NonZeroUsize; use std::time::Duration; use paigasus_helikon::core::RunConfig; let config = RunConfig::new() .with_timeout(Duration::from_secs(30)) .with_parallel_tool_call_limit(NonZeroUsize::new(4).unwrap()) .with_max_agent_depth(4); }
Cancellation is not a RunConfig field — the canonical CancellationToken lives on RunContext. Cancellation and timeout are best-effort: a genuine terminal event that already occurred wins over a late cancel or timeout, so a caller cannot assume that calling cancel() always yields RunError::Cancelled.