Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Tirea is an immutable state-driven agent framework built in Rust. It combines typed JSON state management with an agent loop, providing full traceability of state changes, replay capability, and component isolation.

Crate Overview

CrateDescription
tirea-stateCore library: typed state, JSON patches, apply, conflict detection
tirea-state-deriveProc-macro for #[derive(State)]
tirea-contractShared contracts: thread/events/tools/plugins/runtime/storage/protocol
tirea-agentosAgent runtime: inference engine, tool execution, orchestration, plugin composition
tirea-extension-*Plugins: permission, reminder, observability, skills, MCP, A2UI
tirea-protocol-ag-uiAG-UI protocol adapters
tirea-protocol-ai-sdk-v6Vercel AI SDK v6 protocol adapters
tirea-store-adaptersStorage adapters: memory/file/postgres/nats-buffered
tirea-agentos-serverHTTP/SSE/NATS gateway server
tireaUmbrella crate that re-exports core modules

Architecture

┌─────────────────────────────────────────────────────┐
│  Application Layer                                    │
│  - Register tools, define agents, call run_stream    │
└─────────────────────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────┐
│  AgentOs                                             │
│  - Prepare run, execute phases, emit events          │
└─────────────────────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────┐
│  Thread + State Engine                               │
│  - Thread history, RunContext delta, apply_patch     │
└─────────────────────────────────────────────────────┘

Core Principle

All state transitions follow a deterministic, pure-function model:

State' = apply_patch(State, Patch)
  • Same (State, Patch) always produces the same State'
  • apply_patch never mutates its input
  • Full history enables replay to any point in time

What’s in This Book

  • Tutorials — Learn by building a first agent and first tool
  • How-to — Task-focused implementation guides for integration and operations
  • Reference — API, protocol, config, and schema lookup pages
  • Explanation — Architecture and design rationale

If you are new to the repository, use this order:

  1. Read First Agent to see the smallest runnable flow.
  2. Read First Tool to understand state reads and writes.
  3. Read Typed Tool Reference before writing production tools.
  4. Use Build an Agent and Add a Tool as implementation checklists.
  5. Return to Architecture and Run Lifecycle and Phases when you need the full execution model.

Repository Map

These paths matter most when you move from docs into code:

PathPurpose
crates/tirea-contract/Core runtime contracts: tools, events, state/runtime interfaces
crates/tirea-agentos/Agent runtime: inference engine, tool execution, orchestration, extensions
crates/tirea-agentos-server/HTTP/SSE/NATS server surfaces
crates/tirea-state/Immutable state patch/apply/conflict engine
examples/src/Small backend examples for tools, agents, and state
examples/ai-sdk-starter/Shortest browser-facing end-to-end example
examples/copilotkit-starter/Richer end-to-end UI example with approvals and persistence
docs/book/src/This documentation source

For the full Rust API documentation, see the API Reference.

First Agent

Goal

Run one agent end-to-end and confirm you receive a complete event stream.

Prerequisites

[dependencies]
tirea = "0.5.0-alpha.1"
tirea-agentos-server = "0.5.0-alpha.1"
tirea-store-adapters = "0.5.0-alpha.1"
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
futures = "0.3"
serde_json = "1"

Set one model provider key before running:

# OpenAI-compatible models (for gpt-4o-mini)
export OPENAI_API_KEY=<your-key>

# Or DeepSeek models
export DEEPSEEK_API_KEY=<your-key>

1. Create src/main.rs

use futures::StreamExt;
use serde_json::{json, Value};
use tirea::contracts::{AgentEvent, Message, RunOrigin, RunRequest, ToolCallContext};
use tirea::composition::{tool_map, AgentDefinition, AgentDefinitionSpec, AgentOsBuilder};
use tirea::prelude::*;

struct EchoTool;

#[async_trait]
impl Tool for EchoTool {
    fn descriptor(&self) -> ToolDescriptor {
        ToolDescriptor::new("echo", "Echo", "Echo input")
            .with_parameters(json!({
                "type": "object",
                "properties": { "text": { "type": "string" } },
                "required": ["text"]
            }))
    }

    async fn execute(
        &self,
        args: Value,
        _ctx: &ToolCallContext<'_>,
    ) -> Result<ToolResult, ToolError> {
        let text = args["text"].as_str().unwrap_or_default();
        Ok(ToolResult::success("echo", json!({ "text": text })))
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let os = AgentOsBuilder::new()
    .with_tools(tool_map([EchoTool]))
    .with_agent_spec(AgentDefinitionSpec::local_with_id(
        "assistant",
        AgentDefinition::new("gpt-4o-mini")
            .with_system_prompt("You are a helpful assistant.")
            .with_allowed_tools(vec!["echo".to_string()]),
    ))
    .build()?;

    let run = os
        .run_stream(RunRequest {
            agent_id: "assistant".to_string(),
            thread_id: Some("thread-1".to_string()),
            run_id: None,
            parent_run_id: None,
            parent_thread_id: None,
            resource_id: None,
            origin: RunOrigin::default(),
            state: None,
            messages: vec![Message::user("Say hello using the echo tool")],
            initial_decisions: vec![],
            source_mailbox_entry_id: None,
        })
        .await?;

    let events: Vec<_> = run.events.collect().await;
    println!("events: {}", events.len());

    let finished = events.iter().any(|e| matches!(e, AgentEvent::RunFinish { .. }));
    println!("run_finish_seen: {}", finished);

    Ok(())
}

2. Run

cargo run

3. Verify

Expected output includes:

  • events: <n> where n > 0
  • run_finish_seen: true

What You Created

This example creates an in-process AgentOs and runs one request immediately.

That means the agent is already usable in three ways:

  1. Call os.run_stream(...) from your own Rust application code.
  2. Start it as a local CLI-style binary with cargo run.
  3. Mount the same AgentOs into an HTTP server so browser or remote clients can call it.

The tutorial shows option 1 and 2. Production integrations usually move to option 3.

How To Use It After Creation

The object you actually use is:

let os = AgentOsBuilder::new()
    .with_tools(tool_map([EchoTool]))
    .with_agent_spec(...)
    .build()?;

After that, the normal entrypoint is:

let run = os.run_stream(RunRequest { ... }).await?;

Common usage patterns:

  • one-shot CLI program: construct RunRequest, collect events, print result
  • application service: wrap os.run_stream(...) inside your own app logic
  • HTTP server: store Arc<AgentOs> in app state and expose protocol routes

How To Start It

For this tutorial, the binary entrypoint is main(), so startup is simply:

cargo run

If the agent is in a package inside a workspace, use:

cargo run -p your-package-name

If startup succeeds, your process:

  • builds the tool registry
  • registers the agent definition
  • sends one RunRequest
  • streams events until completion
  • exits

So this tutorial is a runnable smoke test, not a long-lived server process.

How To Turn It Into A Server

To expose the same agent over HTTP, keep the AgentOsBuilder wiring and move it into server state:

use std::sync::Arc;
use tirea_agentos::contracts::storage::{MailboxStore, ThreadReader, ThreadStore};
use tirea_agentos_server::service::{AppState, MailboxService};
use tirea_agentos_server::{http, protocol};
use tirea_store_adapters::FileStore;

let file_store = Arc::new(FileStore::new("./sessions"));
let agent_os = AgentOsBuilder::new()
    .with_tools(tool_map([EchoTool]))
    .with_agent_spec(AgentDefinitionSpec::local_with_id(
        "assistant",
        AgentDefinition::new("gpt-4o-mini")
            .with_system_prompt("You are a helpful assistant.")
            .with_allowed_tools(vec!["echo".to_string()]),
    ))
    .with_agent_state_store(file_store.clone() as Arc<dyn ThreadStore>)
    .build()?;

let os = Arc::new(agent_os);
let read_store: Arc<dyn ThreadReader> = file_store.clone();
let mailbox_store: Arc<dyn MailboxStore> = file_store;
let mailbox_svc = Arc::new(MailboxService::new(os.clone(), mailbox_store, "my-agent"));

let app = axum::Router::new()
    .merge(http::health_routes())
    .merge(http::thread_routes())
    .merge(http::run_routes())
    .nest("/v1/ag-ui", protocol::ag_ui::http::routes())
    .nest("/v1/ai-sdk", protocol::ai_sdk_v6::http::routes())
    .with_state(AppState::new(os, read_store, mailbox_svc));

Then run the server with an Axum listener instead of immediately calling run_stream(...).

Use the next page based on what you want:

Common Errors

  • Model/provider mismatch: gpt-4o-mini requires a compatible OpenAI-style provider setup.
  • Missing key: set OPENAI_API_KEY or DEEPSEEK_API_KEY before cargo run.
  • Tool not selected: ensure prompt explicitly asks to use echo.

Next

First Tool

Goal

Implement one tool that reads and updates typed state.

State is optional. Many tools (API calls, search, shell commands) don’t need state — just implement execute and return a ToolResult.

Prerequisites

  • Complete First Agent first.
  • Reuse the runtime dependencies from First Agent.
  • State derive is available in your dependencies:
[dependencies]
async-trait = "0.1"
serde_json = "1"
serde = { version = "1", features = ["derive"] }
tirea = "0.5.0-alpha.1"
tirea-state-derive = "0.5.0-alpha.1"

1. Define Typed State with Action

State mutations in Tirea are action-based: define an action enum and a reducer, the runtime applies changes through ToolExecutionEffect. Direct state writes via ctx.state::<T>().set_*() are rejected at runtime.

The #[tirea(action = "...")] attribute wires the action type and generates StateSpec. State scope defaults to thread (persists across runs); you can set #[tirea(scope = "run")] for per-run state or #[tirea(scope = "tool_call")] for per-invocation scratch data.

use serde::{Deserialize, Serialize};
use tirea_state_derive::State;

#[derive(Debug, Clone, Default, Serialize, Deserialize, State)]
#[tirea(action = "CounterAction")]
struct Counter {
    value: i64,
    label: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
enum CounterAction {
    Increment(i64),
}

impl Counter {
    fn reduce(&mut self, action: CounterAction) {
        match action {
            CounterAction::Increment(amount) => self.value += amount,
        }
    }
}

2. Implement the Tool

Override execute_effect and return state changes as typed actions via ToolExecutionEffect.

use async_trait::async_trait;
use serde_json::{json, Value};
use tirea::contracts::{AnyStateAction, ToolCallContext};
use tirea::contracts::runtime::tool_call::ToolExecutionEffect;
use tirea::prelude::*;

struct IncrementCounter;

#[async_trait]
impl Tool for IncrementCounter {
    fn descriptor(&self) -> ToolDescriptor {
        ToolDescriptor::new("increment_counter", "Increment Counter", "Increment counter state")
            .with_parameters(json!({
                "type": "object",
                "properties": {
                    "amount": { "type": "integer", "default": 1 }
                }
            }))
    }

    async fn execute(&self, args: Value, ctx: &ToolCallContext<'_>) -> Result<ToolResult, ToolError> {
        Ok(<Self as Tool>::execute_effect(self, args, ctx).await?.result)
    }

    async fn execute_effect(
        &self,
        args: Value,
        ctx: &ToolCallContext<'_>,
    ) -> Result<ToolExecutionEffect, ToolError> {
        let amount = args["amount"].as_i64().unwrap_or(1);
        let current = ctx.snapshot_of::<Counter>()
            .map(|c| c.value)
            .unwrap_or(0);

        Ok(ToolExecutionEffect::new(ToolResult::success(
            "increment_counter",
            json!({ "before": current, "after": current + amount }),
        ))
        .with_action(AnyStateAction::new::<Counter>(
            CounterAction::Increment(amount),
        )))
    }
}

3. Register the Tool

use tirea::composition::{tool_map, AgentOsBuilder};

let os = AgentOsBuilder::new()
    .with_tools(tool_map([IncrementCounter]))
    .build()?;

4. Verify Behavior

Run one request that triggers increment_counter, then verify:

  • Event stream contains ToolCallDone for increment_counter
  • Thread state counter.value increases by expected amount
  • Thread patch history appends at least one new patch

5. Reading State

Use snapshot_of to read the current state as a plain Rust value:

let snap = ctx.snapshot_of::<Counter>().unwrap_or_default();
println!("current value = {}", snap.value);

Note: ctx.state::<T>("path") and ctx.snapshot_at::<T>("path") exist for advanced cases where the same state type is reused at different paths. For most tools, snapshot_of is the right choice — it uses the path declared on the state type automatically.

6. TypedTool

For tools with fixed argument shapes, see TypedTool — it auto-generates JSON Schema from the Rust struct and handles deserialization.

Common Errors

  • Missing derive macro import: ensure use tirea_state_derive::State; exists.
  • Using ctx.state::<T>().set_*() for writes: the runtime rejects direct state writes. Use ToolExecutionEffect + AnyStateAction instead.
  • Numeric parse fallback hides bugs: validate amount if strict input is required.
  • Reaching for raw Value parsing too early: if your arguments map cleanly to one struct, switch to TypedTool.
  • Using ? on state reads inside tool methods: snapshot_of returns TireaResult<T> but tool methods return Result<_, ToolError> with no From conversion; use unwrap_or_default() for types that derive Default.
  • Forgetting #[derive(JsonSchema)] on TypedTool::Args: compilation will fail without it.

Next

Build an Agent

Use this when you need a production integration path with tool registry, persistence, and protocol endpoints.

Prerequisites

  • One model provider key is configured (for example OPENAI_API_KEY for gpt-4o-mini).
  • You have at least one tool implementation.
  • You know whether the deployment needs persistent storage.

Steps

  1. Define tool set.
.with_tools(tool_map([SearchTool, SummarizeTool]))
  1. Define agent behavior.
.with_agent_spec(AgentDefinitionSpec::local_with_id(
    "assistant",
    AgentDefinition::new("gpt-4o-mini")
        .with_system_prompt("You are a helpful assistant.")
        .with_max_rounds(10)
        .with_allowed_tools(vec!["search".to_string(), "summarize".to_string()]),
))
  1. Wire persistence.
.with_agent_state_store(store.clone())
  1. Execute via run_stream.
let run = os.run_stream(RunRequest {
    agent_id: "assistant".to_string(),
    thread_id: Some("thread-1".to_string()),
    run_id: None,
    parent_run_id: None,
    parent_thread_id: None,
    resource_id: None,
    origin: RunOrigin::default(),
    state: None,
    messages: vec![Message::user("hello")],
    initial_decisions: vec![],
    source_mailbox_entry_id: None,
}).await?;
  1. Consume stream and inspect terminal state.
let mut events = run.events;
while let Some(event) = events.next().await {
    if let AgentEvent::RunFinish { termination, .. } = event {
        println!("termination = {:?}", termination);
    }
}

Verify

  • You receive at least one RunStart and one RunFinish event.
  • RunFinish.termination matches your expectation (NaturalEnd, Stopped, Error, etc.).
  • If persistence is enabled, thread can be reloaded from store after run.

After The Agent Is Built

Once you have:

let os = AgentOsBuilder::new()
    .with_tools(...)
    .with_agent_spec(...)
    .build()?;

you normally choose one of these runtime modes:

  1. In-process execution: call os.run_stream(RunRequest { ... }).await?
  2. Long-lived backend service: put Arc<AgentOs> into server state and expose HTTP protocol routes
  3. Example/starter backend: reuse the same builder pattern in a dedicated binary and let frontend clients connect over AI SDK or AG-UI

The important point is that AgentDefinition creation alone does not “start” anything. The run starts only when:

  • your code calls run_stream(...), or
  • an HTTP route receives a request and delegates to AgentOs

Common Errors

  • Model/provider mismatch: Use a model id compatible with the provider key you exported.
  • Tool unavailable: Ensure tool id is registered and included in allowed_tools if whitelist is enabled.
  • Empty runs with no meaningful output: Confirm user message is appended in RunRequest.messages.
  • examples/ai-sdk-starter/README.md is the fastest browser-facing backend integration
  • examples/copilotkit-starter/README.md shows the same runtime exposed through AG-UI with richer UI state

Key Files

  • examples/src/starter_backend/mod.rs
  • crates/tirea-agentos/src/composition/agent_definition.rs
  • crates/tirea-agentos/src/composition/builder.rs
  • crates/tirea-agentos-server/src/main.rs

Configure Stop Policies

Use this when a run must terminate on explicit loop, budget, timeout, or domain-specific conditions.

What is auto-wired

  • AgentDefinition.max_rounds is lowered into StopConditionSpec::MaxRounds during agent wiring unless you already declared an explicit max_rounds stop spec.
  • AgentOsBuilder wires the internal stop_policy behavior automatically when stop specs or stop-condition ids are present.
  • stop_policy is a reserved behavior id. Register stop policies through builder APIs instead of attaching a behavior id manually.

Prerequisites

  • You know whether the stop rule is declarative (StopConditionSpec) or custom (StopPolicy trait).
  • You have a way to observe terminal run status through events or the Run API.

Steps

  1. Add declarative stop specs on the agent definition.
use tirea::composition::{AgentDefinition, AgentDefinitionSpec, StopConditionSpec};

let agent = AgentDefinition::new("deepseek-chat")
    .with_stop_condition_specs(vec![
        StopConditionSpec::Timeout { seconds: 30 },
        StopConditionSpec::LoopDetection { window: 4 },
        StopConditionSpec::StopOnTool {
            tool_name: "finish".to_string(),
        },
    ]);
  1. Keep max_rounds aligned with your stop strategy.
  • max_rounds still acts as the default loop-depth guard.
  • If you already added StopConditionSpec::MaxRounds, do not expect max_rounds to stack on top of it.
  1. Register reusable custom stop policies when declarative specs are not enough.
use std::sync::Arc;
use tirea::composition::AgentOsBuilder;
use tirea::runtime::{StopPolicy, StopPolicyInput};
use tirea::contracts::StoppedReason;

struct AlwaysStop;

impl StopPolicy for AlwaysStop {
    fn id(&self) -> &str {
        "always"
    }

    fn evaluate(&self, _input: &StopPolicyInput<'_>) -> Option<StoppedReason> {
        Some(StoppedReason::new("always_stop"))
    }
}

let os = AgentOsBuilder::new()
    .with_stop_policy("always", Arc::new(AlwaysStop))
    .with_agent_spec(AgentDefinitionSpec::local_with_id(
        "assistant",
        AgentDefinition::new("deepseek-chat").with_stop_condition_id("always"),
    ))
    .build()?;
  1. Observe the terminal reason from events or run records.
  • AgentEvent::RunFinish { termination, .. }
  • GET /v1/runs/:id (termination_code, termination_detail)

Verify

  • The run terminates with the expected stopped reason instead of timing out implicitly elsewhere.
  • GET /v1/runs/:id reflects the same terminal reason that you observed in the event stream.
  • A new run starts with fresh stop-policy runtime state rather than inheriting counters from the previous run.

Common Errors

  • Trying to register stop_policy as a normal behavior id.
  • Expecting max_rounds and StopConditionSpec::MaxRounds to both apply independently.
  • Using stop policies for tool authorization. Authorization belongs in tool/policy behaviors, not termination logic.
  • Forgetting that stop-policy runtime bookkeeping is run-scoped and will be reset on the next run.
  • examples/src/starter_backend/mod.rs defines a stopper agent that terminates on StopConditionSpec::StopOnTool { tool_name: "finish" }

Key Files

  • crates/tirea-agentos/src/runtime/plugin/stop_policy.rs
  • crates/tirea-agentos/src/composition/wiring.rs
  • crates/tirea-agentos/src/composition/agent_definition.rs
  • crates/tirea-agentos/src/runtime/tests.rs

Add a Tool

Use this when you already have an agent and need to add one tool safely.

If your tool arguments have a stable Rust shape, start with TypedTool. Reach for plain Tool only when you need manual JSON handling or custom effect wiring.

Prerequisites

  • Existing AgentOsBuilder wiring.
  • Tool behavior can be expressed as one deterministic unit of work.
  • You know whether this tool should be exposed to the model (allowed_tools).

Steps

  1. Choose TypedTool for fixed schemas, or Tool for dynamic/manual schemas.
  2. Implement the tool with a stable descriptor id (tool_id() for TypedTool, descriptor().id for Tool).
  3. Validate arguments explicitly (validate() for TypedTool, execute or validate_args for Tool).
  4. Keep execution deterministic on the same (args, state) when possible.
  5. Register tool with AgentOsBuilder::with_tools(...).
  6. If using whitelist mode, include tool id in AgentDefinition::with_allowed_tools(...).

Preferred Pattern: TypedTool

use async_trait::async_trait;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::json;
use tirea::contracts::runtime::tool_call::{ToolError, ToolResult, TypedTool};
use tirea::contracts::ToolCallContext;

#[derive(Debug, Deserialize, JsonSchema)]
struct MyToolArgs {
    input: String,
}

struct MyTool;

#[async_trait]
impl TypedTool for MyTool {
    type Args = MyToolArgs;

    fn tool_id(&self) -> &str { "my_tool" }
    fn name(&self) -> &str { "My Tool" }
    fn description(&self) -> &str { "Do one thing" }

    fn validate(&self, args: &Self::Args) -> Result<(), String> {
        if args.input.trim().is_empty() {
            return Err("input cannot be empty".to_string());
        }
        Ok(())
    }

    async fn execute(
        &self,
        args: MyToolArgs,
        _ctx: &ToolCallContext<'_>,
    ) -> Result<ToolResult, ToolError> {
        Ok(ToolResult::success("my_tool", json!({ "input": args.input })))
    }
}

Alternative Pattern: plain Tool

Use this when argument shape is dynamic or you need manual JSON handling.

use async_trait::async_trait;
use serde_json::{json, Value};
use tirea::contracts::ToolCallContext;
use tirea::prelude::{Tool, ToolDescriptor, ToolError, ToolResult};

struct MyUntypedTool;

#[async_trait]
impl Tool for MyUntypedTool {
    fn descriptor(&self) -> ToolDescriptor {
        ToolDescriptor::new("my_untyped_tool", "My Untyped Tool", "Do one thing")
            .with_parameters(json!({
                "type": "object",
                "properties": { "input": { "type": "string" } },
                "required": ["input"]
            }))
    }

    async fn execute(&self, args: Value, _ctx: &ToolCallContext<'_>) -> Result<ToolResult, ToolError> {
        let input = args["input"]
            .as_str()
            .ok_or_else(|| ToolError::InvalidArguments("input is required".to_string()))?;
        Ok(ToolResult::success("my_untyped_tool", json!({ "input": input })))
    }
}

Verify

  • Run event stream includes ToolCallStart and ToolCallDone for my_tool.
  • Thread message history includes tool call + tool result messages.
  • If tool writes state, a new patch is appended.

State Access Checklist

State is optional — many tools don’t need it at all.

  • Reading: use ctx.snapshot_of::<T>() for read-only access. Use snapshot_at only for advanced cases with dynamic paths.
  • Writing: implement execute_effect and return ToolExecutionEffect + AnyStateAction::new::<T>(action). Direct writes via ctx.state::<T>().set_*() are rejected at runtime.
  • Scoping: declare scope on the state type via #[tirea(scope = "...")]:
    • thread (default) — persists across all runs in the conversation
    • run — reset at the start of each agent run
    • tool_call — exists only during a single tool execution
  • If one tool depends on another tool having run first, encode that precondition in state and reject invalid execution explicitly.

For concrete examples, see Typed Tool.

Common Errors

  • Descriptor id mismatch: registration and allowed_tools must use identical id.
  • Missing derives for TypedTool: Args must implement both Deserialize and JsonSchema.
  • Silent argument defaults: prefer explicit validation for required fields.
  • Non-deterministic side effects: hard to replay/debug and can break tests.
  • Choosing plain Tool for a fixed schema: this usually adds parsing noise and drifts schema away from Rust types.
  • examples/ai-sdk-starter/README.md is the shortest end-to-end path for adding tools to a browser demo
  • examples/copilotkit-starter/README.md shows tool rendering, approval, and persisted-thread integration

Key Files

  • examples/src/starter_backend/tools.rs
  • examples/src/travel/tools.rs
  • examples/src/research/tools.rs
  • crates/tirea-contract/src/runtime/tool_call/tool.rs

Add a Plugin

Use this for cross-cutting behavior such as policy checks, approval gates, reminders, and observability.

Prerequisites

  • You know which phase should emit behavior (RunStart, BeforeInference, BeforeToolExecute, AfterToolExecute, RunEnd, etc.).
  • Plugin side effects are explicit and bounded.

Steps

  1. Implement AgentBehavior and assign a stable id().
  2. Return phase actions with ActionSet<...> from the phase hooks you need.
  3. Register behavior in AgentOsBuilder::with_registered_behavior("id", plugin).
  4. Attach behavior id in AgentDefinition.behavior_ids or with_behavior_id(...).

Minimal Pattern

use async_trait::async_trait;
use tirea::contracts::runtime::phase::{ActionSet, BeforeInferenceAction};
use tirea::contracts::{AgentBehavior, ReadOnlyContext};

struct AuditBehavior;

#[async_trait]
impl AgentBehavior for AuditBehavior {
    fn id(&self) -> &str {
        "audit"
    }

    async fn before_inference(
        &self,
        _ctx: &ReadOnlyContext<'_>,
    ) -> ActionSet<BeforeInferenceAction> {
        ActionSet::single(BeforeInferenceAction::AddSystemContext(
            "Audit: request entering inference".to_string(),
        ))
    }
}

Verify

  • Behavior hook runs at the intended phase.
  • Event/thread output contains expected behavior side effects.
  • Runs are unchanged when behavior preconditions are not met.

Common Errors

  • Registering behavior but forgetting to include its id in AgentDefinition.behavior_ids.
  • Using the wrong phase (effect appears too early or too late).
  • Unbounded mutations in a behavior, making runs hard to reason about.
  • examples/src/travel.rs shows a production LLMMetryPlugin registration path
  • examples/src/starter_backend/mod.rs wires permission and tool-policy behaviors into multiple agents

Key Files

  • crates/tirea-contract/src/runtime/behavior.rs
  • crates/tirea-agentos/src/composition/builder.rs
  • crates/tirea-extension-reminder/src/lib.rs
  • crates/tirea-extension-permission/src/plugin.rs

Use File Store

Use FileStore for local development and small single-node deployments.

Prerequisites

  • Writable local directory for thread files.
  • Single-writer assumptions or low write contention.

Steps

  1. Create file store.
use std::sync::Arc;
use tirea_store_adapters::FileStore;

let store = Arc::new(FileStore::new("./threads"));
  1. Inject into AgentOsBuilder.
use tirea::composition::{tool_map, AgentDefinition, AgentDefinitionSpec, AgentOsBuilder};

let os = AgentOsBuilder::new()
    .with_tools(tool_map([MyTool]))
    .with_agent_spec(AgentDefinitionSpec::local_with_id(
        "assistant",
        AgentDefinition::new("gpt-4o-mini"),
    ))
    .with_agent_state_store(store.clone())
    .build()?;
  1. Run once, then inspect persisted files under ./threads.

Verify

  • After one run, a thread JSON file exists.
  • Reloading the same thread id returns persisted messages and state.
  • Version preconditions reject conflicting concurrent appends.

Common Errors

  • Directory permission denied.
  • Multiple writers on same files causing frequent conflicts.
  • Assuming file store is suitable for horizontally scaled production.
  • examples/ai-sdk-starter/README.md and examples/copilotkit-starter/README.md both default to local file-backed storage for their starter backends

Key Files

  • crates/tirea-store-adapters/src/file_store.rs
  • crates/tirea-store-adapters/src/file_run_store.rs
  • examples/src/lib.rs
  • examples/src/starter_backend/mod.rs

Use Postgres Store

Use PostgresStore when you need shared durable storage across instances.

Prerequisites

  • tirea-store-adapters is enabled with feature postgres.
  • A reachable PostgreSQL DSN is available.
  • Tables auto-initialize on first store access; call ensure_table() only if you want eager startup validation.

Steps

  1. Add dependencies.
[dependencies]
tirea-store-adapters = { version = "0.5.0-alpha.1", features = ["postgres"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres"], default-features = false }
  1. Connect pool and initialize store.
use std::sync::Arc;
use tirea_store_adapters::PostgresStore;

let dsn = std::env::var("DATABASE_URL")?;
let pool = sqlx::PgPool::connect(&dsn).await?;
let store = Arc::new(PostgresStore::new(pool));
store.ensure_table().await?;
  1. Inject into AgentOsBuilder.
let os = AgentOsBuilder::new()
    .with_tools(tool_map([MyTool]))
    .with_agent_spec(AgentDefinitionSpec::local_with_id(
        "assistant",
        AgentDefinition::new("gpt-4o-mini"),
    ))
    .with_agent_state_store(store.clone())
    .build()?;
  1. Run and load persisted thread.
let _ = os.run_stream(run_request).await?;
let loaded = store.load_thread("thread-1").await?;

Verify

  • load_thread("thread-1") returns Some(Thread) after a run.
  • load_messages returns stored messages in expected order.
  • Concurrent write conflicts surface as VersionConflict (not silent overwrite).

Common Errors

  • Missing tables: The store bootstraps them on first access; call ensure_table() during startup only if you want failures surfaced before traffic.
  • DSN/auth failures: Validate DATABASE_URL and database permissions.
  • Feature not enabled: Confirm postgres feature is enabled on tirea-store-adapters.
  • No dedicated starter ships with Postgres prewired; the closest full integration fixture is crates/tirea-agentos-server/tests/e2e_nats_postgres.rs

Key Files

  • crates/tirea-store-adapters/src/postgres_store.rs
  • crates/tirea-agentos-server/tests/e2e_nats_postgres.rs
  • crates/tirea-agentos-server/src/main.rs

Use NATS Buffered Postgres Store

Use this for high-write runs: checkpoint deltas are buffered in NATS JetStream and flushed to Postgres at run end.

Prerequisites

  • tirea-store-adapters with nats and postgres features.
  • Reachable PostgreSQL and NATS JetStream.

Steps

  1. Create Postgres durable store.
use std::sync::Arc;
use tirea_store_adapters::PostgresStore;

let pool = sqlx::PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let postgres = Arc::new(PostgresStore::new(pool));
postgres.ensure_table().await?;
  1. Wrap writer with NATS JetStream buffer.
use tirea::contracts::storage::ThreadStore;
use tirea_store_adapters::NatsBufferedThreadWriter;

let nats = async_nats::connect(std::env::var("NATS_URL")?).await?;
let jetstream = async_nats::jetstream::new(nats);

let durable: Arc<dyn ThreadStore> = postgres.clone();
let buffered = Arc::new(NatsBufferedThreadWriter::new(durable, jetstream).await?);
  1. Recover pending deltas on startup.
let recovered = buffered.recover().await?;
eprintln!("recovered {} buffered deltas", recovered);
  1. Wire buffered writer for runtime commits, Postgres for reads.
use tirea::contracts::storage::ThreadReader;
use tirea::composition::{AgentDefinition, AgentDefinitionSpec, AgentOsBuilder};

let os = AgentOsBuilder::new()
    .with_agent_state_store(buffered.clone())
    .with_agent_spec(AgentDefinitionSpec::local_with_id(
        "assistant",
        AgentDefinition::new("deepseek-chat"),
    ))
    .build()?;

let read_store: Arc<dyn ThreadReader> = postgres.clone();

Semantics

  • During run: deltas are published to JetStream (thread.<thread_id>.deltas).
  • On run-finished checkpoint: buffered deltas are materialized and persisted to Postgres.
  • Query APIs read Postgres snapshot (CQRS), so they may lag active in-flight deltas.

Verify

  • Active runs emit normal events while Postgres writes are reduced.
  • After run completion, Postgres thread contains full committed messages/state.
  • recover() replays unacked deltas after crash.

Common Errors

  • Skipping ensure_table() when you expect startup-time validation of database permissions or schema creation.
  • Running without JetStream enabled on NATS server.
  • Expecting query endpoints to include not-yet-flushed in-run deltas.
  • No dedicated UI starter ships for this storage path; use crates/tirea-agentos-server/tests/e2e_nats_postgres.rs as the end-to-end integration fixture

Key Files

  • crates/tirea-store-adapters/src/nats_buffered.rs
  • crates/tirea-store-adapters/src/postgres_store.rs
  • crates/tirea-agentos-server/tests/e2e_nats_postgres.rs

Expose HTTP SSE

Use this when clients consume run events over HTTP streaming.

Prerequisites

  • AgentOs is wired with tools and agents.
  • ThreadReader is available for query routes.
  • ThreadReader is wired to the same state store used by run/query APIs.

Endpoints

Run streams:

  • POST /v1/ag-ui/agents/:agent_id/runs
  • POST /v1/ai-sdk/agents/:agent_id/runs
  • POST /v1/runs

Run stream resume:

  • GET /v1/ai-sdk/agents/:agent_id/chats/:chat_id/stream

Query APIs:

  • GET /v1/threads
  • GET /v1/threads/summaries
  • GET /v1/threads/:id
  • GET /v1/threads/:id/messages
  • PATCH /v1/threads/:id/metadata
  • DELETE /v1/threads/:id
  • GET /v1/runs
  • GET /v1/runs/:id

Steps

  1. Build router from route groups.
use std::sync::Arc;
use tirea_agentos_server::http::{self, AppState};
use tirea_agentos_server::protocol;

let app = axum::Router::new()
    .merge(http::health_routes())
    .merge(http::thread_routes())
    .merge(http::run_routes())
    .merge(protocol::a2a::http::well_known_routes())
    .nest("/v1/ag-ui", protocol::ag_ui::http::routes())
    .nest("/v1/ai-sdk", protocol::ai_sdk_v6::http::routes())
    .nest("/v1/a2a", protocol::a2a::http::routes())
    .with_state(AppState {
        os: Arc::new(agent_os),
        read_store,
        mailbox_service,
    });
  1. Call AI SDK v6 stream.
curl -N \
  -H 'content-type: application/json' \
  -d '{"id":"thread-1","messages":[{"role":"user","content":"hello"}],"runId":"run-1"}' \
  http://127.0.0.1:8080/v1/ai-sdk/agents/assistant/runs
  1. Call AG-UI stream.
curl -N \
  -H 'content-type: application/json' \
  -d '{"threadId":"thread-2","runId":"run-2","messages":[{"role":"user","content":"hello"}],"tools":[]}' \
  http://127.0.0.1:8080/v1/ag-ui/agents/assistant/runs
  1. Query persisted data.
curl 'http://127.0.0.1:8080/v1/threads/thread-1/messages?limit=20'
curl 'http://127.0.0.1:8080/v1/runs?thread_id=thread-1&limit=20'

Verify

  • Stream routes return 200 with content-type: text/event-stream.
  • AI SDK stream returns x-vercel-ai-ui-message-stream: v1 and data: [DONE] trailer.
  • AG-UI stream includes lifecycle events (for example RUN_STARTED and RUN_FINISHED).
  • GET /v1/runs/:id returns run projection metadata.

Common Errors

  • 400 for payload validation (id, threadId, runId, or message/decision rules).
  • 404 for unknown agent_id.
  • Run/A2A routes fail with internal error when run service is not initialized.
  • examples/ai-sdk-starter/README.md exercises AI SDK HTTP streaming end to end
  • examples/copilotkit-starter/README.md exercises AG-UI streaming end to end

Key Files

  • crates/tirea-agentos-server/src/http.rs
  • crates/tirea-agentos-server/src/protocol/ag_ui/http.rs
  • crates/tirea-agentos-server/src/protocol/ai_sdk_v6/http.rs
  • examples/src/starter_backend/mod.rs

Expose NATS Gateway

Use this when producers/consumers communicate through NATS instead of HTTP.

Prerequisites

  • NATS server is reachable.
  • Gateway is started with AGENTOS_NATS_URL.
  • Clients can provide a reply subject (NATS inbox or replySubject).

Subjects

  • agentos.ag-ui.runs
  • agentos.ai-sdk.runs

Steps

  1. Start gateway (example).
AGENTOS_NATS_URL=nats://127.0.0.1:4222 cargo run --package tirea-agentos-server -- --config ./agentos.json
  1. Publish AI SDK request with auto-reply inbox.
nats req agentos.ai-sdk.runs '{"agentId":"assistant","sessionId":"thread-1","input":"hello"}'
  1. Publish AG-UI request with auto-reply inbox.
nats req agentos.ag-ui.runs '{"agentId":"assistant","request":{"threadId":"thread-2","runId":"run-1","messages":[{"role":"user","content":"hello"}],"tools":[]}}'

Verify

  • You receive protocol-encoded run events on reply subject.
  • AG-UI stream contains RUN_STARTED and RUN_FINISHED.
  • AI SDK stream ends with a finish event equivalent to HTTP [DONE] semantics.

Common Errors

  • Missing reply subject when using publish (gateway requires a reply target).
  • Invalid JSON payload or missing required fields.
  • Unknown agentId returns one error event on reply subject.
  • No dedicated UI starter ships for NATS yet; use crates/tirea-agentos-server/tests/nats_gateway.rs as the end-to-end fixture

Key Files

  • crates/tirea-agentos-server/src/protocol/ag_ui/nats.rs
  • crates/tirea-agentos-server/src/protocol/ai_sdk_v6/nats.rs
  • crates/tirea-agentos-server/src/transport/nats.rs
  • crates/tirea-agentos-server/tests/nats_gateway.rs

Integrate AI SDK Frontend

Use this when your web app uses @ai-sdk/react and backend is tirea-agentos-server.

Choose this path when you want the simplest chat-style integration and your frontend does not need AG-UI-specific shared state or frontend tool orchestration.

Best Fit

AI SDK is usually the right choice when:

  • your UI is primarily chat-first
  • you want useChat with minimal frontend runtime glue
  • tools mostly execute on the backend
  • you want to hydrate thread history into an AI SDK-compatible message stream

Choose AG-UI/CopilotKit instead when you need richer shared state, frontend-executed tools, or canvas-style interactions.

Prerequisites

  • Backend is reachable (default http://localhost:8080).
  • AI SDK routes are enabled:
    • POST /v1/ai-sdk/agents/:agent_id/runs
    • GET /v1/ai-sdk/agents/:agent_id/chats/:chat_id/stream
    • GET /v1/ai-sdk/threads/:id/messages

Minimum Architecture

Browser (useChat)
  -> Next.js route (/api/chat)
  -> Tirea AI SDK endpoint (/v1/ai-sdk/agents/:agent_id/runs)
  -> SSE stream back to browser

Thread history hydration:

Browser
  -> GET /v1/ai-sdk/threads/:id/messages
  -> backend returns AI SDK-encoded message history

Steps

  1. Install frontend deps.
npm install @ai-sdk/react ai
  1. Create app/api/chat/route.ts as pass-through proxy.
const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:8080";
const AGENT_ID = process.env.AGENT_ID ?? "default";

export async function POST(req: Request) {
  const incoming = await req.json();
  const agentId = incoming.agentId ?? AGENT_ID;

  const sessionId =
    req.headers.get("x-session-id") ??
    incoming.id ??
    `ai-sdk-${crypto.randomUUID()}`;

  const upstream = await fetch(
    `${BACKEND_URL}/v1/ai-sdk/agents/${agentId}/runs`,
    {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ ...incoming, id: sessionId }),
    },
  );

  if (!upstream.ok) {
    return new Response(await upstream.text(), { status: upstream.status });
  }
  if (!upstream.body) {
    return new Response("upstream body missing", { status: 502 });
  }

  return new Response(upstream.body, {
    headers: {
      "content-type": "text/event-stream",
      "cache-control": "no-cache",
      connection: "keep-alive",
      "x-vercel-ai-ui-message-stream": "v1",
      "x-session-id": sessionId,
    },
  });
}
  1. Build transport with explicit id/messages payload.
"use client";

import { useMemo } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";

export default function ChatPage() {
  const sessionId = "ai-sdk-demo-session";

  const transport = useMemo(
    () =>
      new DefaultChatTransport({
        api: "/api/chat",
        headers: { "x-session-id": sessionId },
        prepareSendMessagesRequest: ({ messages, trigger, messageId }) => {
          const lastAssistantIndex = (() => {
            for (let i = messages.length - 1; i >= 0; i -= 1) {
              if (messages[i]?.role === "assistant") return i;
            }
            return -1;
          })();

          const newUserMessages = messages
            .slice(lastAssistantIndex + 1)
            .filter((m) => m.role === "user");

          return {
            body: {
              id: sessionId,
              runId: crypto.randomUUID(),
              messages: trigger === "regenerate-message" ? [] : newUserMessages,
              ...(trigger ? { trigger } : {}),
              ...(messageId ? { messageId } : {}),
            },
          };
        },
      }),
    [sessionId],
  );

  const { messages, sendMessage, status } = useChat({ transport });
  // render + sendMessage({ text })
}
  1. Optional: load history from GET /v1/ai-sdk/threads/:id/messages before first render.

Backend / Frontend Boundary

In the AI SDK integration path:

  • backend tools execute in tirea-agentos-server
  • frontend mainly owns chat rendering and local UI state
  • thread state remains backend-owned
  • the Next.js route is usually only a transport adapter, not the source of truth

A practical rule:

  • durable conversation and tool state live in Tirea
  • browser-only visual state stays in the frontend
  • if you need frontend-executed tools with suspend/resume semantics, AG-UI is usually a better fit

Request Shape Mapping

The frontend sends AI SDK UI messages, but Tirea expects the v6 HTTP shape:

  • id maps to thread id
  • messages contains the new user messages to submit
  • runId is used for decision-forwarding correlation
  • trigger and messageId are required for regenerate flows

The route adapter is responsible for preserving this shape. Do not send legacy sessionId/input bodies.

Resume And Approval Flow

AI SDK integration supports suspension decision forwarding, but the transport is still chat-centric.

Typical flow:

  1. backend suspends a tool call and emits AI SDK stream events
  2. frontend renders an approval UI from the streamed message parts
  3. frontend submits a decision-only payload using the same id and runId
  4. server forwards the decision to the active run when possible
  5. stream resumes or a new execution path is started if no active run is found

This path works well for approval dialogs, but it is less expressive than AG-UI for frontend tool execution.

Verify

  • /api/chat responds with text/event-stream.
  • Response includes x-vercel-ai-ui-message-stream: v1.
  • Normal chat streams tokens.
  • Regenerate flow (trigger=regenerate-message) works with messageId.
  • History hydration from GET /v1/ai-sdk/threads/:id/messages replays the same thread in the browser.

Common Errors

  • Sending legacy sessionId/input payload to backend HTTP v6 route.
  • Missing id (thread id) in forwarded payload.
  • Missing stream headers, causing client parser failures.
  • examples/ai-sdk-starter/README.md is the canonical AI SDK v6 integration in this repo

Key Files

  • examples/ai-sdk-starter/src/lib/transport.ts
  • examples/ai-sdk-starter/src/pages/playground-page.tsx
  • examples/ai-sdk-starter/src/lib/api-client.ts

Integrate CopilotKit (AG-UI)

Use this when your frontend uses CopilotKit and your backend is tirea-agentos-server AG-UI SSE.

Choose this path when you need frontend tools, shared state, canvas-style UX, or richer suspend/resume flows than a chat-only transport usually provides.

Best Fit

AG-UI/CopilotKit is usually the right choice when:

  • you want shared agent state surfaced directly in the frontend
  • some tools should execute in the browser instead of the backend
  • you need human-in-the-loop approvals and resumable interactions
  • the UI is more than a plain chat transcript

Prerequisites

  • tirea-agentos-server is reachable (default http://localhost:8080).
  • AG-UI endpoint is enabled: POST /v1/ag-ui/agents/:agent_id/runs.
  • Next.js frontend.

Minimum Architecture

Browser (CopilotKit components)
  -> Next.js runtime route (/api/copilotkit)
  -> AG-UI HttpAgent
  -> Tirea AG-UI endpoint (/v1/ag-ui/agents/:agent_id/runs)
  -> SSE AG-UI events back to browser

Thread hydration:

Browser / runtime
  -> GET /v1/ag-ui/threads/:id/messages
  -> backend returns AG-UI-encoded history

Steps

  1. Add frontend dependencies:
npm install @copilotkit/react-core @copilotkit/react-ui @copilotkit/runtime @ag-ui/client
  1. Create lib/copilotkit-app.ts to connect CopilotKit runtime to AG-UI.
import {
  CopilotRuntime,
  ExperimentalEmptyAdapter,
  copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";

const BACKEND_URL = process.env.BACKEND_URL ?? "http://localhost:8080";

const runtime = new CopilotRuntime({
  agents: {
    default: new HttpAgent({
      url: `${BACKEND_URL}/v1/ag-ui/agents/default/runs`,
    }) as any,
  },
});

const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
  runtime,
  serviceAdapter: new ExperimentalEmptyAdapter(),
  endpoint: "/api/copilotkit",
});

export { handleRequest };
  1. Create route handler app/api/copilotkit/route.ts.
import { handleRequest } from "@/lib/copilotkit-app";

export const POST = handleRequest;
  1. Wrap app with CopilotKit provider in app/layout.tsx.
"use client";

import { CopilotKit } from "@copilotkit/react-core";
import "@copilotkit/react-ui/styles.css";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <CopilotKit runtimeUrl="/api/copilotkit" agent="default">
          {children}
        </CopilotKit>
      </body>
    </html>
  );
}
  1. Add chat UI in app/page.tsx using CopilotChat or CopilotSidebar.

Backend / Frontend Boundary

In the AG-UI path, backend and frontend can both participate in tool orchestration.

  • backend remains the source of truth for thread history and durable agent state
  • backend tools execute normally inside Tirea
  • frontend tools are declared with execute = "frontend" and are suspended by the backend
  • CopilotKit resolves those frontend interactions and resumes the run with a result or cancellation

This makes AG-UI the better fit for:

  • browser-native tools
  • UI approvals
  • canvas updates
  • generative UI surfaces

Frontend Tool Flow

When AG-UI frontend tools are enabled, the flow is:

  1. model selects a tool that is marked as frontend-executed
  2. backend does not run that tool locally
  3. backend emits a pending tool interaction via AG-UI
  4. frontend renders the tool UI or approval card
  5. frontend responds with resume or cancel
  6. backend converts that decision into tool result semantics and continues the run

This behavior is implemented by the runtime-side pending plugin in:

  • crates/tirea-agentos-server/src/protocol/ag_ui/runtime.rs

Shared State Model

AG-UI works best when you treat backend thread state as authoritative and frontend state as a projection.

  • backend persists thread state and messages
  • frontend reads AG-UI events and history to render the current projection
  • local UI state can exist, but should not replace durable agent state unless you intentionally send state overrides

Suspend / Resume And HITL

AG-UI is the stronger path for HITL flows.

Typical pattern:

  1. backend permission/plugin/tool logic suspends execution
  2. AG-UI emits a pending interaction event
  3. CopilotKit renders an approval or data-entry component
  4. user responds
  5. runtime sends the decision back to the backend
  6. backend resumes the original run using the supplied decision or tool result

Optional: parse tool-call-progress activity events

When reading raw AG-UI events, inspect ACTIVITY_SNAPSHOT / ACTIVITY_DELTA with activityType = "tool-call-progress".

function onAgUiEvent(event: any) {
  if (event?.type !== "ACTIVITY_SNAPSHOT") return;
  if (event.activityType !== "tool-call-progress") return;
  const node = event.content;
  console.log("tool progress", node.node_id, node.status, node.progress);
}

Verify

  • POST /api/copilotkit streams AG-UI events.
  • Chat UI receives assistant output in real time.
  • Tool calls and shared state updates are reflected in the UI.
  • Frontend-executed tools suspend and resume correctly.
  • Persisted thread history can be rehydrated from GET /v1/ag-ui/threads/:id/messages.

Common Errors

  • Wrong AG-UI URL (/v1/ag-ui/agents/:agent_id/runs) causes empty responses or 404.
  • agent in CopilotKit provider does not match runtime agents key.
  • Package version mismatch may require temporary as any cast for HttpAgent.
  • examples/copilotkit-starter/README.md is the full-featured AG-UI + CopilotKit starter
  • examples/travel-ui/README.md is the smaller travel-specific CopilotKit scenario demo
  • examples/research-ui/README.md is the research-specific CopilotKit scenario demo

Key Files

  • examples/copilotkit-starter/lib/copilotkit-app.ts
  • examples/copilotkit-starter/lib/persisted-http-agent.ts
  • examples/travel-ui/lib/copilotkit-app.ts
  • examples/research-ui/lib/copilotkit-app.ts
  • examples/copilotkit-starter/app/api/copilotkit/route.ts

Use Skills Subsystem

Use this when you want reusable file-backed skills (SKILL.md, references, scripts) as runtime tools/context.

Prerequisites

  • Skill directories containing SKILL.md.
  • tirea-extension-skills available.

Steps

  1. Discover skills from filesystem.
use tirea::skills::FsSkill;

let discovered = FsSkill::discover("./skills")?;
let skills = FsSkill::into_arc_skills(discovered.skills);
  1. Enable skills mode in builder.
use tirea::composition::{AgentDefinition, AgentDefinitionSpec, AgentOsBuilder, SkillsConfig};

let os = AgentOsBuilder::new()
    .with_skills(skills)
    .with_skills_config(SkillsConfig {
        enabled: true,
        advertise_catalog: true,
        ..SkillsConfig::default()
    })
    .with_agent_spec(AgentDefinitionSpec::local_with_id(
        "assistant",
        AgentDefinition::new("deepseek-chat"),
    ))
    .build()?;

Config flags:

  • enabled: registers skill tools (skill, load_skill_resource, skill_script)
  • advertise_catalog: injects available-skills catalog into inference context
  1. (Optional) use scope filters per agent via AgentDefinition.
AgentDefinition::new("deepseek-chat")
    .with_allowed_skills(vec!["code-review".to_string()])
    .with_excluded_skills(vec!["dangerous-skill".to_string()])

These populate RunPolicy.allowed_skills / RunPolicy.excluded_skills, enforced at runtime when skills are resolved.

Verify

  • Resolved tools include skill, load_skill_resource, skill_script.
  • Model receives available-skills context (when discovery mode is enabled).
  • Activated skill resources/scripts are accessible in runtime.

Common Errors

  • Enabling skills mode without providing skills/registry.
  • Tool id conflict with existing skill tool names.
  • No dedicated starter ships with skills enabled yet; the closest wiring surface is examples/src/starter_backend/mod.rs once you add skills discovery/config there

Key Files

  • crates/tirea-extension-skills/src/subsystem.rs
  • crates/tirea-extension-skills/src/lib.rs
  • crates/tirea-agentos/src/runtime/tests.rs

Use MCP Tools

Use this when you want to expose MCP server tools as regular agent tools.

Prerequisites

  • tirea-extension-mcp dependency is available.
  • mcp = { package = "model-context-protocol", version = "0.2", default-features = false, features = ["client"] } in your Cargo.toml.
  • One or more reachable MCP servers.
  • Runtime uses Tokio.

Steps

  1. Build MCP server configs.
use mcp::transport::McpServerConnectionConfig;

let cfg = McpServerConnectionConfig::stdio(
    "mcp_demo",
    "python3",
    vec!["-u".to_string(), "./mcp_server.py".to_string()],
);
  1. Connect MCP registry manager and fetch tool snapshot.
use tirea::extensions::mcp::McpToolRegistryManager;

let manager = McpToolRegistryManager::connect([cfg]).await?;
let mcp_tools = manager.registry().snapshot();
  1. Merge MCP tools into your tool map and build AgentOS.
use std::collections::HashMap;
use std::sync::Arc;
use tirea::composition::{AgentDefinition, AgentDefinitionSpec, AgentOsBuilder};
use tirea::contracts::runtime::tool_call::Tool;

let mut tools: HashMap<String, Arc<dyn Tool>> = HashMap::new();
// add your native tools first...

tools.extend(mcp_tools);

let os = AgentOsBuilder::new()
    .with_tools(tools)
    .with_agent_spec(AgentDefinitionSpec::local_with_id(
        "assistant",
        AgentDefinition::new("deepseek-chat"),
    ))
    .build()?;
  1. Keep manager alive for refresh lifecycle.

Optional refresh controls:

manager.refresh().await?;
manager.start_periodic_refresh(std::time::Duration::from_secs(30))?;
// shutdown path:
let _stopped = manager.stop_periodic_refresh().await;

Verify

  • manager.registry().ids() includes MCP tool ids.
  • Tool execution result contains MCP metadata (mcp.server, mcp.tool).
  • If MCP tool provides UI resource, result metadata includes mcp.ui.resourceUri and UI content fields.

Common Errors

  • Duplicate MCP server name in configs.
  • Duplicate tool id conflict when merging with existing tool map.
  • Periodic refresh started without Tokio runtime.
  • examples/ai-sdk-starter/README.md can surface MCP tool cards when the starter backend is run with MCP_SERVER_CMD

Key Files

  • crates/tirea-extension-mcp/src/lib.rs
  • crates/tirea-extension-mcp/src/client_transport.rs
  • examples/src/starter_backend/mod.rs

Enable Tool Permission HITL

Use this when tool calls must be allow / deny / ask with human approval.

Prerequisites

  • tirea-extension-permission is enabled.
  • Frontend can return approval decisions to run inputs.

Steps

  1. Register permission behaviors.
use std::sync::Arc;
use tirea::composition::{AgentDefinition, AgentDefinitionSpec, AgentOsBuilder};
use tirea::extensions::permission::{PermissionPlugin, ToolPolicyPlugin};

let os = AgentOsBuilder::new()
    .with_registered_behavior("tool_policy", Arc::new(ToolPolicyPlugin))
    .with_registered_behavior("permission", Arc::new(PermissionPlugin))
    .with_agent_spec(AgentDefinitionSpec::local_with_id(
        "assistant",
        AgentDefinition::new("deepseek-chat").with_behavior_ids(vec![
            "tool_policy".to_string(),
            "permission".to_string(),
        ]),
    ))
    .build()?;
  1. Configure permission policy state.
use tirea::extensions::permission::{
    permission_state_action, PermissionAction, ToolPermissionBehavior,
};

let set_default = permission_state_action(PermissionAction::SetDefault {
    behavior: ToolPermissionBehavior::Ask,
});

let allow_server_info = permission_state_action(PermissionAction::SetTool {
    tool_id: "serverInfo".to_string(),
    behavior: ToolPermissionBehavior::Allow,
});
  1. Optional: constrain tools per agent via AgentDefinition.
AgentDefinition::new("deepseek-chat")
    .with_allowed_tools(vec!["search".to_string()])
    .with_excluded_tools(vec!["dangerous_tool".to_string()])

These populate RunPolicy.allowed_tools / RunPolicy.excluded_tools, which are enforced by ToolPolicyPlugin before tool execution.

  1. Forward approval decisions from client to active run.
curl -X POST \
  -H 'content-type: application/json' \
  -d '{"decisions":[{"target_id":"fc_call_1","decision_id":"d1","action":"resume","result":{"approved":true},"updated_at":1760000000000}]}' \
  http://127.0.0.1:8080/v1/runs/<run_id>/inputs

Verify

  • allow: tool executes immediately.
  • deny: tool execution is rejected by policy.
  • ask: run suspends until decision is forwarded.

Common Errors

  • Registering plugin but forgetting to include behavior ids in agent definition.
  • Wrong behavior order (permission before tool_policy) makes out-of-scope checks less strict.
  • Missing target_id / malformed decisions prevents resume.
  • examples/copilotkit-starter/README.md is the most complete approval-focused frontend integration
  • examples/travel-ui/README.md shows approval-gated trip creation
  • examples/research-ui/README.md shows approval-gated resource deletion

Key Files

  • crates/tirea-extension-permission/src/plugin.rs
  • examples/src/starter_backend/mod.rs
  • examples/ai-sdk-starter/src/components/tools/permission-dialog.tsx
  • examples/travel-ui/hooks/useTripApproval.tsx
  • examples/research-ui/hooks/useDeleteApproval.tsx

Use Reminder Plugin

Use this when reminders should be injected into inference context from persisted state.

Prerequisites

  • tirea-extension-reminder is enabled.
  • Agent includes reminder behavior id.

Steps

  1. Register plugin and attach behavior.
use std::sync::Arc;
use tirea::composition::{AgentDefinition, AgentDefinitionSpec, AgentOsBuilder};
use tirea::extensions::reminder::ReminderPlugin;

let os = AgentOsBuilder::new()
    .with_registered_behavior(
        "reminder",
        Arc::new(ReminderPlugin::new().with_clear_after_llm_request(true)),
    )
    .with_agent_spec(AgentDefinitionSpec::local_with_id(
        "assistant",
        AgentDefinition::new("deepseek-chat").with_behavior_id("reminder"),
    ))
    .build()?;
  1. Write reminder state actions.
use tirea::extensions::reminder::add_reminder_action;

let add = add_reminder_action("Call Alice at 3pm");
// dispatch as state action in your behavior/tool pipeline

ReminderState path is reminders and stores deduplicated items: Vec<String>.

  1. Choose clear strategy.
  • true (default): reminders are cleared after each LLM call.
  • false: reminders persist until explicit clear action.

Verify

  • On next inference, reminder text is injected as session context.
  • When clear_after_llm_request=true, reminder list is cleared after injection.

Common Errors

  • Behavior registered but not attached to target agent.
  • Assuming reminders are per-run; reminder scope is thread-level state.
  • No dedicated starter ships with reminders enabled by default; layer this plugin onto examples/ai-sdk-starter/README.md or examples/copilotkit-starter/README.md

Key Files

  • crates/tirea-extension-reminder/src/lib.rs
  • crates/tirea-extension-reminder/src/actions.rs
  • crates/tirea-extension-reminder/src/state.rs

Enable LLMMetry Observability

Use this when you need per-run inference/tool metrics and OpenTelemetry GenAI-aligned spans.

Prerequisites

  • tirea-extension-observability dependency is enabled.
  • Optional: tracing/OTel exporter configured in your runtime.

Steps

  1. Implement a metrics sink.
use tirea::extensions::observability::{AgentMetrics, GenAISpan, MetricsSink, ToolSpan};

struct LoggingSink;

impl MetricsSink for LoggingSink {
    fn on_inference(&self, span: &GenAISpan) {
        eprintln!("inference model={} input={:?} output={:?}", span.model, span.input_tokens, span.output_tokens);
    }

    fn on_tool(&self, span: &ToolSpan) {
        eprintln!("tool={} error={:?}", span.name, span.error_type);
    }

    fn on_run_end(&self, metrics: &AgentMetrics) {
        eprintln!("run tokens={}", metrics.total_tokens());
    }
}
  1. Register LLMMetryPlugin and attach to agent.
use std::sync::Arc;
use genai::chat::ChatOptions;
use tirea::composition::{AgentDefinition, AgentDefinitionSpec, AgentOsBuilder};
use tirea::extensions::observability::LLMMetryPlugin;

let chat_options = ChatOptions::default().with_temperature(0.7);
let llmmetry = LLMMetryPlugin::new(LoggingSink)
    .with_model("deepseek-chat")
    .with_provider("deepseek")
    .with_chat_options(&chat_options);

let os = AgentOsBuilder::new()
    .with_registered_behavior("llmmetry", Arc::new(llmmetry))
    .with_agent_spec(AgentDefinitionSpec::local_with_id(
        "assistant",
        AgentDefinition::new("deepseek-chat").with_behavior_id("llmmetry"),
    ))
    .build()?;

Verify

  • Inference spans include token counts and duration.
  • Tool spans include call id, duration, and error type on failures.
  • Run end callback receives aggregated AgentMetrics.

Common Errors

  • Registering plugin but not adding behavior id to agent.
  • Setting wrong model/provider labels, causing misleading metrics dimensions.
  • examples/src/travel.rs wires LLMMetryPlugin into a real runnable backend

Key Files

  • crates/tirea-extension-observability/src/lib.rs
  • examples/src/travel.rs
  • crates/tirea-agentos-server/tests/phoenix_observability_e2e.rs

Use Sub-Agent Delegation

Use this when one agent orchestrates other agents through built-in delegation tools.

What is auto-wired

By default, AgentOs::resolve(...) wires these tools and behaviors:

  • tools: agent_run, agent_stop, agent_output
  • behaviors: agent_tools, agent_recovery

No manual plugin registration is required for baseline delegation.

Steps

  1. Define worker agents and orchestrator.
let os = AgentOs::builder()
    .with_agent_spec(AgentDefinitionSpec::local_with_id("writer", AgentDefinition::new("deepseek-chat")
            .with_excluded_tools(vec!["agent_run".to_string(), "agent_stop".to_string()]),))
    .with_agent_spec(AgentDefinitionSpec::local_with_id("reviewer", AgentDefinition::new("deepseek-chat")
            .with_excluded_tools(vec!["agent_run".to_string(), "agent_stop".to_string()]),))
    .with_agent_spec(AgentDefinitionSpec::local_with_id("orchestrator", AgentDefinition::new("deepseek-chat")
            .with_allowed_agents(vec!["writer".to_string(), "reviewer".to_string()]),))
    .build()?;
  1. In orchestrator prompt/tool flow, call delegation tools.
  • start or resume: agent_run
  • stop background run tree: agent_stop
  • fetch output snapshot: agent_output
  1. Choose foreground/background execution per agent_run call.
  • background=false: parent waits and receives child progress
  • background=true: child runs asynchronously and can be resumed/stopped later

Verify

  • Orchestrator can call agent_run for allowed child agents.
  • Child run status transitions are visible (running, completed, failed, stopped).
  • agent_output returns child-thread outputs for the requested run_id.

Common Errors

  • Target agent filtered by allowed_agents / excluded_agents.
  • Worker agents accidentally retain delegation tools and recurse unexpectedly.
  • Background runs left running without agent_stop/resume policy.
  • No dedicated UI starter focuses on sub-agents yet; use crates/tirea-agentos/tests/real_multi_subagent_deepseek.rs for the main end-to-end example

Key Files

  • crates/tirea-agentos/src/runtime/agent_tools/manager.rs
  • crates/tirea-agentos/src/runtime/agent_tools/tools/
  • crates/tirea-agentos/tests/real_multi_subagent_deepseek.rs

Debug a Run

Use this when a run stops unexpectedly or tool behavior is incorrect.

Prerequisites

  • Target thread_id and (if available) run_id.
  • Access to the emitted event stream and persisted thread data.

Steps

  1. Confirm termination reason from AgentEvent::RunFinish { termination, .. }.

termination is authoritative and usually one of:

  • NaturalEnd
  • BehaviorRequested
  • Stopped(...)
  • Cancelled
  • Suspended
  • Error
  1. Inspect event timeline ordering:
  • StepStart / StepEnd
  • ToolCallStart / ToolCallDone
  • InferenceComplete
  • Error
  1. Verify persisted delta in storage:
  • New messages
  • New patches
  • Metadata version increment
  1. Check plugin phase behavior if execution is phase-dependent:
  • BeforeInference
  • BeforeToolExecute
  • AfterToolExecute
  1. Reproduce with minimal deterministic inputs and compare event traces.

Verify

A fix is effective when:

  • The same input no longer reproduces the failure.
  • Event sequence and terminal reason are stable across repeated runs.
  • Persisted thread state matches expected messages and patch history.

Common Errors

  • Debugging only final text output and ignoring event stream.
  • Inspecting latest thread snapshot but not patch delta/version movement.
  • Mixing protocol-encoded events with canonical AgentEvent semantics.
  • examples/ai-sdk-starter/README.md includes thread-history verification that is useful when debugging replay and persistence issues
  • examples/copilotkit-starter/README.md includes persisted-thread and canvas flows that surface event-ordering and approval issues quickly

Key Files

  • crates/tirea-agentos/src/runtime/loop_runner/mod.rs
  • crates/tirea-agentos-server/src/http.rs
  • crates/tirea-agentos-server/tests/run_api.rs

Reference Overview

This section is normative and lookup-oriented.

Use these pages by question type:

Capability Matrix

This matrix maps each framework capability to the authoritative docs and concrete implementation paths in this repository.

CapabilityPrimary docsExample / implementation paths
Agent composition (AgentDefinition, behaviors, stop specs)reference/config.md, how-to/build-an-agent.mdcrates/tirea-agentos/src/composition/agent_definition.rs, examples/src/starter_backend/mod.rs
Stop policies and termination controlshow-to/configure-stop-policies.md, reference/config.md, explanation/run-lifecycle-and-phases.mdcrates/tirea-agentos/src/runtime/plugin/stop_policy.rs, examples/src/starter_backend/mod.rs, crates/tirea-agentos/src/runtime/tests.rs
Tool execution modesreference/config.md, explanation/hitl-and-decision-flow.mdcrates/tirea-agentos/src/composition/agent_definition.rs, examples/src/starter_backend/mod.rs
Tool authoring and registrationtutorials/first-tool.md, how-to/add-a-tool.md, reference/typed-tool.mdexamples/src/starter_backend/tools.rs, examples/src/travel/tools.rs
Plugin authoring and registrationhow-to/add-a-plugin.md, reference/derive-macro.mdcrates/tirea-extension-reminder/src/lib.rs, crates/tirea-extension-permission/src/plugin.rs
State patch operations + conflict modelreference/state-ops.md, explanation/state-and-patch-model.mdcrates/tirea-state/src/op.rs, crates/tirea-state/src/apply.rs
Typed state derive (#[derive(State)])reference/derive-macro.mdcrates/tirea-state-derive/src/
State scopes + run-scoped cleanupexplanation/persistence-and-versioning.md, reference/config.mdcrates/tirea-contract/src/lib.rs, crates/tirea-agentos/src/runtime/tests.rs, crates/tirea-agentos/src/runtime/plugin/stop_policy.rs
HTTP SSE server surfacereference/http-api.md, how-to/expose-http-sse.mdcrates/tirea-agentos-server/src/http.rs, examples/src/starter_backend/mod.rs
Canonical Run API (list/get/start/inputs/cancel)reference/run-api.mdcrates/tirea-agentos-server/src/http.rs, crates/tirea-agentos-server/tests/run_api.rs
Decision forwarding / suspend / replayexplanation/hitl-and-decision-flow.md, reference/run-api.md, how-to/enable-tool-permission-hitl.mdcrates/tirea-contract/src/io/decision.rs, crates/tirea-agentos/src/runtime/loop_runner/mod.rs, crates/tirea-agentos-server/tests/run_api.rs
AG-UI protocolreference/protocols/ag-ui.md, how-to/integrate-copilotkit-ag-ui.mdcrates/tirea-agentos-server/src/protocol/ag_ui/http.rs, examples/copilotkit-starter/lib/persisted-http-agent.ts, examples/travel-ui/lib/copilotkit-app.ts, examples/research-ui/lib/copilotkit-app.ts
AI SDK v6 protocolreference/protocols/ai-sdk-v6.md, how-to/integrate-ai-sdk-frontend.mdcrates/tirea-agentos-server/src/protocol/ai_sdk_v6/http.rs, examples/ai-sdk-starter/src/lib/transport.ts
A2A protocolreference/protocols/a2a.mdcrates/tirea-agentos-server/src/protocol/a2a/http.rs, crates/tirea-agentos-server/tests/a2a_http.rs
NATS gateway transportreference/protocols/nats.md, how-to/expose-nats.mdcrates/tirea-agentos-server/src/protocol/ag_ui/nats.rs, crates/tirea-agentos-server/src/protocol/ai_sdk_v6/nats.rs
File thread/run storagehow-to/use-file-store.mdcrates/tirea-store-adapters/src/file_store.rs, crates/tirea-store-adapters/src/file_run_store.rs
Postgres thread/run storagehow-to/use-postgres-store.mdcrates/tirea-store-adapters/src/postgres_store.rs
NATS-buffered + Postgres durabilityhow-to/use-nats-buffered-postgres-store.mdcrates/tirea-store-adapters/src/nats_buffered.rs, crates/tirea-agentos-server/tests/e2e_nats_postgres.rs
Tool permission + HITL approvalhow-to/enable-tool-permission-hitl.md, explanation/hitl-and-decision-flow.mdcrates/tirea-extension-permission/src/, examples/src/starter_backend/mod.rs, examples/ai-sdk-starter/src/components/tools/permission-dialog.tsx, examples/travel-ui/hooks/useTripApproval.tsx, examples/research-ui/hooks/useDeleteApproval.tsx
Reminder pluginhow-to/use-reminder-plugin.mdcrates/tirea-extension-reminder/src/
LLM telemetry / observabilityhow-to/enable-llmmetry-observability.mdcrates/tirea-extension-observability/src/, examples/src/travel.rs
Skills subsystemhow-to/use-skills-subsystem.mdcrates/tirea-extension-skills/src/subsystem.rs
MCP tool bridgehow-to/use-mcp-tools.mdcrates/tirea-extension-mcp/src/lib.rs, examples/src/starter_backend/mod.rs
Sub-agent delegation (agent_run/stop/output)how-to/use-sub-agent-delegation.md, explanation/sub-agent-delegation.mdcrates/tirea-agentos/src/runtime/agent_tools/, crates/tirea-agentos/tests/real_multi_subagent_deepseek.rs

Actions

This page lists the action types available in the runtime, what tools can emit, what plugins can emit, and which built-in plugins use them.

Why This Matters

Tirea is not a “tool returns result only” runtime.

  • tools can emit ToolExecutionEffect
  • plugins return ActionSet<...> from phase hooks
  • the loop validates actions by phase, applies them to StepContext, and reduces state actions into patches

Use this page when you need to answer:

  • “Can a tool change state or only return ToolResult?”
  • “Which action type is valid in this phase?”
  • “Should this behavior live in a tool or a plugin?”

Core Phase Actions

The authoritative definitions live in crates/tirea-contract/src/runtime/phase/action_set.rs.

Phase action enumValid phaseWhat it can doTypical use
LifecycleActionRunStart, StepStart, StepEnd, RunEndState(AnyStateAction)lifecycle bookkeeping, run metadata
BeforeInferenceActionBeforeInferenceAddSystemContext, AddSessionContext, ExcludeTool, IncludeOnlyTools, AddRequestTransform, Terminate, Stateprompt injection, tool filtering, context-window shaping, early termination
AfterInferenceActionAfterInferenceTerminate, Stateinspect model response and stop or persist derived state
BeforeToolExecuteActionBeforeToolExecuteBlock, Suspend, SetToolResult, Statepermission checks, frontend approval, short-circuiting tool execution
AfterToolExecuteActionAfterToolExecuteAddSystemReminder, AddUserMessage, Stateappend follow-up context, inject skill instructions, persist post-tool state

What Tools Can Emit

Tools do not directly return phase enums like BeforeInferenceAction. A tool can influence the runtime in three ways:

  1. Return ToolResult
  2. Write state through ToolCallContext
  3. Return ToolExecutionEffect with Actions

In practice, a tool can safely do:

  • direct typed state writes through ctx.state_of::<T>() or ctx.state::<T>(...)
  • explicit AnyStateAction
  • built-in AfterToolExecuteAction
  • custom Action implementations only when no built-in AfterToolExecuteAction variant matches

The most common tool-side actions are:

  • AnyStateAction
  • AfterToolExecuteAction::AddUserMessage
  • AfterToolExecuteAction::AddSystemReminder
  • custom actions that mutate step-local runtime data after a tool completes and have no built-in equivalent

Important constraint:

  • tools run inside tool execution, so they should think in AfterToolExecute terms
  • they should not try to behave like BeforeInference or BeforeToolExecute plugins

AnyStateAction

AnyStateAction is the generic state mutation wrapper. It is the main bridge between reducer-backed state and the action pipeline.

Use it when:

  • you want reducer-style typed state updates instead of direct setter-style writes
  • a tool or plugin needs to mutate typed state in a phase-aware way
  • you need thread/run/tool-call scope to be resolved consistently by the runtime

Common constructors:

  • AnyStateAction::new::<T>(action) for thread/run scoped state
  • AnyStateAction::new_for_call::<T>(action, call_id) for tool-call scoped state

Important:

  • direct ctx.state... writes are materialized into patches during tool execution and folded into the same effect pipeline
  • for business code, prefer ctx.state... for straightforward typed updates or AnyStateAction::new... for reducer-style domain actions

What Plugins Can Emit

Plugins emit phase-specific core action enums through ActionSet<...>.

Typical patterns:

  • BeforeInferenceAction for prompt/context/tool selection shaping
  • BeforeToolExecuteAction for gating, approval, or short-circuiting tools
  • AfterToolExecuteAction for injecting reminders/messages after a tool
  • LifecycleAction for lifecycle-scoped state changes

If a behavior must apply uniformly across many tools or every run, it belongs in a plugin.

Built-in Plugin Action Matrix

Public extension plugins

PluginActions usedScenario
ReminderPluginBeforeInferenceAction::AddSessionContext, BeforeInferenceAction::Stateinject reminder text into the next inference and optionally clear reminder state
PermissionPluginBeforeToolExecuteAction::Block, BeforeToolExecuteAction::Suspenddeny a tool or suspend for permission approval
ToolPolicyPluginBeforeInferenceAction::IncludeOnlyTools, BeforeInferenceAction::ExcludeTool, BeforeToolExecuteAction::Blockconstrain visible tools up front and enforce scope at execution time
SkillDiscoveryPluginBeforeInferenceAction::AddSystemContextinject the active skill catalog or skill usage instructions into the prompt
LLMMetryPluginno runtime-mutating actions; returns empty ActionSetobservability only, collects spans and metrics without changing behavior

Built-in runtime / integration plugins

PluginActions usedScenario
ContextPluginBeforeInferenceAction::AddRequestTransformcompact + trim history and enable prompt caching before the provider request is sent
AG-UI ContextInjectionPluginBeforeInferenceAction::AddSystemContextinject frontend-provided context into the prompt
AG-UI FrontendToolPendingPluginBeforeToolExecuteAction::Suspend, BeforeToolExecuteAction::SetToolResultforward frontend tools to the UI, then resume with a frontend decision/result

Tool Examples That Emit Actions

Skill activation tool

SkillActivateTool is the clearest example of a tool returning more than a result.

It emits:

  • a success ToolResult
  • AnyStateAction for SkillStateAction::Activate(...)
  • permission-domain state actions via permission_state_action(...)
  • AfterToolExecuteAction::AddUserMessage to insert skill instructions into the message stream

See:

Direct state writes in tools

When a tool writes state through ToolCallContext, the runtime collects the resulting patch and turns it into an action-backed execution effect during tool execution.

That means both of these are valid:

  • write via ctx.state_of::<T>()
  • emit explicit AnyStateAction

Choose based on which model fits the state update better:

  • direct field/setter patching for straightforward state edits
  • reducer action form when you want a domain action log or reducer semantics

Direct ctx.state... writes produce patches that the runtime handles internally. Business code should use either ctx.state... setters or AnyStateAction::new... constructors.

Guidance By Scenario

ScenarioRecommended action form
Add prompt context before the next model callBeforeInferenceAction::AddSystemContext or AddSessionContext
Hide or narrow tools for one runBeforeInferenceAction::IncludeOnlyTools / ExcludeTool
Enforce approval before a tool executesBeforeToolExecuteAction::Suspend
Reject tool execution with an explicit reasonBeforeToolExecuteAction::Block
Return a synthetic tool result without running the toolBeforeToolExecuteAction::SetToolResult
Persist typed state from a tool or pluginAnyStateAction or direct ctx.state... writes
Add follow-up instructions/messages after a tool completesAfterToolExecuteAction::AddUserMessage
Modify request assembly itselfBeforeInferenceAction::AddRequestTransform

Rule Of Thumb

  • If you need phase-aware orchestration, use a plugin and core phase actions.
  • If you need domain work plus a result plus post-tool side effects, use a tool with execute_effect.
  • If all you need is state mutation, stay on the typed path: ctx.state... for direct updates or AnyStateAction::new... for reducer-style actions.

Typed Tool

TypedTool auto-generates JSON Schema from a Rust argument struct and handles deserialization.

Why Prefer TypedTool

  • Generates JSON Schema automatically from the Rust argument type
  • Deserializes JSON into a typed struct before your business logic runs
  • Centralizes validation in Rust instead of manual Value parsing
  • Keeps descriptor metadata and argument schema aligned

Trait Shape

#[async_trait]
pub trait TypedTool: Send + Sync {
    type Args: for<'de> Deserialize<'de> + JsonSchema + Send;

    fn tool_id(&self) -> &str;
    fn name(&self) -> &str;
    fn description(&self) -> &str;

    fn validate(&self, _args: &Self::Args) -> Result<(), String> {
        Ok(())
    }

    async fn execute(
        &self,
        args: Self::Args,
        ctx: &ToolCallContext<'_>,
    ) -> Result<ToolResult, ToolError>;
}

A blanket implementation converts every TypedTool into a normal Tool, so registration is unchanged.

Minimal Example

use async_trait::async_trait;
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::json;
use tirea::contracts::runtime::tool_call::{ToolError, ToolResult, TypedTool};
use tirea::contracts::ToolCallContext;

#[derive(Debug, Deserialize, JsonSchema)]
struct SelectTripArgs {
    trip_id: String,
}

struct SelectTripTool;

#[async_trait]
impl TypedTool for SelectTripTool {
    type Args = SelectTripArgs;

    fn tool_id(&self) -> &str {
        "select_trip"
    }

    fn name(&self) -> &str {
        "Select Trip"
    }

    fn description(&self) -> &str {
        "Select a trip as the currently active trip"
    }

    async fn execute(
        &self,
        args: SelectTripArgs,
        _ctx: &ToolCallContext<'_>,
    ) -> Result<ToolResult, ToolError> {
        Ok(ToolResult::success(
            "select_trip",
            json!({ "selected": args.trip_id }),
        ))
    }
}

Validation Flow

TypedTool validation happens in this order:

  1. The runtime deserializes incoming JSON into Args with serde_json::from_value.
  2. validate(&Args) runs for business rules that schema alone cannot express.
  3. execute(Args, ctx) runs with a typed value.

That means:

  • Missing required fields fail at deserialization time
  • Type mismatches fail at deserialization time
  • Cross-field or domain rules belong in validate

State-Writing Example

This example shows a state-writing tool using the plain Tool trait with execute_effect. State mutations must go through ToolExecutionEffect + AnyStateAction — the runtime rejects direct writes via ctx.state::<T>().set_*().

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tirea::contracts::{AnyStateAction, ToolCallContext};
use tirea::contracts::runtime::tool_call::{ToolExecutionEffect, ToolError, ToolResult};
use tirea::prelude::*;
use tirea_state_derive::State;

#[derive(Debug, Clone, Default, Serialize, Deserialize, State)]
#[tirea(action = "CounterAction")]
struct Counter {
    value: i64,
    label: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
enum CounterAction {
    Increment(i64),
    Rename(String),
}

impl Counter {
    fn reduce(&mut self, action: CounterAction) {
        match action {
            CounterAction::Increment(n) => self.value += n,
            CounterAction::Rename(name) => self.label = name,
        }
    }
}

struct RenameCounter;

#[async_trait]
impl Tool for RenameCounter {
    fn descriptor(&self) -> ToolDescriptor {
        ToolDescriptor::new("rename_counter", "Rename Counter", "Update the counter label")
            .with_parameters(json!({
                "type": "object",
                "properties": { "label": { "type": "string" } },
                "required": ["label"]
            }))
    }

    async fn execute(&self, args: Value, ctx: &ToolCallContext<'_>) -> Result<ToolResult, ToolError> {
        Ok(<Self as Tool>::execute_effect(self, args, ctx).await?.result)
    }

    async fn execute_effect(
        &self,
        args: Value,
        _ctx: &ToolCallContext<'_>,
    ) -> Result<ToolExecutionEffect, ToolError> {
        let label = args["label"]
            .as_str()
            .ok_or_else(|| ToolError::InvalidArguments("label is required".to_string()))?;

        Ok(ToolExecutionEffect::new(ToolResult::success(
            "rename_counter",
            json!({ "label": label }),
        ))
        .with_action(AnyStateAction::new::<Counter>(
            CounterAction::Rename(label.to_string()),
        )))
    }
}

Reading State

Inside a tool, state reads follow one of two patterns:

Use ctx.snapshot_of::<T>() to read the current state as a deserialized Rust value:

let file: WorkspaceFile = ctx.snapshot_of::<WorkspaceFile>().unwrap_or_default();

For advanced cases where the same state type is reused at different paths, use ctx.snapshot_at::<T>("some.path").

Writing State

All state writes use the action-based pattern:

  1. Read current state via snapshot_of
  2. Return ToolExecutionEffect::new(result).with_action(AnyStateAction::new::<T>(action))
  3. The runtime applies the action through the state’s reduce method

The runtime rejects direct state writes through ctx.state::<T>().set_*(). All mutations must go through the action pipeline.

State Scope Examples

State scope is declared on the state type, not on the tool.

#[derive(Debug, Clone, Default, Serialize, Deserialize, State)]
#[tirea(action = "FileAccessAction", scope = "thread")]
struct FileAccessState {
    opened_paths: Vec<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, State)]
#[tirea(action = "EditorRunAction", scope = "run")]
struct EditorRunState {
    current_goal: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, State)]
#[tirea(action = "ApprovalAction", scope = "tool_call")]
struct ApprovalState {
    requested: bool,
}

Use each scope for a different kind of data:

  • thread: durable user-visible state, such as opened files, notes, trips, reports
  • run: temporary execution state for one run, such as a plan, current objective, or runtime bookkeeping
  • tool_call: per-invocation scratch state, especially for suspended calls, approvals, and resumable workflows

If you need the full cleanup semantics, see Persistence and Versioning and Derive Macro.

Hard Constraint Example: Must Read Before Write

Coding agents often need invariants such as “a file must be read before it can be edited”. The simplest robust pattern is to persist read-tracking in thread-scoped state and reject writes that do not satisfy that precondition.

#[derive(Debug, Clone, Default, Serialize, Deserialize, State)]
#[tirea(action = "FileAccessAction", scope = "thread")]
struct FileAccessState {
    opened_paths: Vec<String>,
}

#[derive(Debug, Deserialize, JsonSchema)]
struct ReadFileArgs {
    path: String,
}

#[derive(Debug, Deserialize, JsonSchema)]
struct WriteFileArgs {
    path: String,
    content: String,
}

struct ReadFileTool;
struct WriteFileTool;

// ReadFileTool uses the plain Tool trait (not TypedTool) so it can implement
// execute_effect and emit state actions. Deserialize the typed args manually.
#[async_trait]
impl Tool for ReadFileTool {
    fn descriptor(&self) -> ToolDescriptor {
        ToolDescriptor::new("read_file", "Read File", "Read a file and record access")
            .with_parameters(json!({
                "type": "object",
                "properties": { "path": { "type": "string" } },
                "required": ["path"]
            }))
    }

    async fn execute(&self, args: Value, ctx: &ToolCallContext<'_>) -> Result<ToolResult, ToolError> {
        Ok(<Self as Tool>::execute_effect(self, args, ctx).await?.result)
    }

    async fn execute_effect(
        &self,
        args: Value,
        ctx: &ToolCallContext<'_>,
    ) -> Result<ToolExecutionEffect, ToolError> {
        let typed_args: ReadFileArgs = serde_json::from_value(args)
            .map_err(|e| ToolError::InvalidArguments(e.to_string()))?;

        let content = std::fs::read_to_string(&typed_args.path)
            .map_err(|err| ToolError::ExecutionFailed(err.to_string()))?;

        let access: FileAccessState = ctx.snapshot_of::<FileAccessState>().unwrap_or_default();
        let mut effect = ToolExecutionEffect::new(ToolResult::success(
            "read_file",
            json!({ "path": typed_args.path, "content": content }),
        ));

        if !access.opened_paths.contains(&typed_args.path) {
            effect = effect.with_action(AnyStateAction::new::<FileAccessState>(
                FileAccessAction::MarkOpened(typed_args.path),
            ));
        }

        Ok(effect)
    }
}

#[async_trait]
impl TypedTool for WriteFileTool {
    type Args = WriteFileArgs;

    fn tool_id(&self) -> &str { "write_file" }
    fn name(&self) -> &str { "Write File" }
    fn description(&self) -> &str { "Write a file only after it was read" }

    fn validate(&self, args: &Self::Args) -> Result<(), String> {
        if args.path.trim().is_empty() {
            return Err("path cannot be empty".to_string());
        }
        Ok(())
    }

    async fn execute(
        &self,
        args: WriteFileArgs,
        ctx: &ToolCallContext<'_>,
    ) -> Result<ToolResult, ToolError> {
        let access: FileAccessState = ctx.snapshot_of::<FileAccessState>().unwrap_or_default();
        if !access.opened_paths.contains(&args.path) {
            return Err(ToolError::Denied(format!(
                "write_file requires a prior read_file for {}",
                args.path
            )));
        }

        std::fs::write(&args.path, &args.content)
            .map_err(|err| ToolError::ExecutionFailed(err.to_string()))?;

        Ok(ToolResult::success(
            "write_file",
            json!({ "path": args.path, "written": true }),
        ))
    }
}

This pattern is usually enough when:

  • the invariant is domain-specific
  • the check depends on state accumulated by other tools
  • you want the rule to survive across multiple runs in the same thread

If the same policy must apply to many tools uniformly, move the gate into a plugin or BeforeToolExecute policy instead of duplicating the check in each tool.

ToolExecutionEffect

ToolExecutionEffect lets a tool return:

  • a ToolResult
  • state actions
  • non-state actions applied in AfterToolExecute

Example: State Action + Message Injection

use tirea::contracts::runtime::phase::AfterToolExecuteAction;
use tirea::contracts::runtime::state::AnyStateAction;
use tirea::contracts::runtime::tool_call::{
    Tool, ToolCallContext, ToolDescriptor, ToolError, ToolExecutionEffect, ToolResult,
};

struct ActivateSkillTool;

#[async_trait]
impl Tool for ActivateSkillTool {
    fn descriptor(&self) -> ToolDescriptor {
        ToolDescriptor::new("activate_skill", "Activate Skill", "Activate a skill")
    }

    async fn execute(
        &self,
        _args: serde_json::Value,
        _ctx: &ToolCallContext<'_>,
    ) -> Result<ToolResult, ToolError> {
        Ok(ToolResult::success("activate_skill", serde_json::json!({ "ok": true })))
    }

    async fn execute_effect(
        &self,
        _args: serde_json::Value,
        _ctx: &ToolCallContext<'_>,
    ) -> Result<ToolExecutionEffect, ToolError> {
        Ok(
            ToolExecutionEffect::new(ToolResult::success(
                "activate_skill",
                serde_json::json!({ "ok": true }),
            ))
            // SkillState and SkillStateAction come from tirea-extension-skills
            .with_action(AnyStateAction::new::<SkillState>(
                SkillStateAction::Activate("docx".to_string()),
            ))
            .with_action(AfterToolExecuteAction::AddUserMessage(
                "Skill instructions...".to_string(),
            )),
        )
    }
}

The real skill implementation in this repository goes further and also applies permission-domain actions:

Prefer these built-in AfterToolExecuteAction variants for common post-tool side effects before introducing a custom Action type.

Temporary Permission Changes

One common pattern is: a tool activates a capability, then temporarily widens what the agent is allowed to call.

The important design distinction is:

  • the tool may emit permission-domain state actions as part of its effect
  • the plugin remains responsible for enforcing those permissions at execution time

That separation keeps policy enforcement centralized while still allowing domain tools to request policy-relevant state changes.

Registration

Register TypedTool exactly like any other tool:

use tirea::composition::{tool_map, AgentOsBuilder};

let os = AgentOsBuilder::new()
    .with_tools(tool_map([SelectTripTool]))
    .build()?;

Complete Example In This Repository

  • examples/src/travel/tools.rs contains SelectTripTool, a real TypedTool implementation.
  • crates/tirea-contract/src/runtime/tool_call/tool.rs contains the authoritative trait definition.

Common Mistakes

  • Deriving Deserialize but forgetting JsonSchema on Args
  • Putting business validation into execute instead of validate
  • Falling back to untyped Value parsing even though the input is fixed
  • Assuming validate_args still uses JSON Schema at runtime for TypedTool

For TypedTool, the runtime skips Tool::validate_args and relies on deserialization plus validate(&Args).

API Documentation

The full Rust API reference is generated from source code documentation using cargo doc.

Viewing API Docs

Build and view the API documentation locally:

# Build all crate docs
cargo doc --workspace --no-deps --open

# Or use the unified build script
bash scripts/build-docs.sh

When using scripts/build-docs.sh, the API docs are available at target/book/doc/.

Publishing Docs

The Docs GitHub Actions workflow builds the book and Rust API docs on pushes to main and on manual dispatch.

GitHub Pages deployment is enabled only when both of the following are true:

  • The repository Pages source is set to GitHub Actions
  • The repository variable ENABLE_GITHUB_PAGES_DOCS is set to true

The published site is available at https://tirea-ai.github.io/tirea/.

Crate Index

CrateDescriptionAPI Docs
tirea_stateCore state managementtirea_state
tirea_state_deriveDerive macrostirea_state_derive
tirea_contractShared contractstirea_contract
tirea_agentosAgent runtime & orchestrationtirea_agentos
tirea_store_adaptersPersistence adapterstirea_store_adapters
tirea_agentos_serverServer gatewaytirea_agentos_server
tireaUmbrella re-export cratetirea

Key Entry Points

tirea_state

tirea_contract

tirea_agentos

Derive Macro Reference

#[derive(State)] generates typed state refs, patch collection, and optional reducer wiring.

Basic Usage

use serde::{Deserialize, Serialize};
use tirea_state::State;
use tirea_state_derive::State;

#[derive(Debug, Clone, Serialize, Deserialize, State)]
#[tirea(path = "counter", action = "CounterAction", scope = "run")]
struct Counter {
    value: i64,
}

Struct Attributes

#[tirea(path = "...")]

Sets canonical state path used by State::PATH and state_of::<T>().

#[tirea(action = "TypeName")]

Generates impl StateSpec for T with type Action = TypeName, delegating reducer to inherent fn reduce(&mut self, action).

#[tirea(scope = "thread|run|tool_call")]

When action is set, also generates StateSpec::SCOPE.

  • default: thread
  • valid values: thread, run, tool_call

Field Attributes

#[tirea(rename = "json_key")]

Maps Rust field to different JSON key.

#[tirea(default = "expr")]

Uses expression when field is missing.

#[tirea(skip)]

Excludes field from generated ref API.

#[tirea(nested)]

Treats field type as nested State, returning nested ref accessors.

#[tirea(flatten)]

Flattens nested struct fields into parent object.

#[tirea(lattice)]

Marks field as CRDT/lattice field.

Generated behavior:

  • field diff emits Op::LatticeMerge
  • generated ref includes merge_<field>(&T) helper
  • register_lattice(...) and lattice_keys() are emitted for this type

Validation Rules

Compile-time errors are raised for invalid combinations:

  • flatten + rename
  • lattice + nested
  • lattice + flatten
  • lattice on Option<T>, Vec<T>, Map<K,V>
  • flatten on non-struct/non-State field

Generated API Shape

For included fields, macro generates typed methods on YourTypeRef<'a> such as:

  • readers: field()
  • setters: set_field(...)
  • optional helpers: field_none()
  • vec helpers: field_push(...)
  • map helpers (String key): field_insert(key, value)
  • numeric helpers: increment_field(...), decrement_field(...)
  • delete helpers: delete_field()
  • nested refs: nested_field()

Exact method set depends on field type and attributes.

Generated Trait Implementations

  • impl State for T
  • type Ref<'a> = TRef<'a>
  • const PATH: &'static str
  • from_value / to_value
  • optimized diff_ops (field-level)
  • optional impl StateSpec when action is configured

Operations

Operations (Op) are atomic state mutations against JSON paths.

Operation Types

Set

Set a value at path. Creates intermediate objects when needed.

#![allow(unused)]
fn main() {
extern crate tirea_state;
extern crate serde_json;
use tirea_state::{Op, path};
use serde_json::json;

let op = Op::set(path!("user", "name"), json!("Alice"));
}

Delete

Delete value at path. No-op when path is absent.

#![allow(unused)]
fn main() {
extern crate tirea_state;
use tirea_state::{Op, path};

let op = Op::delete(path!("user", "temp_field"));
}

Append

Append to array. Creates array when absent.

#![allow(unused)]
fn main() {
extern crate tirea_state;
extern crate serde_json;
use tirea_state::{Op, path};
use serde_json::json;

let op = Op::append(path!("user", "roles"), json!("admin"));
}

Error: AppendRequiresArray when target exists but is not array.

MergeObject

Shallow-merge object keys into target object.

#![allow(unused)]
fn main() {
extern crate tirea_state;
extern crate serde_json;
use tirea_state::{Op, path};
use serde_json::json;

let op = Op::merge_object(path!("user", "settings"), json!({"theme": "dark"}));
}

Error: MergeRequiresObject when target is not object.

Increment / Decrement

Numeric arithmetic on existing numeric value.

#![allow(unused)]
fn main() {
extern crate tirea_state;
use tirea_state::{Op, path};

let inc = Op::increment(path!("counter"), 1i64);
let dec = Op::decrement(path!("counter"), 1i64);
}

Error: NumericOperationOnNonNumber.

Insert

Insert into array index (shift right).

#![allow(unused)]
fn main() {
extern crate tirea_state;
extern crate serde_json;
use tirea_state::{Op, path};
use serde_json::json;

let op = Op::insert(path!("items"), 0, json!("first"));
}

Error: IndexOutOfBounds.

Remove

Remove first matching array element.

#![allow(unused)]
fn main() {
extern crate tirea_state;
extern crate serde_json;
use tirea_state::{Op, path};
use serde_json::json;

let op = Op::remove(path!("tags"), json!("deprecated"));
}

Errors: PathNotFound when path does not exist; TypeMismatch when target exists but is not an array.

LatticeMerge

Merge CRDT/lattice delta at path.

#![allow(unused)]
fn main() {
extern crate tirea_state;
extern crate serde_json;
use tirea_state::{Op, path};
use serde_json::json;

let op = Op::lattice_merge(path!("permission_policy", "allowed_tools"), json!(["search"]));
}

Behavior:

  • with LatticeRegistry: performs registered lattice merge
  • without registry: falls back to Set semantics

Number Type

Numeric ops use Number:

pub enum Number {
    Int(i64),
    Float(f64),
}

From is implemented for i32, i64, u32, u64, f32, f64.

Paths

path! builds path segments:

#![allow(unused)]
fn main() {
extern crate tirea_state;
use tirea_state::path;

let p = path!("users", 0, "name");
let p = path!("settings", "theme");
}

Apply Semantics

  • apply_patch / apply_patches: plain op application
  • apply_patch_with_registry / apply_patches_with_registry: enables lattice-aware merge for Op::LatticeMerge

Serialization

Op serializes with op discriminator:

{"op":"set","path":["user","name"],"value":"Alice"}
{"op":"increment","path":["counter"],"amount":1}
{"op":"lattice_merge","path":["permission_policy","allowed_tools"],"value":["search"]}

Thread Model

Thread is the persisted conversation and state history entity.

Fields

  • id: thread identifier
  • resource_id: optional ownership key for listing/filtering
  • parent_thread_id: lineage for delegated/sub-agent runs
  • messages: ordered message history
  • state: base snapshot value
  • patches: tracked patch history since base snapshot
  • metadata.version: persisted version cursor

Core Methods

  • Thread::new(id)
  • Thread::with_initial_state(id, state)
  • with_message / with_messages
  • with_patch / with_patches
  • rebuild_state()
  • replay_to(index)
  • snapshot()

Persistence Coupling

Storage append operations consume ThreadChangeSet plus VersionPrecondition.

Run Context

RunContext is the mutable run-scoped workspace derived from a persisted Thread.

Responsibilities

  • Track run-time messages and patches
  • Expose immutable snapshots of current state (snapshot, snapshot_of, snapshot_at)
  • Emit incremental run delta via take_delta()
  • Carry run identity and version cursor

Common Calls

  • RunContext::from_thread(thread, run_policy)
  • messages() / add_message(...)
  • add_thread_patch(...)
  • take_delta()
  • has_delta()
  • suspended_calls()

Events

AgentEvent is the canonical run event stream.

Lifecycle

  • RunStart
  • StepStart
  • InferenceComplete
  • ToolCallStart / ToolCallDelta / ToolCallReady / ToolCallDone (see note below)
  • StepEnd
  • RunFinish

State and UI Events

  • TextDelta
  • ReasoningDelta / ReasoningEncryptedValue
  • StateSnapshot / StateDelta
  • MessagesSnapshot
  • ActivitySnapshot / ActivityDelta
  • ToolCallResumed
  • Error

Tool Call Progress Payload

Tool-level progress is emitted through ActivitySnapshot / ActivityDelta with:

  • activity_type = "tool-call-progress" (canonical)
  • message_id = "tool_call:<tool_call_id>" (node id)

Canonical content shape (tool-call-progress.v1):

{
  "type": "tool-call-progress",
  "schema": "tool-call-progress.v1",
  "node_id": "tool_call:call_123",
  "parent_node_id": "tool_call:call_parent_1",
  "parent_call_id": "call_parent_1",
  "call_id": "call_123",
  "tool_name": "mcp.search",
  "status": "running",
  "progress": 0.42,
  "total": 100,
  "message": "fetching documents",
  "run_id": "run_abc",
  "parent_run_id": "run_root",
  "thread_id": "thread_1",
  "updated_at_ms": 1760000000000
}

call_id / parent_call_id are framework-maintained lineage fields. Tools should not set or override them.

Compatibility:

  • consumers should still accept legacy activity_type = "progress"
  • consumers should ignore unknown fields for forward compatibility

ToolCallDone Wire Fields

ToolCallDone carries additional fields on the wire that are not part of the in-process enum variant:

  • patch (optional) — TrackedPatch representing state changes produced by the tool call.
  • message_id — pre-generated ID for the stored tool result message.
  • outcomeToolCallOutcome derived from the result (succeeded, failed, etc.). Computed automatically; not set by tools.

Consumers receiving ToolCallDone over SSE or NATS will see all of these fields in the serialized event data.

Terminal Semantics

RunFinish.termination indicates why the run ended and should be treated as authoritative.

HTTP API

This page lists the complete HTTP surface exposed by tirea-agentos-server.

Conventions

  • Error response shape:
{ "error": "<message>" }
  • Stream responses use text/event-stream.
  • Query limit is clamped to 1..=200.
  • Canonical Run API and A2A task APIs rely on the configured ThreadReader/state store.

Endpoint Map

Health:

  • GET /health

Threads:

  • GET /v1/threads
  • GET /v1/threads/summaries
  • GET /v1/threads/:id
  • GET /v1/threads/:id/messages
  • POST /v1/threads/:id/interrupt
  • GET /v1/threads/:id/mailbox
  • PATCH /v1/threads/:id/metadata
  • DELETE /v1/threads/:id

Canonical runs:

  • GET /v1/runs
  • GET /v1/runs/:id
  • POST /v1/runs
  • POST /v1/runs/:id/inputs
  • POST /v1/runs/:id/cancel

AG-UI:

  • POST /v1/ag-ui/agents/:agent_id/runs
  • GET /v1/ag-ui/threads/:id/messages

AI SDK v6:

  • POST /v1/ai-sdk/agents/:agent_id/runs
  • GET /v1/ai-sdk/agents/:agent_id/chats/:chat_id/stream (legacy: /runs/:chat_id/stream)
  • GET /v1/ai-sdk/threads/:id/messages

A2A:

  • GET /.well-known/agent-card.json
  • GET /v1/a2a/agents
  • GET /v1/a2a/agents/:agent_id/agent-card
  • POST /v1/a2a/agents/:agent_id/message:send
  • GET /v1/a2a/agents/:agent_id/tasks/:task_id
  • POST /v1/a2a/agents/:agent_id/tasks/:task_id:cancel

Core Examples

Health:

curl -i http://127.0.0.1:8080/health

List thread projections:

curl 'http://127.0.0.1:8080/v1/threads?offset=0&limit=50&parent_thread_id=thread-root'

Load raw thread messages:

curl 'http://127.0.0.1:8080/v1/threads/thread-1/messages?after=10&limit=20&order=asc&visibility=all&run_id=run-1'

Interrupt thread (cancel active run and supersede queued entries):

curl -X POST http://127.0.0.1:8080/v1/threads/thread-1/interrupt

Response (202):

{
  "status": "interrupt_requested",
  "thread_id": "thread-1",
  "generation": 3,
  "cancelled_run_id": "run-1",
  "superseded_pending_count": 1,
  "superseded_pending_entry_ids": ["entry-2"]
}

List thread mailbox entries:

curl 'http://127.0.0.1:8080/v1/threads/thread-1/mailbox?offset=0&limit=50&status=queued&origin=external'

Query params: offset, limit (clamped 1..=200, default 50), status (queued, claimed, accepted, superseded, cancelled, dead_letter), origin (external, internal, none, default external), visibility (internal, none, default all).

Stream Run Endpoints

AI SDK v6 stream (id/messages payload):

curl -N \
  -H 'content-type: application/json' \
  -d '{"id":"thread-1","runId":"run-1","messages":[{"id":"u1","role":"user","content":"hello"}]}' \
  http://127.0.0.1:8080/v1/ai-sdk/agents/assistant/runs

AI SDK decision forwarding to an active run:

curl -X POST \
  -H 'content-type: application/json' \
  -d '{"id":"thread-1","runId":"run-1","messages":[{"role":"assistant","parts":[{"type":"tool-approval-response","approvalId":"fc_perm_1","approved":true}]}]}' \
  http://127.0.0.1:8080/v1/ai-sdk/agents/assistant/runs

AI SDK regenerate-message:

curl -X POST \
  -H 'content-type: application/json' \
  -d '{"id":"thread-1","trigger":"regenerate-message","messageId":"m_assistant_1"}' \
  http://127.0.0.1:8080/v1/ai-sdk/agents/assistant/runs

AI SDK resume stream for active chat id:

curl -N http://127.0.0.1:8080/v1/ai-sdk/agents/assistant/chats/thread-1/stream

AG-UI stream:

curl -N \
  -H 'content-type: application/json' \
  -d '{"threadId":"thread-2","runId":"run-2","messages":[{"role":"user","content":"hello"}],"tools":[]}' \
  http://127.0.0.1:8080/v1/ag-ui/agents/assistant/runs

Canonical Run API

Start run:

curl -N \
  -H 'content-type: application/json' \
  -d '{"agentId":"assistant","threadId":"thread-1","messages":[{"role":"user","content":"hello"}]}' \
  http://127.0.0.1:8080/v1/runs

List runs:

curl 'http://127.0.0.1:8080/v1/runs?thread_id=thread-1&status=done&origin=ag_ui&limit=20'

Forward decisions or continue:

curl -X POST \
  -H 'content-type: application/json' \
  -d '{"decisions":[{"target_id":"fc_perm_1","decision_id":"d1","action":"resume","result":{"approved":true},"updated_at":1760000000000}]}' \
  http://127.0.0.1:8080/v1/runs/run-1/inputs

Cancel:

curl -X POST http://127.0.0.1:8080/v1/runs/run-1/cancel

A2A

Gateway discovery card:

curl -i http://127.0.0.1:8080/.well-known/agent-card.json

Submit task:

curl -X POST \
  -H 'content-type: application/json' \
  -d '{"input":"hello from a2a"}' \
  http://127.0.0.1:8080/v1/a2a/agents/assistant/message:send

Get task projection:

curl http://127.0.0.1:8080/v1/a2a/agents/assistant/tasks/<task_id>

Cancel task:

curl -X POST http://127.0.0.1:8080/v1/a2a/agents/assistant/tasks/<task_id>:cancel

Validation Failures

AI SDK v6:

  • empty id -> 400 (id cannot be empty)
  • no user input and no decisions and not regenerate -> 400
  • trigger=regenerate-message without valid messageId -> 400
  • legacy payload (sessionId/input) -> 400 (legacy AI SDK payload shape is no longer supported; use id/messages)

AG-UI:

  • empty threadId -> 400
  • empty runId -> 400

Run API:

  • empty agentId on run creation -> 400
  • /inputs with both messages and decisions empty -> 400
  • /inputs with messages but missing agentId -> 400

A2A:

  • action not message:send -> 400
  • decision-only submit without taskId -> 400
  • cancel path without :cancel suffix -> 400

Shared:

  • unknown agent_id -> 404
  • unknown run/task id -> 404

Run API

The Run API is the canonical HTTP surface for run/task management.

Unlike protocol adapters (ag-ui, ai-sdk, a2a), this API exposes a stable transport-neutral run model.

Prerequisites

  • Server must include http::run_routes().

Endpoints

  • GET /v1/runs
  • GET /v1/runs/:id
  • POST /v1/runs
  • POST /v1/runs/:id/inputs
  • POST /v1/runs/:id/cancel

Run Record Model

GET /v1/runs/:id returns RunRecord:

{
  "run_id": "run-1",
  "thread_id": "thread-1",
  "agent_id": "assistant",
  "parent_run_id": "run-0",
  "parent_thread_id": "thread-0",
  "origin": "ag_ui",
  "status": "done",
  "termination_code": "input_required",
  "termination_detail": "approval needed",
  "created_at": 1760000000000,
  "updated_at": 1760000005000,
  "metadata": null
}

origin:

  • user
  • subagent
  • ag_ui
  • ai_sdk
  • a2a
  • internal

status:

  • running
  • waiting
  • done

List Runs

GET /v1/runs query params:

  • offset (default 0)
  • limit (clamped 1..=200, default 50)
  • thread_id
  • parent_run_id
  • status
  • origin
  • termination_code
  • created_at_from, created_at_to (unix millis, inclusive)
  • updated_at_from, updated_at_to (unix millis, inclusive)

Example:

curl 'http://127.0.0.1:8080/v1/runs?thread_id=t1&status=done&origin=ag_ui&limit=20'

Start Run

POST /v1/runs starts a run and streams canonical SSE events.

Minimal payload:

{
  "agentId": "assistant",
  "messages": [{ "role": "user", "content": "hello" }]
}

All accepted fields:

  • agentId / agent_id (required)
  • threadId / thread_id / contextId / context_id (optional — generated if absent)
  • runId / run_id / taskId / task_id (optional — generated if absent)
  • parentRunId / parent_run_id (optional — links this run to a parent run for lineage tracking)
  • parentThreadId / parent_thread_id / parentContextId / parent_context_id (optional — links to a parent thread)
  • resourceId / resource_id (optional — associates the run with a resource identifier)
  • state (optional JSON object — initial state to merge into the thread before the run starts)
  • messages (optional array — user messages to start the run with)
  • initialDecisions / initial_decisions / decisions (optional array — pre-resolved tool call decisions)

Example:

curl -N \
  -H 'content-type: application/json' \
  -d '{"agentId":"assistant","threadId":"thread-1","messages":[{"role":"user","content":"hello"}]}' \
  http://127.0.0.1:8080/v1/runs

Push Inputs

POST /v1/runs/:id/inputs has two modes.

  1. Decision forwarding (active run):
{
  "decisions": [
    {
      "target_id": "fc_perm_1",
      "decision_id": "d1",
      "action": "resume",
      "result": { "approved": true },
      "updated_at": 1760000000000
    }
  ]
}

Response (202):

{
  "status": "decision_forwarded",
  "run_id": "run-1",
  "thread_id": "thread-1"
}
  1. Continuation run (messages provided):
{
  "agentId": "assistant",
  "messages": [{ "role": "user", "content": "continue" }],
  "decisions": []
}

Response (202):

{
  "status": "continuation_started",
  "parent_run_id": "run-1",
  "thread_id": "thread-1"
}

Rules:

  • messages and decisions cannot both be empty.
  • If messages is present, agentId is required.
  • Decision-only forwarding requires the target run to be active.

Cancel Run

POST /v1/runs/:id/cancel

Response (202):

{
  "status": "cancel_requested",
  "run_id": "run-1"
}

If run exists but is not active: 400 (run is not active).

Errors

Common API errors:

  • 404: run not found: <id>
  • 404: agent not found: <id>
  • 400: input validation failures
  • 500: internal errors (including uninitialized run service)

Error body shape:

{ "error": "bad request: messages and decisions cannot both be empty" }

Config

Server Environment Variables

From tirea-agentos-server CLI (crates/tirea-agentos-server/src/main.rs):

  • AGENTOS_HTTP_ADDR (default 127.0.0.1:8080)
  • AGENTOS_STORAGE_DIR (default ./threads)
  • AGENTOS_CONFIG (JSON config file path)
  • AGENTOS_NATS_URL (enables NATS gateway)
  • TENSORZERO_URL (routes model calls through TensorZero provider)

Run records are stored under ${AGENTOS_STORAGE_DIR}/runs when using the default file run store.

To print the canonical JSON Schema for AGENTOS_CONFIG:

cargo run -p tirea-agentos-server -- --print-agent-config-schema

AGENTOS_CONFIG JSON Shape

{
  "agents": [
    {
      "kind": "local",
      "id": "assistant",
      "name": "Assistant",
      "description": "Primary hosted assistant",
      "model": "gpt-4o-mini",
      "system_prompt": "You are a helpful assistant.",
      "max_rounds": 10,
      "tool_execution_mode": "parallel_streaming",
      "behavior_ids": ["tool_policy", "permission"],
      "stop_condition_specs": [
        { "type": "max_rounds", "rounds": 10 }
      ]
    }
  ]
}

You can also register remote A2A agents:

{
  "agents": [
    {
      "kind": "a2a",
      "id": "researcher",
      "name": "Researcher",
      "description": "Remote research agent",
      "endpoint": "https://example.test/v1/a2a",
      "remote_agent_id": "remote-researcher",
      "poll_interval_ms": 250,
      "auth": {
        "kind": "bearer_token",
        "token": "secret"
      }
    }
  ]
}

Local agent file fields:

  • id (required)
  • name (optional, defaults to id)
  • description (optional, default empty string)
  • model (optional, defaults to AgentDefinition::default().model)
  • system_prompt (optional, default empty string)
  • max_rounds (optional)
  • tool_execution_mode (optional, default parallel_streaming)
  • behavior_ids (optional, default [])
  • stop_condition_specs (optional, default [])

Remote A2A agent file fields:

  • id (required)
  • name (optional, defaults to id)
  • description (optional, default empty string)
  • endpoint (required, A2A base URL)
  • remote_agent_id (optional, defaults to id)
  • poll_interval_ms (optional, clamped to the runtime minimum)
  • auth (optional)
    • { "kind": "bearer_token", "token": "..." }
    • { "kind": "header", "name": "X-Api-Key", "value": "..." }

Legacy local entries without "kind": "local" are still accepted.

tool_execution_mode values:

  • sequential
  • parallel_batch_approval
  • parallel_streaming

Tool Execution Mode Semantics

ModeSchedulerSuspension handlingUse when
sequentialOne tool call at a timeAt most one call is actively executing at a timeDeterministic debugging and strict call ordering matter more than latency
parallel_batch_approvalParallel tool execution per roundApproval/suspension outcomes are applied after the tool round commitsMultiple tools may fan out, but you still want batch-style resume behavior
parallel_streamingParallel tool execution per roundStream mode can surface progress and apply resume decisions while tools are still in flightRich UIs need progress, activity events, and lower-latency approval loops

parallel_streaming is the default because it fits the frontend-oriented AG-UI / AI SDK starter flows well.

stop_condition_specs (StopConditionSpec) values:

  • max_rounds
  • timeout
  • token_budget
  • consecutive_errors
  • stop_on_tool
  • content_match
  • loop_detection

Stop Policy Wiring and Semantics

Stop conditions are enforced by the internal stop_policy behavior. You do not register this behavior id manually; AgentOsBuilder wires it automatically when an agent resolves with stop specs or stop-condition ids.

max_rounds is still the top-level ergonomic field on AgentDefinition, but it is lowered into StopConditionSpec::MaxRounds during wiring. If you already provide an explicit max_rounds stop spec, the implicit one is not added a second time.

Built-in stop conditions:

SpecWhat it measuresUse when
max_roundsCompleted tool/inference roundsYou want a hard upper bound on loop depth
timeoutWall-clock elapsed timeLong-running agents must terminate predictably
token_budgetCumulative prompt + completion tokensSpend must stay inside a token budget
consecutive_errorsBack-to-back failing tool roundsYou want to halt repeated tool failure cascades
stop_on_toolSpecific tool id emitted by the modelA tool should act as an explicit finish/escape hatch
content_matchLiteral text pattern in model outputYou need a simple semantic stop trigger without a dedicated tool
loop_detectionRepeated tool-call name patternsYou want to cut off obvious repetitive tool loops

Stop-policy runtime bookkeeping lives in run-scoped state under __kernel.stop_policy_runtime, so it is reset on each new run.

AgentDefinition Fields (Builder-Level)

AgentDefinition supports these orchestration fields:

  • Identity/model: id, model, system_prompt
  • Loop policy: max_rounds, tool_execution_mode
  • Inference options: chat_options, fallback_models, llm_retry_policy
  • Behavior/stop wiring: behavior_ids, stop_condition_specs, stop_condition_ids
  • Visibility controls:
    • tools: allowed_tools, excluded_tools
    • skills: allowed_skills, excluded_skills
    • delegated agents: allowed_agents, excluded_agents

Model Fallbacks and Retries

  • fallback_models: ordered fallback model ids that are tried after the primary model
  • llm_retry_policy: retry behavior for transient inference failures before the framework escalates to fallbacks or terminates

Keep these fields close to your provider registry definitions so a single source of truth controls which model ids are valid in each environment.

Scope Filters (RunPolicy)

Runtime policy filters are stored as RunPolicy fields (set via set_*_if_absent methods during agent resolution):

  • allowed_tools / excluded_tools
  • allowed_skills / excluded_skills
  • allowed_agents / excluded_agents

Error Handling

tirea-state uses the thiserror crate with a unified TireaError enum.

TireaError

pub enum TireaError {
    PathNotFound { path: Path },
    IndexOutOfBounds { path: Path, index: usize, len: usize },
    TypeMismatch { path: Path, expected: &'static str, found: &'static str },
    NumericOperationOnNonNumber { path: Path },
    MergeRequiresObject { path: Path },
    AppendRequiresArray { path: Path },
    InvalidOperation { message: String },
    Serialization(serde_json::Error),
}

Variants

VariantWhen It Occurs
PathNotFoundAccessing a path that doesn’t exist in the document
IndexOutOfBoundsArray index exceeds array length
TypeMismatchExpected one type, found another (e.g., expected object, found string)
NumericOperationOnNonNumberIncrement/Decrement on a non-numeric value
MergeRequiresObjectMergeObject on a non-object value
AppendRequiresArrayAppend on a non-array value
InvalidOperationGeneral invalid operation
Serializationserde_json serialization/deserialization failure

TireaResult

Convenience type alias:

pub type TireaResult<T> = Result<T, TireaError>;

Error Context with with_prefix

When working with nested state, errors include the full path context. The with_prefix method prepends a path segment:

// If a nested struct at "address" has an error at "city":
let err = TireaError::path_not_found(path!("city"));
let contextualized = err.with_prefix(&path!("address"));
// Error now shows: "path not found: address.city"

Convenience Constructors

TireaError provides factory methods for each variant:

  • TireaError::path_not_found(path)
  • TireaError::index_out_of_bounds(path, index, len)
  • TireaError::type_mismatch(path, expected, found)
  • TireaError::numeric_on_non_number(path)
  • TireaError::merge_requires_object(path)
  • TireaError::append_requires_array(path)
  • TireaError::invalid_operation(message)

Agent-Level Errors

tirea defines additional error types:

  • ToolError — Errors from tool execution
  • AgentLoopError — Errors in the agent loop (LLM failures, tool errors)
  • ThreadStoreError — Thread persistence failures
  • AgentOsRunError — Run preparation/execution errors in orchestration
  • AgentOsBuildError / AgentOsWiringError — Configuration errors

Ecosystem Integrations

This page summarizes common AG-UI and AI SDK integration patterns and maps them to this project.

Which Frontend Path To Choose

Use this quick rule:

NeedPreferred path
Fastest chat-style integration with useChatAI SDK v6
Rich shared state, frontend tools, generative UI, HITLAG-UI / CopilotKit
Backend-only tools with a thin frontend adapterAI SDK v6
Frontend-executed tools that suspend and resume runsAG-UI / CopilotKit
Canvas-style or co-agent-like UXAG-UI / CopilotKit

Repo-specific mapping:

IntegrationBackend endpointFrontend runtime shapeBest for
AI SDK v6POST /v1/ai-sdk/agents/:agent_id/runsuseChat + SSE adapter routeplain chat, minimal glue, backend-centric tools
AG-UI / CopilotKitPOST /v1/ag-ui/agents/:agent_id/runsCopilotKit runtime proxy + HttpAgentshared state, frontend tools, approvals, canvas UX

In-Repo Shortest Paths

  • AI SDK: examples/ai-sdk-starter/
  • CopilotKit / AG-UI: examples/copilotkit-starter/

These are the primary references when you need full working frontend integrations rather than protocol reference alone.

CopilotKit Integration Patterns

Pattern A: Runtime Proxy + HttpAgent (remote backend)

Used when agent runtime is hosted separately and exposes AG-UI over HTTP.

  • CopilotKit runtime runs in Next.js route (/api/copilotkit).
  • HttpAgent points to backend AG-UI run endpoint.

Examples:

Pattern B: Runtime Proxy + framework-specific adapter

Used when framework has a dedicated CopilotKit adapter.

  • LangGraphHttpAgent / LangGraphAgent
  • MastraAgent

Examples:

Pattern C: Canvas and HITL focused apps

Used for shared state, generative UI, and human-in-the-loop workflows.

Examples:

Official Starter Ecosystem Support

The table below combines AG-UI upstream support status with CopilotKit official starter availability.

Framework/SpecAG-UI Upstream StatusCopilotKit Starter AvailabilityStarter Repositories
LangGraphSupportedwith-*, canvas-*, coagents-*, persisted threadswith-langgraph-fastapi, with-langgraph-fastapi-persisted-threads, with-langgraph-js, with-langgraph-python, canvas-with-langgraph-python, coagents-starter-langgraph
MastraSupportedwith-*, canvas-*with-mastra, canvas-with-mastra
Pydantic AISupportedwith-*with-pydantic-ai
LlamaIndexSupportedwith-*, canvas-*with-llamaindex, canvas-with-llamaindex, canvas-with-llamaindex-composio
CrewAI FlowsSupported (CrewAI)with-*, coagents-*with-crewai-flows, coagents-starter-crewai-flows
Microsoft Agent FrameworkSupportedwith-*with-microsoft-agent-framework-dotnet, with-microsoft-agent-framework-python
Google ADKSupportedwith-*with-adk
AWS StrandsSupportedwith-*with-strands-python
AgnoSupportedwith-*with-agno
AG2Supporteddemo-level onlyag2-feature-viewer
A2A ProtocolSupportedprotocol starterswith-a2a-middleware, with-a2a-a2ui
Oracle Agent SpecSupportedprotocol/spec starterwith-agent-spec
MCP AppsSupportedprotocol/spec starterwith-mcp-apps
AWS Bedrock AgentsIn Progressnone yet(no official starter repo found)
OpenAI Agent SDKIn Progressnone yet(no official starter repo found)
Cloudflare AgentsIn Progressnone yet(no official starter repo found)

Minimum Feature Comparison (Official Starters)

This comparison is based on actual page.tsx and route.ts usage in each starter.

StarterRuntime AdapterShared StateFrontend ActionsGenerative UIHITL
with-langgraph-fastapiLangGraphHttpAgentYesYesYesYes
with-langgraph-jsLangGraphAgentYesYesYesNo
with-mastraMastraAgentYesYesYesYes
with-pydantic-aiHttpAgentYesYesYesYes
with-llamaindexLlamaIndexAgentYesYesYesNo
with-crewai-flowsHttpAgentYesYesYesNo
with-agnoHttpAgentNoYesYesNo
with-adkHttpAgentYesYesYesYes
with-strands-pythonHttpAgentYesYesYesNo
with-microsoft-agent-framework-dotnetHttpAgentYesYesYesYes
with-microsoft-agent-framework-pythonHttpAgentYesYesYesYes
with-langgraph-fastapi-persisted-threadsLangGraphHttpAgentYesYesYesYes

Notes:

  • Most starters use Next.js copilotRuntimeNextJSAppRouterEndpoint as the frontend runtime bridge.
  • Most non-LangGraph/Mastra starters use generic HttpAgent transport.
  • with-langgraph-fastapi-persisted-threads is the explicit persisted-thread variant.

AG-UI Supported Frameworks (Upstream)

AG-UI upstream lists first-party/partner integrations including LangGraph, CrewAI, Microsoft Agent Framework, ADK, Strands, Mastra, Pydantic AI, Agno, and LlamaIndex.

AI SDK Frontend Pattern

For @ai-sdk/react, the common pattern is:

  1. useChat in the browser.
  2. Next.js /api/chat route adapts UI messages to backend request body.
  3. Route passes through SSE stream and AI SDK headers.

Best in this repository when:

  • frontend is mostly a chat shell
  • the backend remains the only place tools execute
  • you want the smallest integration surface

Related how-to:

AG-UI / CopilotKit Frontend Pattern

For CopilotKit and AG-UI, the common pattern is:

  1. CopilotKit runtime runs behind a same-origin Next.js route.
  2. HttpAgent points at Tirea’s AG-UI endpoint.
  3. AG-UI SSE events drive chat, shared state, activity updates, and frontend tool suspensions.
  4. Frontend decisions are sent back to resume pending interactions.

Best in this repository when:

  • frontend must participate in tool execution
  • you need richer HITL flows
  • you want persisted thread hydration plus shared state UI

Related how-to:

Related docs:

Mapping to Tirea Endpoints

Run streaming:

  • AG-UI: POST /v1/ag-ui/agents/:agent_id/runs
  • AI SDK v6: POST /v1/ai-sdk/agents/:agent_id/runs

History:

  • Raw messages: GET /v1/threads/:id/messages
  • AG-UI encoded: GET /v1/ag-ui/threads/:id/messages
  • AI SDK encoded: GET /v1/ai-sdk/threads/:id/messages

In-Repo Integration Examples

CopilotKit:

  • examples/copilotkit-starter/README.md
  • examples/travel-ui/README.md
  • examples/research-ui/README.md

AI SDK:

  • examples/ai-sdk-starter/README.md

AG-UI Protocol

Endpoints

  • POST /v1/ag-ui/agents/:agent_id/runs
  • GET /v1/ag-ui/threads/:id/messages

Request Model (RunAgentInput)

Required:

  • threadId
  • runId

Core optional fields:

  • messages
  • tools
  • context
  • state
  • parentRunId
  • parentThreadId
  • model
  • systemPrompt
  • config
  • forwardedProps

Minimal request:

{
  "threadId": "thread-1",
  "runId": "run-1",
  "messages": [{ "role": "user", "content": "Plan my weekend" }],
  "tools": []
}

Runtime Mapping

  • RunAgentInput is converted to internal RunRequest.
  • model / systemPrompt override resolved agent defaults for this run.
  • config can override tool execution mode and selected chat options.
  • config overrides are applied at runtime (tool execution mode, chat options) but not persisted to state. forwardedProps is a pass-through field on the request struct.

Frontend tools

When tools[].execute = "frontend":

  • backend registers runtime stub descriptors
  • execution is suspended and returned as pending call
  • client decisions are consumed as tool result (resume) or denial (cancel)

Decision-only forwarding

If request has no new user input but contains interaction decisions, server first attempts to forward them to active run key (ag_ui + agent + thread + run).

Success response (202):

{
  "status": "decision_forwarded",
  "threadId": "thread-1",
  "runId": "run-1"
}

Response Transport

  • content-type: text/event-stream
  • data: frames contain AG-UI protocol events
  • lifecycle includes events such as RUN_STARTED, RUN_FINISHED

Tool progress example:

{
  "type": "ACTIVITY_SNAPSHOT",
  "messageId": "tool_call:call_123",
  "activityType": "tool-call-progress",
  "content": {
    "type": "tool-call-progress",
    "schema": "tool-call-progress.v1",
    "node_id": "tool_call:call_123",
    "parent_node_id": "tool_call:call_parent_1",
    "parent_call_id": "call_parent_1",
    "status": "running",
    "progress": 0.4,
    "message": "searching..."
  },
  "replace": true
}

Validation and Errors

  • empty threadId -> 400
  • empty runId -> 400
  • unknown agent_id -> 404

Error shape:

{ "error": "bad request: threadId cannot be empty" }

AI SDK v6 Protocol

Endpoints

  • POST /v1/ai-sdk/agents/:agent_id/runs
  • GET /v1/ai-sdk/agents/:agent_id/chats/:chat_id/stream (legacy: /runs/:chat_id/stream)
  • GET /v1/ai-sdk/threads/:id/messages

Request Model (POST /runs)

AI SDK v6 HTTP uses id/messages.

  • id (required): thread id
  • messages (required): UI messages array
  • parentThreadId (optional)
  • trigger (optional): submit-message or regenerate-message
  • messageId (required when trigger=regenerate-message)

Example (normal submit):

{
  "id": "thread-1",
  "runId": "run-1",
  "messages": [
    { "id": "u1", "role": "user", "content": "Summarize latest messages" }
  ]
}

Example (regenerate):

{
  "id": "thread-1",
  "trigger": "regenerate-message",
  "messageId": "m_assistant_1"
}

Legacy shape (sessionId + input) is rejected for HTTP v6 UI transport.

Decision Forwarding

Decision-only submissions are extracted from assistant message parts (for example tool-approval-response).

If there is no user input and decisions are present, server first tries to forward decisions to an active run keyed by:

  • protocol: ai_sdk
  • agent_id
  • thread id (id)
  • runId

Success response:

{
  "status": "decision_forwarded",
  "threadId": "thread-1"
}

If no active run is found, request falls back to normal run execution path.

Response Transport

Headers:

  • content-type: text/event-stream
  • x-vercel-ai-ui-message-stream: v1
  • x-tirea-ai-sdk-version: v6

Stream semantics:

  • SSE frames contain AI SDK UI stream events (start, text-*, finish, data-*, …)
  • server appends data: [DONE] trailer

Tool progress is emitted as data-activity-snapshot (activityType = tool-call-progress).

Resume Stream

GET /v1/ai-sdk/agents/:agent_id/chats/:chat_id/stream (legacy: /runs/:chat_id/stream)

  • 204 No Content if no active fanout stream exists for agent_id:chat_id
  • 200 SSE stream when active

History Endpoint

GET /v1/ai-sdk/threads/:id/messages

Returns AI SDK-encoded message history for thread hydration/replay.

Validation and Errors

  • empty id -> 400 (id cannot be empty)
  • no user input and no suspension decisions and not regenerate -> 400
  • regenerate without messageId -> 400
  • regenerate with empty messageId -> 400
  • unknown agent_id -> 404

Error body:

{ "error": "bad request: request must include user input or suspension decisions" }

A2A Protocol

A2A routes provide gateway discovery plus task-style run submission and control.

Endpoints

Discovery:

  • GET /.well-known/agent-card.json
  • GET /v1/a2a/agents
  • GET /v1/a2a/agents/:agent_id/agent-card

Task APIs:

  • POST /v1/a2a/agents/:agent_id/message:send
  • GET /v1/a2a/agents/:agent_id/tasks/:task_id
  • POST /v1/a2a/agents/:agent_id/tasks/:task_id:cancel

Discovery Semantics

/.well-known/agent-card.json:

  • Returns a gateway card with taskManagement, streaming, and agentDiscovery capability flags.
  • Adds HTTP caching headers:
    • cache-control: public, max-age=30, must-revalidate
    • etag: W/"a2a-agents-..."
  • Supports if-none-match (* and CSV values).

Single-agent deployment:

  • Well-known card url points directly to /v1/a2a/agents/<id>/message:send.

Multi-agent deployment:

  • Well-known card url points to /v1/a2a/agents.

message:send Request

Accepted payload fields:

  • contextId / context_id (optional)
  • taskId / task_id (optional)
  • input (optional string)
  • message (optional object: { role?, content })
  • decisions (optional ToolCallDecision[])

Example (new task):

{
  "input": "hello from a2a"
}

Example (continue existing task):

{
  "taskId": "run-1",
  "input": "continue"
}

Example (decision-only forward):

{
  "taskId": "run-1",
  "decisions": [
    {
      "target_id": "fc_perm_1",
      "decision_id": "d1",
      "action": "resume",
      "result": { "approved": true },
      "updated_at": 1760000000000
    }
  ]
}

Response for submission (202):

{
  "contextId": "thread-1",
  "taskId": "run-1",
  "status": "submitted"
}

Response for decision forwarding (202):

{
  "contextId": "thread-1",
  "taskId": "run-1",
  "status": "decision_forwarded"
}

Task Query

GET /v1/a2a/agents/:agent_id/tasks/:task_id response:

{
  "taskId": "run-1",
  "contextId": "thread-1",
  "status": "done",
  "origin": "a2a",
  "terminationCode": null,
  "terminationDetail": null,
  "createdAt": 1760000000000,
  "updatedAt": 1760000005000,
  "message": {
    "role": "assistant",
    "content": "final assistant reply"
  },
  "artifacts": [
    {
      "content": "final assistant reply"
    }
  ],
  "history": [
    {
      "role": "user",
      "content": "hello from a2a"
    },
    {
      "role": "assistant",
      "content": "final assistant reply"
    }
  ]
}

Notes:

  • message is the latest public assistant output when one exists; otherwise it is null.
  • artifacts contains the latest public assistant output normalized as text artifacts.
  • history contains public user / assistant / system thread messages in order.

Task Cancel

POST /v1/a2a/agents/:agent_id/tasks/:task_id:cancel

Response (202):

{
  "taskId": "run-1",
  "status": "cancel_requested"
}

Validation and Errors

  • Action must be exactly message:send.
  • Decision-only requests require taskId.
  • Empty payloads are rejected unless at least one of input/message/decisions/contextId/taskId exists.
  • Cancel path must end with :cancel and must be POST.
  • 404 for unknown agent or missing task/run.
  • 400 when task exists but is not active (task is not active).

Error body shape:

{ "error": "bad request: unsupported A2A action; expected 'message:send'" }

NATS Protocol

NATS gateway exposes protocol-encoded run streaming over request/reply subjects.

Subjects

Default subjects:

  • agentos.ag-ui.runs
  • agentos.ai-sdk.runs

Request Payloads

AG-UI payload:

{
  "agentId": "assistant",
  "request": {
    "threadId": "t1",
    "runId": "r1",
    "messages": [{ "role": "user", "content": "hello" }],
    "tools": []
  },
  "replySubject": "_INBOX.x"
}

AI SDK payload (NATS path):

{
  "agentId": "assistant",
  "sessionId": "t1",
  "input": "hello",
  "runId": "r1",
  "replySubject": "_INBOX.x"
}

Note: AI SDK NATS currently uses sessionId/input, while AI SDK HTTP v6 UI route uses id/messages. runId is optional; when provided it is used as the run identifier for the started run.

Reply Subject Resolution

Gateway chooses reply target in this order:

  1. NATS message reply inbox (msg.reply)
  2. payload replySubject

If both are missing, request is rejected with missing reply subject.

Reply Behavior

  • Request starts run and publishes protocol events to reply subject.
  • Invalid request or agent resolution failure publishes one protocol error event.
  • AG-UI replies use AG-UI event encoding.
  • AI SDK replies use AI SDK UI stream event encoding.

Operational Notes

  • NATS is transport only; run lifecycle remains canonical AgentEvent-driven.
  • Run tracking/projection still uses configured run service/store.

Architecture

Tirea runtime is organized as three layers:

Application -> AgentOs (orchestration + execution engine) -> Thread/State Engine

1. Application Layer

Your application defines tools, agent definitions, and integration endpoints.

Primary call path:

  • Build AgentOs via AgentOsBuilder
  • Submit RunRequest
  • Consume streamed AgentEvent

2. AgentOs (Orchestration + Execution)

AgentOs handles both pre-run orchestration and loop execution:

Orchestration (composition/, runtime/):

  • Resolve agent/model/plugin wiring (plugins implement the AgentBehavior trait)
  • Load or create thread
  • Deduplicate incoming messages
  • Persist pre-run checkpoint
  • Construct RunContext

Execution engine (engine/, runtime/loop_runner/):

Loop is phase-driven:

  • RunStart
  • StepStart -> BeforeInference -> AfterInference -> BeforeToolExecute -> AfterToolExecute -> StepEnd
  • RunEnd

Termination is explicit in RunFinish.termination.

3. Thread + State Engine

State mutation is patch-based:

  • State' = apply_patch(State, Patch)
  • Thread stores base state + patch history + messages
  • RunContext accumulates run delta and emits take_delta() for persistence

Design Intent

  • Deterministic state transitions
  • Append-style persistence with version checks
  • Transport-independent runtime (AgentEvent as core stream)

See Also

Immutable State Management

tirea-state provides typed access to JSON state with automatic patch collection, enabling deterministic state transitions and full replay capability.

The Patch Model

State is never mutated directly. Instead, changes are described as patches — serializable records of operations:

State' = apply_patch(State, Patch)

A Patch contains a list of Op (operations), each targeting a specific path in the JSON document.

#![allow(unused)]
fn main() {
extern crate tirea_state;
extern crate serde_json;
use tirea_state::{apply_patch, Patch, Op, path};
use serde_json::json;

let state = json!({"count": 0, "name": "counter"});

let patch = Patch::new()
    .with_op(Op::set(path!("count"), json!(10)))
    .with_op(Op::set(path!("updated"), json!(true)));

let new_state = apply_patch(&state, &patch).unwrap();

assert_eq!(new_state["count"], 10);
assert_eq!(new_state["updated"], true);
assert_eq!(state["count"], 0); // Original unchanged
}

Key Types

  • Patch — A container of operations. Created via Patch::new().with_op(...) or collected automatically from typed state access.
  • Op — A single atomic operation: Set, Delete, Append, MergeObject, Increment, Decrement, Insert, Remove, LatticeMerge.
  • Path — A path into a JSON document, e.g., path!("users", 0, "name").
  • apply_patch / apply_patches — Pure functions that produce new state from old state + patches.

StateManager

StateManager manages immutable state with patch history:

  • Tracks all applied patches (timestamps are optional and caller-supplied via TrackedPatch, not generated by StateManager)
  • Supports replay to a specific history index via replay_to(index: usize)
  • Provides conflict detection between concurrent patches via detect_conflicts

JsonWriter

For dynamic JSON manipulation without typed structs, use JsonWriter:

#![allow(unused)]
fn main() {
extern crate tirea_state;
extern crate serde_json;
use tirea_state::{JsonWriter, path};
use serde_json::json;

let mut w = JsonWriter::new();
w.set(path!("user", "name"), json!("Alice"));
w.append(path!("user", "roles"), json!("admin"));
w.increment(path!("user", "login_count"), 1i64);

let patch = w.build();
}

Conflict Detection

When multiple patches modify overlapping paths, detect_conflicts identifies the conflicts:

  • compute_touched — Determines which paths a patch affects
  • detect_conflicts — Compares two sets of touched paths to find overlaps
  • ConflictKind — Describes the type of conflict (e.g., both write to same path)

Run Lifecycle and Phases

The runtime uses a two-layer state machine: a run-level state machine tracks coarse execution status, while a tool-call-level state machine tracks each tool call independently. Suspension bridges the two layers — when all active tool calls are suspended, the run transitions to Waiting; when a resume decision arrives, the run transitions back to Running.

Two-Layer State Machine

Layer 1: Run Lifecycle (RunStatus)

Persisted at state["__run"].

stateDiagram-v2
    [*] --> Running
    Running --> Waiting: all tool calls suspended
    Running --> Done: NaturalEnd / BehaviorRequested / Stopped / Cancelled / Error
    Waiting --> Running: resume decision received
    Waiting --> Done: Cancelled / Error
StatusMeaning
RunningRun is actively executing (inference or tools)
WaitingRun is paused waiting for external resume decisions
DoneTerminal — run finished with a TerminationReason

Terminal reasons: NaturalEnd, BehaviorRequested, Stopped(StoppedReason), Cancelled, Suspended, Error.

StoppedReason carries { code: String, detail: Option<String> } — both the stop code and an optional descriptive message.

Layer 2: Tool Call Lifecycle (ToolCallStatus)

Persisted per call at state["__tool_call_scope"]["<call_id>"]["tool_call_state"].

stateDiagram-v2
    [*] --> New
    New --> Running
    New --> Suspended: pre-execution suspend
    Running --> Suspended: tool returned Pending
    Running --> Succeeded: tool returned Success/Warning
    Running --> Failed: tool returned Error
    Running --> Cancelled: run cancelled
    Suspended --> Resuming: resume decision received
    Suspended --> Cancelled: cancel decision or run cancelled
    Resuming --> Running: replay tool call
    Resuming --> Suspended: re-suspended after replay
    Resuming --> Succeeded
    Resuming --> Failed
    Resuming --> Cancelled
StatusMeaning
NewCall observed but not yet started
RunningCall is executing
SuspendedCall paused waiting for external decision
ResumingExternal decision received, replay in progress
SucceededTerminal — execution succeeded
FailedTerminal — execution failed
CancelledTerminal — call cancelled

How the Layers Connect

  1. During a tool round, each tool call transitions through its own lifecycle.
  2. After the round commits, the run evaluates all outcomes:
    • If all outcomes are Suspended → run transitions to Waiting and terminates with TerminationReason::Suspended.
    • If any outcome is non-suspended → run stays Running and loops back to inference.
  3. An inbound ToolCallDecision triggers:
    • Tool call: SuspendedResuming → replay → terminal state.
    • Run: WaitingRunning (if the run was suspended).

Durable State Paths

PathContent
__runRunLifecycleState (id, status, done_reason, updated_at)
__tool_call_scope.<call_id>.tool_call_stateToolCallState (per-call status)
__tool_call_scope.<call_id>.suspended_callSuspendedCallState (suspended call payload)

Canonical Top-Level Flow

  1. RunStart
  2. commit(UserMessage) + optional resume replay (apply_decisions_and_replay)
  3. Loop:
    • RESUME_TOOL_CALL (apply inbound decisions + replay if any)
    • StepStart
    • BeforeInference
    • LLM_CALL
    • AfterInference
    • StepEnd
    • Optional TOOL_CALL round:
      • BeforeToolExecute
      • Tool execution
      • AfterToolExecute
      • apply tool results + commit
      • RESUME_TOOL_CALL again (consume decisions received during tool round)
  4. RunEnd

This loop applies to both run_loop and run_loop_stream; stream mode adds extra decision handling windows while inference/tool execution is in-flight.

Run Execution Flow (non-stream canonical)

stateDiagram-v2
    [*] --> RunStart
    RunStart --> CommitUser: CKPT UserMessage
    CommitUser --> ResumeReplay0
    ResumeReplay0 --> CommitReplay0: replayed
    ResumeReplay0 --> LoopTop: no replay
    CommitReplay0 --> LoopTop
    CommitReplay0 --> Suspended: new suspended at run-start
    LoopTop --> ResumeToolCall
    ResumeToolCall --> StepPrepare
    StepPrepare --> Terminate: run_action=Terminate
    StepPrepare --> LlmCall: run_action=Continue
    LlmCall --> Error
    LlmCall --> Cancelled
    LlmCall --> AfterInference
    AfterInference --> CommitAssistant: CKPT AssistantTurnCommitted
    CommitAssistant --> Terminate: post-inference terminate
    CommitAssistant --> NaturalEnd: no tool calls
    CommitAssistant --> ToolRound: has tool calls
    ToolRound --> Error
    ToolRound --> Cancelled
    ToolRound --> CommitTool: CKPT ToolResultsCommitted
    CommitTool --> ResumeAfterTool
    ResumeAfterTool --> Suspended: all outcomes suspended
    ResumeAfterTool --> LoopTop: has non-suspended outcomes
    Terminate --> RunEnd
    NaturalEnd --> RunEnd
    Suspended --> RunEnd
    Cancelled --> RunEnd
    Error --> RunEnd
    RunEnd --> CommitFinished: CKPT RunFinished(force=true)
    CommitFinished --> [*]

Checkpoint Triggers

StateCommitter is optional. When configured, checkpoints are emitted at these boundaries:

  1. UserMessage
    • Trigger: after RunStart side effects are applied.
  2. ToolResultsCommitted (run-start replay)
    • Trigger: run-start replay actually executed at least one resumed call.
  3. AssistantTurnCommitted
    • Trigger: each successful LLM_CALL result is applied (AfterInference + assistant message + StepEnd).
  4. ToolResultsCommitted (tool round)
    • Trigger: each completed tool round is applied to session state/messages.
  5. ToolResultsCommitted (decision replay in-loop)
    • Trigger: inbound decision resolved and replay produced effects.
  6. RunFinished (force=true)
    • Trigger: any terminal exit path (NaturalEnd / Suspended / Cancelled / Error / plugin-requested termination).

Resume Gate After Tool Commit

After CommitTool, the loop does not jump directly to LLM_CALL. It always passes through RESUME_TOOL_CALL first (apply_decisions_and_replay).

Why:

  • consume decisions that arrived during tool execution;
  • avoid stale Suspended evaluation;
  • persist replay side effects before the next inference step.

So the transition is:

CommitTool -> RESUME_TOOL_CALL -> StepStart/BeforeInference -> LLM_CALL.

RESUME_TOOL_CALL Sub-State Machine (Decision Replay)

RESUME_TOOL_CALL is the decision-drain + replay gate used at loop top and immediately after tool-round commit.

stateDiagram-v2
    [*] --> DrainDecisionChannel
    DrainDecisionChannel --> NoDecision: channel empty / unresolved only
    DrainDecisionChannel --> ResolveDecision: matched suspended call
    ResolveDecision --> PersistResumeDecision: upsert __resume_decisions
    PersistResumeDecision --> Replay
    Replay --> ReplayCancel: action=Cancel
    Replay --> ReplayResume: action=Resume
    ReplayCancel --> ClearResolved
    ReplayResume --> ExecuteReplayedTool
    ExecuteReplayedTool --> ClearResolved: outcome != Suspended
    ExecuteReplayedTool --> SetNextSuspended: outcome = Suspended
    SetNextSuspended --> ClearResolved
    ClearResolved --> SnapshotIfStateChanged
    SnapshotIfStateChanged --> CommitReplay: CKPT ToolResultsCommitted
    CommitReplay --> [*]
    NoDecision --> [*]

Notes:

  • NoDecision exits with no replay commit.
  • CommitReplay is only triggered when at least one decision is resolved and replay path executes.
  • The same sub-state machine is reused in:
    • run-start drain path;
    • loop-top apply_decisions_and_replay;
    • post-tool apply_decisions_and_replay.

TOOL_CALL Sub-State Machine (Per ToolCall)

This diagram shows how plugin phases drive ToolCallStatus transitions within a single tool-call round. The outcome (ToolCallOutcome) maps directly to the terminal ToolCallStatus values described in Layer 2 above.

stateDiagram-v2
    [*] --> BeforeToolExecute
    BeforeToolExecute --> Blocked: plugin block / tool_blocked
    BeforeToolExecute --> Suspended: plugin suspend / tool_pending
    BeforeToolExecute --> Failed: out-of-scope / tool-missing / arg-invalid
    BeforeToolExecute --> ExecuteTool: allow
    ExecuteTool --> Succeeded: ToolResult success/warning
    ExecuteTool --> Failed: ToolResult error
    ExecuteTool --> Suspended: ToolResult pending
    Succeeded --> AfterToolExecute
    Failed --> AfterToolExecute
    Blocked --> AfterToolExecute
    Suspended --> AfterToolExecute
    AfterToolExecute --> [*]

Outcome-to-status mapping:

ToolCallOutcomeToolCallStatus
SucceededSucceeded
FailedFailed
SuspendedSuspended

Important:

  • Blocked is a pre-execution gate outcome (from BeforeToolExecute) and is returned to model as ToolResult::error with ToolCallOutcome::Failed.
  • In canonical loop execution, ExecuteTool itself does not produce a cancellation-flavored per-tool outcome; execution-time cancellation is handled at run/tool round level (see below).

CancellationToken Impact on TOOL_CALL

CancellationToken affects TOOL_CALL at round/executor level:

  • If token is cancelled while waiting tool round completion, executor returns cancellation error and run terminates with TerminationReason::Cancelled.
  • This is not represented as a per-tool blocked/failed gate result.

So there are two different “cancel” semantics:

  1. ToolCallOutcome::Failed: plugin/tool-gate blocks and returns tool error.
  2. TerminationReason::Cancelled: external run cancellation token aborts run.

Parallel Tool Execution and Incoming Resume

Non-stream (run_loop)

  • During tool_executor.execute(...).await, decision channel is not drained.
  • Incoming decisions stay queued.
  • They are applied in the immediate post-tool RESUME_TOOL_CALL phase.

Stream (run_loop_stream)

  • Tool round uses tokio::select! over:
    • tool future,
    • activity channel,
    • decision channel.
  • Incoming decisions are applied immediately (apply_decision_and_replay).
  • If a suspended call is already resolved during this window, returned suspended result for that call is filtered to avoid duplicate pending events.

Clarifications for Common Questions

1) Tool failure vs loop error

  • Tool-level failures (tool not found, arg validation failure, plugin block, tool returned error result) are written as tool result messages and the loop continues to next inference round.
  • Runtime-level errors (tool executor failure, patch/apply failure, checkpoint commit failure) terminate with TerminationReason::Error.

2) CommitTool granularity

  • CommitTool is per tool round batch (Vec<ToolExecutionResult>), not per single tool call.
  • In sequential executor, the batch may be a prefix if execution stops at first suspended call.

3) Why post-tool replay is required

After tool batch commit, loop runs apply_decisions_and_replay again to:

  • consume decisions that arrived while tools were running;
  • prevent stale suspended-state checks;
  • make replay side effects durable before next LLM_CALL.

4) Transition when there are non-suspended tool results

When tool results contain at least one non-suspended outcome, the run does not terminate. Control returns to loop top:

CommitTool -> RESUME_TOOL_CALL -> StepStart/BeforeInference -> LLM_CALL.

Why This Design

  • Phases isolate extension logic in plugins.
  • Run termination is explicit via TerminationReason.
  • Step-local control (tool filtering, tool suspension) is deterministic.

Frontend Interaction and Approval Model

Tirea treats frontend tool calls, permission approvals, and other human-in-the-loop interactions as one runtime model rather than unrelated ad-hoc features.

The core idea is:

tool or plugin requests suspension
-> runtime persists pending call state
-> client returns a decision
-> runtime replays deterministically

In code, “plugin” refers to a type that implements the AgentBehavior trait.

This is why frontend tools, approval dialogs, and other manual intervention points all fit the same machinery.

The Problem This Model Solves

Agent systems often need hard pauses that depend on an external actor:

  • a frontend tool must run in the browser, not on the backend
  • a permission gate must ask a human before continuing
  • a structured form or approval card must collect additional input
  • a run must survive process boundaries and resume later

If each of these is implemented as a separate custom transport callback, the runtime becomes hard to reason about and impossible to replay consistently.

Tirea instead normalizes them into:

  • phase actions
  • suspended tool calls
  • decision forwarding
  • deterministic replay

One Abstraction, Multiple Use Cases

Case 1: Frontend tool execution

A tool is selected by the model, but execution must happen in the frontend.

The backend:

  • does not execute the tool locally
  • suspends the tool call
  • emits a pending interaction payload

The frontend:

  • renders the tool UI
  • returns either a result (resume) or a rejection (cancel)

The runtime:

  • stores the decision
  • resumes the call using the configured resume mode

Case 2: Permission approval

A tool is executable in principle, but policy requires explicit approval first.

The permission plugin:

  • inspects current policy state in BeforeToolExecute
  • blocks, allows, or suspends

If it suspends:

  • the run may move to Waiting
  • the client later forwards an approval decision
  • the runtime replays the call

Case 3: Other manual intervention

The same pattern also works for:

  • confirm/delete flows
  • structured input requests
  • frontend-generated intermediate results
  • UI-only actions that still need durable runtime coordination

Why These Are Unified

These scenarios look different at the UI layer, but they are the same at the runtime layer:

  1. execution cannot proceed right now
  2. the reason must be persisted durably
  3. an external actor must provide input
  4. resumed execution must be deterministic

That common shape is why Tirea models them all as suspended tool calls plus replay, instead of bespoke UI callbacks.

The Runtime Pieces

The unified model is built from a small set of primitives:

  • BeforeToolExecuteAction::Suspend
  • BeforeToolExecuteAction::SetToolResult
  • BeforeToolExecuteAction::Block
  • SuspendTicket
  • per-call suspended state
  • decision forwarding
  • replay of the original tool call

These primitives are transport-independent. AG-UI, AI SDK, and Run API adapters only change how requests and events are encoded.

Run State Machine

Run-level status tracks whether the whole run is still making progress or is waiting on external input.

stateDiagram-v2
    [*] --> Running
    Running --> Waiting: all active tool calls suspended
    Waiting --> Running: decision received and replay starts
    Running --> Done
    Waiting --> Done

Meaning:

  • Running: model inference and/or tool execution is still progressing
  • Waiting: the run cannot continue until an external decision arrives
  • Done: terminal exit, including suspended-terminal completion for the current run attempt

This is the run-level view that frontends often surface as “working”, “needs input”, or “finished”.

Tool Call State Machine

Each tool call has its own lifecycle.

stateDiagram-v2
    [*] --> New
    New --> Running
    New --> Suspended: pre-execution suspend
    Running --> Suspended: tool returned Pending
    Running --> Succeeded
    Running --> Failed
    Suspended --> Resuming: decision received
    Resuming --> Running: replay
    Resuming --> Suspended
    Resuming --> Succeeded
    Resuming --> Failed
    Suspended --> Cancelled

This is what lets one run contain multiple independently suspended or resumed calls.

How Frontend Tools Fit

Frontend tools are not a separate execution engine. They are runtime-managed suspended calls with a special resume path.

In AG-UI integrations:

  • backend recognizes a tool should execute in the frontend
  • backend emits BeforeToolExecuteAction::Suspend
  • frontend collects UI input or performs the browser-side action
  • frontend sends a decision back
  • backend converts that decision into tool result semantics and continues

This is why frontend tool execution still participates in:

  • tool-call status tracking
  • run suspension
  • persistence
  • replay

How Permission Approval Fits

Permission approval is also not a separate subsystem. It is a policy plugin layered on the same suspended-call model.

The permission plugin decides among:

  • allow
  • deny
  • ask

In code these map to ToolPermissionBehavior variants: Allow (no action emitted), DenyBeforeToolExecuteAction::Block(reason), AskBeforeToolExecuteAction::Suspend(ticket).

ask becomes:

  • BeforeToolExecuteAction::Suspend
  • pending call persisted in runtime state
  • external approval needed
  • replay on decision

So permission approval and frontend tool execution differ in policy and UI, but not in their core state-machine mechanics.

Why Replay Matters

The external client never mutates the tool call in place.

Instead, the client submits a decision payload, and the runtime:

  1. records the decision
  2. resolves the matching suspended call
  3. marks the call Resuming
  4. replays the original tool call or converts the decision into a tool result, depending on resume mode

That gives you:

  • deterministic behavior
  • auditability
  • testability
  • transport independence

Without replay, approval and frontend tool flows become opaque side channels.

Manual Integration Points

The framework intentionally abstracts manual integration points into a small number of contracts.

What the application or frontend still has to do:

  • render pending interactions
  • collect human decisions or frontend tool outputs
  • forward decisions back over the chosen transport

What the runtime already standardizes:

  • suspension semantics
  • state persistence
  • per-call status
  • run waiting/resume transitions
  • replay behavior

This separation is the main design reason frontend integrations stay relatively thin even when the UX is rich.

Which Layer Owns What

Use this split:

  • tool: domain work, typed state mutation, result generation, post-tool effects
  • plugin: cross-cutting gates and orchestration, such as permission checks and tool filtering
  • transport adapter: maps protocol payloads/events to runtime contracts
  • frontend: renders state, pending interactions, and decision UIs

If a rule must apply uniformly across many tools, it belongs in a plugin. If a specific capability needs user input as part of its domain flow, it may still be modeled as a suspended tool call.

HITL and Decision Flow

Human-in-the-loop in Tirea is not a separate transport feature. It is part of the core run model: tools can suspend, runs can wait, and external decisions can be forwarded back into the active run for replay.

The Three States That Matter

  1. A tool call suspends.
  2. The run transitions to Waiting if all active tool calls are suspended.
  3. An external decision resumes or cancels the suspended call, and the loop replays that tool call deterministically.

This is why HITL behavior spans runtime, protocol, and storage layers instead of living only in frontend adapters.

Two Inbound Paths

There are two distinct ways to continue work after a suspension:

  • Decision forwarding: an active suspended run receives ToolCallDecision payloads and continues in place
  • Continuation run: a new run starts with additional user messages, optionally carrying decisions with it

These paths are intentionally different:

  • decision forwarding preserves the existing run id and resumes the suspended call;
  • continuation starts a new run lineage and is the right choice when the user is adding new intent, not only resolving a pending approval.

Decision Forwarding

Decision forwarding is the canonical HITL path.

Flow:

  1. A tool returns Pending(...) or a behavior suspends it before execution.
  2. Suspended call payload is stored in runtime state.
  3. Client posts a ToolCallDecision to the active run.
  4. The loop resolves the decision, marks the call Resuming, replays it, and commits the resulting effects.

The important property is replay: the original tool call is not mutated in place by the client. The client only submits the decision payload; the runtime owns the actual resumed execution.

Continuation Runs

Continuation is a different mechanism:

  • a new user message is appended;
  • a new run id is created;
  • parent_run_id links the new run back to the suspended or completed parent.

Use continuation when the user is changing the conversation, not merely approving or denying a specific tool call.

Tool Execution Mode Changes the UX

tool_execution_mode changes how suspension feels from the outside:

ModePractical effect on HITL
sequentialSimplest mental model; one call is active at a time
parallel_batch_approvalMultiple calls may suspend in a round, then resume behavior is applied after batch commit
parallel_streamingStream mode can emit activity updates and apply decisions while tools are still in flight

If your UI needs live progress, rich activity cards, or low-latency approval loops, parallel_streaming is the intended mode.

Durable State and Lineage

HITL depends on persisted runtime state:

  • __run tracks run lifecycle
  • __tool_call_scope.<call_id>.tool_call_state tracks per-call status
  • __tool_call_scope.<call_id>.suspended_call stores suspended call payloads

Lineage fields keep the larger execution graph understandable:

  • run_id
  • thread_id
  • parent_run_id
  • parent_thread_id

This matters most once you combine approvals, persisted threads, and sub-agent delegation.

Transport Mapping

The same model appears through multiple transports:

  • Run API: POST /v1/runs/:id/inputs forwards decisions to the active run
  • AG-UI: protocol adapters forward decisions onto the same runtime channel
  • A2A: decision-only requests map to the same ToolCallDecision contract

The transport changes payload shape and encoding, but the runtime semantics stay the same.

When to Reach for Which Doc

This page is the conceptual bridge between those pieces.

Multi-Agent Design Patterns

Natural-language orchestration

Tirea uses natural-language orchestration, inspired by Claude Code’s sub-agent model. The LLM decides when to delegate, to whom, and how to combine results. You define each agent’s identity and access policy; the runtime handles everything else. There are no DAGs, no state machines, and no explicit routing code — unlike frameworks such as LangGraph or Google ADK where you wire agents into graphs and define transitions in code.

This works because the runtime provides:

  • Agent registry — agents registered at build time are rendered into the system prompt, so the LLM always knows who it can delegate to
  • Background execution with completion notifications — sub-agents run in the background; the runtime injects their status after each tool call, keeping the LLM aware of what’s running, finished, or failed
  • Foreground and background modes — block until a sub-agent finishes, or run multiple concurrently and receive completion notifications
  • Thread isolation — each sub-agent runs in its own thread with independent state
  • Orphan recovery — orphaned sub-agents are detected and resumed on restart

Patterns

All patterns below are implemented with the same building blocks:

  • agent_run / agent_stop / agent_output delegation tools
  • AgentDefinition with allowed_agents / excluded_agents
  • SuspendTicket for human-in-the-loop gating
  • System prompt engineering for control flow

No dedicated workflow agent types are needed. The LLM-driven loop plus delegation tools cover these patterns through prompt configuration.

Pattern Index

PatternTirea MechanismDeterministic Flow?
Coordinatoragent_run + prompt routingNo (LLM decides)
Sequential PipelineChained agent_run callsNo (LLM relays)
Parallel Fan-Out/GatherParallel agent_run + agent_outputNo (best-effort)
Hierarchical DecompositionNested agent_runNo
Generator-CriticGenerator as main agent, critic as child via agent_runNo
Iterative RefinementSame as Generator-Critic with additional refiner childNo
Human-in-the-LoopSuspendTicket + PermissionPluginYes (runtime gating)
Swarm / Peer HandoffTODO — not yet supported

Coordinator

A single orchestrator agent analyzes user intent and routes to specialized worker agents.

User -> [Orchestrator] -> intent analysis
                       -> agent_run("billing")   if billing question
                       -> agent_run("support")   if technical issue
                       -> agent_run("sales")     if pricing question

Setup

let os = AgentOs::builder()
    .with_agent_spec(AgentDefinitionSpec::local_with_id("billing", AgentDefinition::new("deepseek-chat")
        .with_system_prompt("You are a billing specialist.")
        .with_excluded_tools(vec!["agent_run".to_string(), "agent_stop".to_string()])))
    .with_agent_spec(AgentDefinitionSpec::local_with_id("support", AgentDefinition::new("deepseek-chat")
        .with_system_prompt("You are a technical support specialist.")
        .with_excluded_tools(vec!["agent_run".to_string(), "agent_stop".to_string()])))
    .with_agent_spec(AgentDefinitionSpec::local_with_id("orchestrator", AgentDefinition::new("deepseek-chat")
        .with_system_prompt("Route user requests to the appropriate specialist:
- billing: payment, invoice, subscription issues
- support: technical problems, bugs, errors
Use agent_run to delegate. Use agent_output to read results.")
        .with_allowed_agents(vec!["billing".to_string(), "support".to_string()])))
    .build()?;

Key decisions

  • Worker agents should exclude delegation tools to prevent recursive delegation.
  • The orchestrator prompt should list available agents and their responsibilities.
  • Use foreground mode (background=false) when the user expects a direct answer.

Sequential Pipeline

Multiple agents execute in order, each transforming the output of the previous stage.

[Parser Agent] -> raw text
    -> [Extractor Agent] -> structured data
        -> [Summarizer Agent] -> final report

Setup

The orchestrator prompt drives the sequence:

.with_agent_spec(AgentDefinitionSpec::local_with_id("orchestrator", AgentDefinition::new("deepseek-chat")
    .with_system_prompt("Process the document through three stages in order:
1. Call agent_run(\"parser\") with the raw input. Read output with agent_output.
2. Call agent_run(\"extractor\") with the parsed text. Read output with agent_output.
3. Call agent_run(\"summarizer\") with the extracted data. Read output with agent_output.
Return the final summary to the user.")
    .with_allowed_agents(vec!["parser".to_string(), "extractor".to_string(), "summarizer".to_string()])))

Limitations

The orchestrator acts as a deterministic relay: call stage N, read output, pass to stage N+1. This relay logic does not require LLM reasoning, yet each relay step consumes an LLM turn. For long pipelines the token and latency overhead adds up. A future runtime-level sequential pipeline primitive could eliminate the relay agent entirely.

When to avoid

If one agent with sequential tool calls can handle the full pipeline, do not split into multiple agents. Multi-agent adds latency and token cost. Split only when stages need different system prompts, tool sets, or model capabilities.

Parallel Fan-Out/Gather

Independent tasks run concurrently, then a synthesizer combines results.

                  ┌-> [Security Auditor]   -> security_report
[Orchestrator] ---┼-> [Style Checker]      -> style_report
                  └-> [Performance Analyst] -> perf_report
                           |
                     [Synthesizer] -> unified review

Setup

The orchestrator prompt instructs the LLM to issue multiple agent_run tool calls in a single step. When the agent’s tool execution mode supports parallel execution, the runtime runs them concurrently and returns all results before the next LLM turn.

.with_agent_spec(AgentDefinitionSpec::local_with_id("orchestrator", AgentDefinition::new("deepseek-chat")
    .with_system_prompt("Review the submitted code:
1. Launch all three reviewers in parallel by calling agent_run for each in the same response:
   - agent_run(\"security_auditor\")
   - agent_run(\"style_checker\")
   - agent_run(\"perf_analyst\")
2. Read the results from each tool call response.
3. Call agent_run(\"synthesizer\") with all three reports.
4. Return the unified review.
IMPORTANT: call all three agent_run tools at the same time, not one after another.")
    .with_allowed_agents(vec![
        "security_auditor".to_string(), "style_checker".to_string(), "perf_analyst".to_string(), "synthesizer".to_string()
    ])
    .with_tool_execution_mode(ToolExecutionMode::ParallelBatchApproval)))

The key is prompting the LLM to emit multiple tool calls in one response. Most capable models respect this instruction and produce parallel agent_run calls that the runtime executes concurrently.

Limitations

Parallelism is best-effort with no runtime guarantee:

  • The approach depends on the LLM choosing to emit multiple tool calls in a single response. If the model serializes them across turns, execution falls back to sequential.
  • The runtime cannot force the LLM to produce parallel calls — it can only execute them concurrently when the LLM does.
  • A future runtime-level fan-out primitive could guarantee parallelism independent of LLM behavior.

Key decisions

  • Set tool_execution_mode to ParallelBatchApproval or ParallelStreaming to enable concurrent tool execution.
  • Prompt must explicitly instruct the LLM to call all agents at the same time.
  • If reviewers depend on each other, use Sequential Pipeline instead.
  • Keep each reviewer’s tool set and prompt isolated so they can run independently.

Hierarchical Decomposition

A parent agent breaks complex tasks into subtasks and delegates recursively.

[Report Writer]
  |-> agent_run("researcher")
  |     |-> agent_run("web_search")
  |     |-> agent_run("summarizer")
  |-> agent_run("formatter")

Setup

Middle-layer agents also have delegation tools:

.with_agent_spec(AgentDefinitionSpec::local_with_id("researcher", AgentDefinition::new("deepseek-chat")
    .with_system_prompt("Research the given topic. Use web_search for facts, summarizer for condensing.")
    .with_allowed_agents(vec!["web_search".to_string(), "summarizer".to_string()])))
.with_agent_spec(AgentDefinitionSpec::local_with_id("report_writer", AgentDefinition::new("deepseek-chat")
    .with_system_prompt("Write a report. Delegate research to the researcher agent, formatting to the formatter agent.")
    .with_allowed_agents(vec!["researcher".to_string(), "formatter".to_string()])))

Key decisions

  • Each layer only sees its allowed children, not the full agent tree.
  • Leaf agents should exclude delegation tools entirely.
  • Depth is unlimited but each level adds latency.

Generator-Critic

The generator is the main agent. The critic is a child agent called via agent_run. The generator writes output, invokes the critic for review, and revises based on feedback — all within its own loop with full message history.

[Generator] -> write draft
            -> agent_run("critic") with draft -> PASS or FAIL + feedback
            -> if FAIL: revise draft (history preserved)
            -> agent_run("critic") again
            -> repeat until PASS

This is better than an orchestrator-mediated approach because the generator retains its full conversation history across revision cycles. It sees all previous drafts and critic feedback naturally, without a middle agent relaying messages.

Setup

.with_agent_spec(AgentDefinitionSpec::local_with_id("critic", AgentDefinition::new("deepseek-chat")
    .with_system_prompt("Validate the SQL query. Output exactly PASS if correct. \
        Otherwise output FAIL followed by specific errors.")
    .with_excluded_tools(vec!["agent_run".to_string(), "agent_stop".to_string()])))
.with_agent_spec(AgentDefinitionSpec::local_with_id("generator", AgentDefinition::new("deepseek-chat")
    .with_system_prompt("You are a SQL query writer with a built-in review process:
1. Write a SQL query for the user's requirement.
2. Call agent_run for agent_id=critic with your query, background=false.
3. If critic returns FAIL, revise based on the feedback and call critic again.
4. Repeat until critic returns PASS or 5 attempts are reached.
5. Return the final validated SQL.
Always call the critic before finishing.")
    .with_allowed_agents(vec!["critic".to_string()])
    .with_max_rounds(20)))

Key decisions

  • The generator is the entry point (agent_id in RunRequest), not a separate orchestrator.
  • The critic should exclude delegation tools to prevent recursion.
  • Set max_rounds high enough to allow multiple generate-critique cycles.
  • The critic must produce a clear pass/fail signal the generator can parse.
  • Works best when validation criteria are objective (syntax, schema, format).

Iterative Refinement

Extends Generator-Critic with a dedicated refiner child agent. The generator writes the initial draft, the critic reviews, and the refiner applies improvements based on feedback.

[Generator] -> draft
            -> agent_run("critic") -> feedback
            -> agent_run("refiner") with draft + feedback -> improved draft
            -> agent_run("critic") again -> feedback or PASS
            -> repeat (max N iterations)

The setup follows the same pattern as Generator-Critic: the generator is the main agent with allowed_agents: ["critic", "refiner"]. Both critic and refiner are leaf agents without delegation tools.

Human-in-the-Loop

High-risk operations pause for human approval before execution.

[Agent] -> prepare transfer of $50,000
        -> tool suspends with SuspendTicket
        -> run transitions to Waiting
        -> human approves/denies
        -> run resumes or cancels

This pattern uses Tirea’s built-in suspension model, not prompt engineering.

Setup

See Enable Tool Permission HITL for the full configuration. The key components:

  • PermissionPlugin intercepts tool calls and emits Suspend actions
  • ToolCallDecision channel carries human decisions back into the loop
  • Two inbound paths: decision forwarding (same run) and continuation (new run)

Key decisions

  • HITL is orthogonal to other patterns. Combine it with any pattern above.
  • Use parallel_streaming tool execution mode for low-latency approval UX.
  • See HITL and Decision Flow for the full runtime model.

Swarm / Peer Handoff

TODO — This pattern is not yet supported. The section below describes the target behavior and current gap.

Agents operate as peers without a central coordinator. Each agent decides when to hand off to another. The handoff transfers full control: Agent A exits, Agent B takes over the same thread with shared message history.

[Alice] <-> [Bob] <-> [Charlie]
  (math)    (code)    (writing)

Unlike Coordinator, there is no parent-child hierarchy. The active agent switches identity, not spawns a child.

Why delegation does not work for handoff

Tirea’s current agent_run model is hierarchical (parent spawns child). Using it for handoff has fundamental issues:

  • Nesting depth grows per hop. A→B→C means C runs as a grandchild. Each hop adds a level of indirection and resource consumption.
  • No shared message history. Each child agent gets its own thread. The handoff target cannot see the prior conversation.
  • Parent does not exit. The parent agent remains alive waiting for the child to finish, consuming context window.

A true peer handoff requires a different mechanism: the runtime switches the active agent’s identity (system prompt, tools, model) on the same thread within the same run. This is not yet implemented.

When to Use Multi-Agent

Split into multiple agents when:

  • Stages need different system prompts, tools, or models
  • A single context window cannot hold all required information
  • Parallel execution provides meaningful speedup
  • An independent critic role improves output quality over self-critique
  • Isolation is needed (each agent sees only its allowed tools and data)

Stay with a single agent when:

  • One agent with a few tools handles the full task
  • The overhead of delegation (extra LLM calls, latency) outweighs the benefit
  • The task is simple enough that prompt instructions suffice

See Also

Sub-Agent Delegation

Sub-agent delegation is a built-in orchestration layer where one run can start/cancel/resume other agent runs.

Runtime Model

Delegation is implemented through three agent-specific tools:

  • agent_run: start or resume a child run
  • agent_stop: cancel a running child run (descendants are cancelled automatically)
  • agent_output: read child run output

System behaviors (agent_tools, agent_recovery) are wired during resolve and inject usage guidance/reminders.

Ownership and Threads

  • Parent run keeps ownership in its caller thread.
  • Each child run executes on its own child thread (sub-agent-<run_id> pattern).
  • Child run records carry lineage (parent_run_id, parent_thread_id).

This keeps parent and child state/history isolated while preserving ancestry.

State and Handle Layers

Delegation state is tracked in two layers:

  1. In-memory handle table (SubAgentHandleTable)

    • live SubAgentHandle per run_id, keyed by run_id
    • owner thread check (owner_thread_id)
    • epoch-based stale completion guard
    • cancellation token per handle
  2. Persisted state in the owner thread:

    • SubAgentState at path sub_agents (scope: Thread)
    • runs: HashMap<String, SubAgent> — lightweight metadata per run_id
    • each SubAgent carries: agent id, execution ref (local thread_id or remote A2A ref), status, optional error

The in-memory table (SubAgentHandleTable) drives active control flow; SubAgentState persists metadata for recovery, output access, and cross-run lineage.

Foreground vs Background

agent_run(background=false):

  • parent waits for child completion
  • child progress can be forwarded to parent tool-call progress

agent_run(background=true):

  • child continues asynchronously
  • parent gets immediate summary and may later call agent_run (resume/check), agent_stop, or agent_output

Policy and Visibility

Target-agent visibility is filtered by scope policy:

  • RunPolicy.allowed_agents
  • RunPolicy.excluded_agents

AgentDefinition::allowed_agents/excluded_agents are projected into RunPolicy fields when absent (via set_allowed_agents_if_absent).

Recovery Behavior

When stale running state is detected (for example after interruption), recovery behavior can transition records and enforce explicit resume/stop decisions before replay.

Design Tradeoff

Delegation favors explicit tool-mediated orchestration over implicit nested runtime calls, so control flow remains observable, stoppable, and policy-filterable at each boundary.

Persistence and Versioning

Thread persistence uses append-style changesets and optimistic concurrency.

Model

  • Persisted object: Thread
  • Incremental write unit: ThreadChangeSet
  • Concurrency guard: VersionPrecondition::Exact(version)

Write Path

  1. Load thread + current version.
  2. Build/apply run delta (messages, patches, optional state snapshot).
  3. Append with exact expected version.
  4. Store returns committed next version.

Checkpoint Mechanism

The runtime persists state through incremental checkpoints.

  • Delta source: RunContext::take_delta() — returns RunDelta { messages, patches, state_actions }
  • Persisted payload: ThreadChangeSet { run_id, parent_run_id, run_meta, reason, messages, patches, state_actions, snapshot } — assembled by StateCommitter from the RunDelta
  • Concurrency: append with VersionPrecondition::Exact(version)
  • Version update: committed version is written back to RunContext

snapshot is only used when replacing base state (for example, frontend-provided state replacement on inbound run preparation). Regular loop checkpoints are append-only (messages + patches).

Checkpoint Timing

A) Inbound checkpoint (AgentOs prepare)

Before loop execution starts:

  • Trigger: incoming user messages and/or inbound state replacement exist
  • Reason: UserMessage
  • Content:
    • deduplicated inbound messages
    • optional full snapshot when request state replaces thread state

B) Runtime checkpoints (loop execution path)

During run_loop / run_loop_stream execution:

  1. After RunStart phase side effects are applied:
    • Reason: UserMessage
    • Purpose: persist immediate inbound side effects before any replay
  2. If RunStart outbox replay executes:
    • Reason: ToolResultsCommitted
    • Purpose: persist replayed tool outputs/patches
  3. After assistant turn is finalized (AfterInference + assistant message + StepEnd):
    • Reason: AssistantTurnCommitted
  4. After tool results are applied (including suspension state updates):
    • Reason: ToolResultsCommitted
  5. On termination:
    • Reason: RunFinished
    • Forced commit (even if no new delta) to mark end-of-run boundary

Failure Semantics

  • Non-final checkpoint failure is treated as run failure:
    • emits state error
    • run terminates with error
  • Final RunFinished checkpoint failure:
    • emits error
    • terminal run-finish event may be suppressed, because final durability was not confirmed

AgentOs::run_stream uses run_loop_stream, so production persistence follows the same checkpoint schedule shown above.

State Scope Lifecycle

Each StateSpec declares a StateScope that controls its cleanup lifecycle:

ScopeLifetimeCleanup
ThreadPersists across runsNever cleaned automatically
RunPer-runDeleted by prepare_run before each new run
ToolCallPer-callScoped under __tool_call_scope.<call_id>, cleaned after call completes

Run-scoped cleanup

At run preparation (prepare_run), the framework:

  1. Queries StateScopeRegistry::run_scoped_paths() for all Run-scoped state paths
  2. Emits Op::delete patches for any paths present in the current thread state
  3. Applies deletions to in-memory state before the lifecycle Running patch

This guarantees Run-scoped state (e.g., __run, __kernel.stop_policy_runtime) starts from defaults on every new run, preventing cross-run leakage.

Choosing a scope when authoring state

State shapeRecommended scopeWhy
User-visible business state (threads, notes, trips, reports)ThreadMust survive across runs and reloads
Execution bookkeeping (__run, stop-policy counters, per-run temp state)RunUseful only while one run is active and must not leak into the next run
Pending approval / per-call scratch stateToolCallBound to a single tool invocation and cleaned when that call resolves

In practice:

  • prefer Thread for state a user would expect to see after a page reload;
  • prefer Run for coordination state owned by plugins or the runtime;
  • prefer ToolCall when the data only makes sense while a specific suspended call exists.

Why It Matters

  • Prevents silent lost updates under concurrent writers.
  • Keeps full history for replay and audits.
  • Enables different storage backends with consistent semantics.

Tool and Plugin Boundary

Tools and plugins solve different problems. Keep the boundary strict.

In code, “plugin” refers to a type that implements the AgentBehavior trait.

Tool Responsibility

Tools implement domain actions:

  • Read state through ToolCallContext (snapshots or references)
  • Call external systems
  • Return structured ToolResult
  • Emit state mutations and other Actions through ToolExecutionEffect

Tools should not orchestrate loop policy globally.

Action Reducer Model

The runtime is not “tool returns JSON and stops there”. Tools and plugins both participate in an action/reducer pipeline.

  • A plain tool may return only ToolResult
  • A richer tool may return ToolExecutionEffect
  • ToolExecutionEffect contains:
    • a ToolResult
    • zero or more Actions applied during AfterToolExecute
  • State changes are represented as AnyStateAction and reduced by the runtime into the execution patch
  • Non-state actions can mutate step-local runtime structures such as queued user messages

Conceptually:

Tool / Plugin -> Action(s) -> Phase validation -> StepContext apply -> Reducer / patch commit

This is why “tool execution” in Tirea can do more than return a payload. It can also update persisted state, inject messages, or alter later runtime behavior through actions.

What Tools Can Change

From a tool, you can:

  • read typed state through ToolCallContext (via snapshot_of, snapshot_at, or live references)
  • return a ToolResult
  • emit state mutations and other Actions from execute_effect (via ToolExecutionEffect + AnyStateAction)

Direct state writes through ctx.state::<T>().set_*() are rejected at runtime. All state mutations must go through the action pipeline.

Typical tool-emitted effects include:

  • AnyStateAction to update reducer-backed state
  • user-message insertion actions
  • other custom Action implementations valid in AfterToolExecute

What Plugins Can Change

Plugins implement cross-cutting policy:

  • Inject context (StepStart, BeforeInference)
  • Filter/allow/deny tools (BeforeInference, BeforeToolExecute)
  • Add reminders or execution metadata (AfterToolExecute)

Plugins operate at phase boundaries and are the right place for rules that must apply uniformly across many tools.

Concrete Examples

Skill activation

The skill activation tool is a good example of a tool using ToolExecutionEffect instead of returning only ToolResult.

It does three things in one execution:

  1. Returns a success ToolResult
  2. Emits a state action to activate the skill in persisted state
  3. Emits additional actions to:
    • append the skill instructions into user-visible message flow
    • widen allowed tool permissions for the activated skill

This is implemented in crates/tirea-extension-skills/src/tools.rs.

Permission policy

Permission handling is a plugin concern because it is global execution policy, not domain work.

The permission plugin:

  • checks state snapshot before tool execution
  • blocks, allows, or suspends the call
  • can emit BeforeInferenceAction to include/exclude tools
  • can emit BeforeToolExecuteAction to deny or suspend execution

This is implemented in crates/tirea-extension-permission/src/plugin.rs.

Plugin Responsibility

Plugins should not own domain-side business operations.

Rule of Thumb

  • If it is business capability, build a tool.
  • If it is execution policy or guardrail, build a plugin.
  • If it is a domain tool that needs to return both a result and side effects, use execute_effect.

Design Tradeoffs

Immutable Patch History vs In-Place Mutation

  • Chosen: immutable patch history.
  • Benefit: replayability, auditability, deterministic reasoning.
  • Cost: larger history and snapshot management requirements.

Unified Event Stream vs Protocol-Specific Runtimes

  • Chosen: one internal AgentEvent stream plus protocol encoders.
  • Benefit: one runtime behavior, many transports.
  • Cost: protocol adapters must map event details carefully.

AgentOs Orchestration Layer

  • Chosen: explicit resolve/prepare/execute split.
  • Benefit: testable and composable pre-run wiring.
  • Cost: more concepts compared with direct loop calls.

Glossary / 术语表

Term中文Description
Thread会话线程Persisted conversation + state history.
Run运行One execution attempt over a thread.
RunContext运行上下文Loop-internal workspace that owns the live DocCell, message log, and patch accumulator. Plugins receive ReadOnlyContext instead.
Patch补丁Ordered list of state Op operations.
TrackedPatch追踪补丁Patch plus metadata for traceability.
ThreadChangeSet线程变更集Append payload persisted to storage.
AgentOs智能体操作系统Orchestration layer for registries and run prep.
AgentEvent智能体事件Canonical runtime stream event.
RunPolicy运行策略Strongly-typed per-run scope and execution policy carrying allow/exclude lists for tools, skills, and agents.
AgentBehavior智能体行为The plugin trait (formerly AgentPlugin); implementations register CRDT paths/state scopes and return typed ActionSets from phase hooks.
ActionSet动作集Typed collection of phase actions returned by AgentBehavior hooks; composed with ActionSet::and.
ReadOnlyContext只读上下文Immutable snapshot of step context passed to AgentBehavior phase hooks; the plugin-facing API surface.
RunDelta运行增量Incremental output from a run step — new messages, TrackedPatches, and serialized state actions since last take_delta().
RunStream运行流Result of AgentOs::run_stream; carries resolved thread/run IDs, a decision sender for mid-run HITL, and the AgentEvent stream.
StateSpec状态规约Extension of State that adds a typed Action associated type, a SCOPE constant (Thread/Run/ToolCall), and a pure reduce method.
ToolCallContext工具调用上下文Execution context passed to tool invocations; provides typed state read/write, run policy, identity, and message queuing.
AgentDefinition智能体定义Orchestration-facing agent composition definition; holds model, system prompt, behavior/stop-condition IDs, and declarative specs.
RunRequest运行请求Unified runtime input for all external protocols; carries agent ID, thread/run IDs, messages, and initial decisions.
ToolExecutionEffect工具执行效果Rich tool return type wrapping a ToolResult plus a list of typed Actions applied during AfterToolExecute.
SuspendTicket挂起票据Suspension payload carrying external suspension data, a pending projection emitted to the event stream, and a resume_mode strategy.

FAQ

Why no mutable session object?

Thread + patch history gives deterministic replay and clearer persistence semantics.

Should I call run_loop_stream_with_context directly?

Prefer AgentOs::run_stream for production. It handles load/create, dedup, and persistence wiring.

For cases where request preprocessing must be separated from stream execution (e.g., testing or custom persistence control), use the prepare_run + execute_prepared pattern instead.

Is rustdoc enough as all reference docs?

No. Rust API reference is necessary but protocol, transport, and operations reference must still be documented in mdBook.

Migration Notes

0.2 → 0.3

Behavior Trait Rename and Signature Overhaul

AgentPlugin is renamed to AgentBehavior. Phase hooks now receive &ReadOnlyContext<'_> (immutable) instead of &mut *Context (mutable), and return ActionSet<PhaseAction> instead of mutating context directly.

// Before (0.2)
use tirea::prelude::*; // imports AgentPlugin

#[async_trait]
impl AgentPlugin for MyPlugin {
    fn id(&self) -> &str { "my_plugin" }
    async fn before_inference(&self, ctx: &mut BeforeInferenceContext<'_, '_>) {
        ctx.add_system_context("Time: now".into());
        ctx.exclude_tool("dangerous_tool");
    }
}

// After (0.3)
use tirea::prelude::*; // imports AgentBehavior, ActionSet, BeforeInferenceAction

#[async_trait]
impl AgentBehavior for MyPlugin {
    fn id(&self) -> &str { "my_plugin" }
    async fn before_inference(&self, _ctx: &ReadOnlyContext<'_>) -> ActionSet<BeforeInferenceAction> {
        ActionSet::single(BeforeInferenceAction::AddSystemContext("Time: now".into()))
            .and(BeforeInferenceAction::ExcludeTool("dangerous_tool".into()))
    }
}

Registry types follow the rename:

0.20.3
AgentPluginAgentBehavior
PluginRegistryBehaviorRegistry
InMemoryPluginRegistryInMemoryBehaviorRegistry
builder.with_registered_plugin(...)builder.with_registered_behavior(...)
AgentDefinition.plugin_idsAgentDefinition.behavior_ids

New AgentBehavior methods (all have default implementations):

  • behavior_ids() — returns list of behavior IDs (default: vec![self.id()])
  • register_lattice_paths(registry) — register CRDT merge paths
  • register_state_scopes(registry) — register state scope metadata
  • register_state_action_deserializers(registry) — register action deserialization

RunConfig → RunPolicy

The JSON-bag RunConfig (alias for SealedState) is replaced by a strongly-typed RunPolicy.

// Before (0.2)
pub type RunConfig = tirea_state::SealedState;
// Access: run_config.value("allowed_tools")

// After (0.3)
pub struct RunPolicy {
    allowed_tools: Option<Vec<String>>,
    excluded_tools: Option<Vec<String>>,
    allowed_skills: Option<Vec<String>>,
    excluded_skills: Option<Vec<String>>,
    allowed_agents: Option<Vec<String>>,
    excluded_agents: Option<Vec<String>>,
}
// Access: run_policy.allowed_tools()
0.20.3
StepContext::run_config()ReadOnlyContext::run_policy()
ToolExecutionRequest field run_configfield run_policy
RunContext::new(..., run_config)RunContext::new(..., run_policy)

Module Restructuring

The orchestrator module in tirea-agentos is split into composition and runtime.

// Before (0.2)
use tirea_agentos::orchestrator::{AgentOsBuilder, AgentDefinition, AgentOs};
use tirea::orchestrator::{AgentOsBuilder, AgentDefinition};

// After (0.3)
use tirea_agentos::composition::{AgentOsBuilder, AgentDefinition};
use tirea_agentos::runtime::{AgentOs, RunStream};
// or via umbrella:
use tirea::composition::{AgentOsBuilder, AgentDefinition};
use tirea::runtime::{AgentOs, RunStream};

AgentOs, AgentOsBuilder, and AgentDefinition are also re-exported at the crate root (tirea_agentos::AgentOs, etc.).

Phase Context Module Move

// Before (0.2)
use tirea_contract::runtime::plugin::phase::{Phase, StepContext, ...};
use tirea_contract::runtime::plugin::AgentPlugin;

// After (0.3)
use tirea_contract::runtime::phase::{Phase, ActionSet, BeforeInferenceAction, ...};
use tirea_contract::runtime::behavior::AgentBehavior;

Action Trait and ToolExecutionEffect

Tools can now return typed state actions via execute_effect():

// After (0.3)
async fn execute_effect(&self, args: Value, ctx: &ToolCallContext<'_>)
    -> Result<ToolExecutionEffect, ToolError>
{
    let effect = ToolExecutionEffect::new(ToolResult::success(json!("done")))
        .with_action(AnyStateAction::new::<MyState>(MyAction::Increment));
    Ok(effect)
}

The default execute_effect() delegates to execute(), so existing tools work unchanged.

StateSpec and State Scopes

New StateSpec trait for typed state with reducer pattern:

#[derive(State)]
#[tirea(path = "__my_state", action = "MyAction", scope = "run")]
pub struct MyState { pub count: i64 }

impl MyState {
    fn reduce(&mut self, action: MyAction) {
        match action {
            MyAction::Increment => self.count += 1,
        }
    }
}

StateScope controls cleanup lifecycle:

ScopeLifetime
Thread (default)Persists across runs
RunDeleted at start of each new run
ToolCallScoped to __tool_call_scope.<call_id>, cleaned after call completes

CRDT / Lattice Support

New lattice system for conflict-free replicated state:

  • #[derive(Lattice)] proc-macro
  • #[tirea(lattice)] field attribute on #[derive(State)] structs
  • Op::LatticeMerge operation variant
  • LatticeRegistry for merge dispatch
  • Primitives: Flag, MaxReg, MinReg, GCounter, GSet, ORSet, ORMap

New State Trait Methods

State gains three new methods (all have default implementations):

fn register_lattice(_registry: &mut LatticeRegistry) {}
fn lattice_keys() -> &'static [&'static str] { &[] }
fn diff_ops(old: &Self, new: &Self, base_path: &Path) -> TireaResult<Vec<Op>> { ... }

Existing #[derive(State)] types remain compatible.

Sub-Agent System Redesign

Delegation* types are renamed to SubAgent*. Child threads are now stored independently in ThreadStore rather than embedded in parent state.

0.20.3
DelegationStatusSubAgentStatus
DelegationRecordSubAgent (lightweight metadata)
DelegationStateSubAgentState
AgentRunManagerSubAgentHandleTable
State path agent_runsState path sub_agents
DelegationRecord.thread (embedded)Independent ThreadStore entry at sub-agent-{run_id}

Plugin Extension Context Changes

Permission and reminder context extension traits are replaced by typed actions:

// Before (0.2)
ctx.allow_tool("tool_name");       // PermissionContextExt
ctx.add_reminder("text");          // ReminderContextExt

// After (0.3) — use typed actions via ToolExecutionEffect
ToolExecutionEffect::new(result)
    .with_action(permission_state_action(PermissionAction::SetTool { ... }))
    .with_action(add_reminder_action("text"))
0.20.3
PermissionContextExtPermissionAction + permission_state_action()
ReminderContextExtReminderAction + add_reminder_action() / clear_reminder_action()
SkillPluginSkillDiscoveryPlugin

Type Renames

0.20.3
RunStateRunLifecycleState (with #[tirea(scope = "run")])
ToolCallStatesMapRemoved — ToolCallState is now per-call scoped
SuspendedToolCallsStateSuspendedCallState (per-call scoped)
InferenceErrorStateRemoved — errors carried in LLMResponse
TerminationReason::PluginRequestedTerminationReason::BehaviorRequested

StreamResult Gains StopReason

pub struct StreamResult {
    pub text: String,
    pub tool_calls: Vec<ToolCall>,
    pub usage: Option<TokenUsage>,
    pub stop_reason: Option<StopReason>,  // new
}

pub enum StopReason { EndTurn, MaxTokens, ToolUse, StopSequence }

ToolResult.suspension Boxing

// Before (0.2)
pub suspension: Option<SuspendTicket>,

// After (0.3)
pub suspension: Option<Box<SuspendTicket>>,

state_paths Module Removed

// Before (0.2)
use tirea_contract::runtime::state_paths::{RUN_LIFECYCLE_STATE_PATH, ...};

// After (0.3) — use PATH constants from state types directly
RunLifecycleState::PATH  // "__run"

Context Window Management

New plugin-based context window management (optional):

use tirea_agentos::runtime::ContextPlugin;

let plugin = ContextPlugin::for_model("claude");
builder.with_registered_behavior("context_window", Arc::new(plugin));

declare_plugin_states! Macro

Replaces impl_loop_config_builder_methods!. Generates register_lattice_paths, register_state_scopes, and register_state_action_deserializers implementations for AgentBehavior types.

ThreadReader New Methods

ThreadReader gains run query methods (all have default implementations):

  • load_run(run_id) — load a single run record
  • list_runs(query) — paginated run listing with filters
  • active_run_for_thread(thread_id) — find the active run for a thread

0.1 → 0.2

Module Reorganization

The event and protocol modules have been consolidated into a unified io module.

// Before (0.1)
use tirea_contract::event::AgentEvent;
use tirea_contract::protocol::{ProtocolInputAdapter, ProtocolOutputEncoder, ProtocolHistoryEncoder};

// After (0.2)
use tirea_contract::io::{AgentEvent, RunRequest, RuntimeInput, ToolCallDecision};
use tirea_contract::transport::{Transcoder, Identity};

The three protocol traits (ProtocolInputAdapter, ProtocolOutputEncoder, ProtocolHistoryEncoder) are replaced by a single Transcoder trait. Use Identity<T> as a pass-through when no transformation is needed.

Plugin Interface

The single on_phase(Phase, StepContext) method is replaced by individual per-phase methods, each with a typed context parameter.

// Before (0.1)
#[async_trait]
impl AgentPlugin for MyPlugin {
    fn id(&self) -> &str { "my_plugin" }
    async fn on_phase(&self, phase: Phase, ctx: &mut StepContext<'_, '_>) {
        match phase {
            Phase::BeforeInference => { /* ... */ }
            Phase::AfterInference => { /* ... */ }
            _ => {}
        }
    }
}

// After (0.2)
#[async_trait]
impl AgentPlugin for MyPlugin {
    fn id(&self) -> &str { "my_plugin" }
    async fn before_inference(&self, ctx: &mut BeforeInferenceContext<'_, '_>) {
        ctx.add_system_context("Time: now".into());
    }
    async fn after_inference(&self, ctx: &mut AfterInferenceContext<'_, '_>) {
        // ...
    }
}

Available phase methods: run_start, step_start, before_inference, after_inference, before_tool_execute, after_tool_execute, step_end, run_end. All have empty default implementations.

Other plugin API changes:

0.10.2
ctx.skip_inference()ctx.terminate_plugin_requested()
ctx.ask_frontend_tool(...)Removed — use SuspendTicket via BeforeToolExecuteContext
ctx.ask(...) / ctx.allow(...) / ctx.deny(...)Removed — use block/allow/suspend on BeforeToolExecuteContext
StepContext skip/termination fieldsRemoved — use RunAction / ToolCallAction on typed contexts

Event Stream Changes

Removed variants:

RemovedReplacement
AgentEvent::InteractionRequestedTool-call suspension emitted via ToolCallDone with pending status
AgentEvent::InteractionResolvedAgentEvent::ToolCallResumed { target_id, result }
AgentEvent::PendingRun lifecycle uses TerminationReason::Suspended

New variants:

  • AgentEvent::ReasoningDelta { delta } — streaming reasoning content.
  • AgentEvent::ReasoningEncryptedValue { encrypted_value } — encrypted reasoning block.
  • AgentEvent::ToolCallResumed { target_id, result } — resume decision applied.
  • AgentEvent::InferenceComplete now includes duration_ms: u64.

Suspension Model

The single-slot “pending interaction” model is replaced by per-call tool-call suspension.

// Before (0.1)
let pending = ctx.pending_interaction();
let frontend = ctx.pending_frontend_invocation();

// After (0.2)
let suspended: &HashMap<String, SuspendedCall> = ctx.suspended_calls();

Key type changes:

0.10.2
ToolSuspensionSuspendTicket (deprecated alias provided)
pending_interaction()suspended_calls()
pending_frontend_invocation()Removed
Single pending slotPer-call SuspendedCall map
Resume via outbox replayResume via ToolCallDecision on decision channel

Each SuspendedCall carries: call_id, tool_name, arguments, and a ticket: SuspendTicket field that holds the suspension payload, pending projection, and resume_mode (ReplayToolCall / UseDecisionAsToolResult / PassDecisionToTool).

Type Renames

Deprecated aliases are provided for all renames. Update imports to suppress warnings:

0.10.2
RunLifecycleStatusRunStatus
RunLifecycleActionRunAction (flow control: Continue/Terminate)
ToolCallLifecycleActionToolCallAction (gate: Proceed/Suspend/Block)
ToolCallLifecycleStateToolCallState
ToolSuspensionSuspendTicket

Method Renames

Deprecated forwarding methods are provided. Update call sites to suppress warnings:

0.10.2
StateManager::apply(patch)StateManager::commit(patch)
StateManager::apply_batch(patches)StateManager::commit_batch(patches)

Stop Conditions

Stop conditions moved from core config to StopPolicyPlugin. If you were passing stop conditions to BaseAgent, register them as a plugin instead:

use tirea::composition::{AgentDefinition, StopConditionSpec};

// Stop conditions are now declared on AgentDefinition:
let agent = AgentDefinition::new("deepseek-chat")
    .with_stop_condition_specs(vec![
        StopConditionSpec::MaxRounds { rounds: 10 },
        StopConditionSpec::Timeout { seconds: 300 },
    ]);
// AgentOsBuilder wires the stop_policy behavior automatically

RunRequest Changes

RunRequest now requires initial_decisions and optionally accepts parent_run_id:

// Before (0.1)
let req = RunRequest {
    agent_id: "agent_1".into(),
    thread_id: Some("thread_1".into()),
    run_id: None,
    resource_id: None,
    state: None,
    messages: vec![user_message],
};

// After (0.2)
let req = RunRequest {
    agent_id: "agent_1".into(),
    thread_id: Some("thread_1".into()),
    run_id: None,
    parent_run_id: None,  // new
    parent_thread_id: None,
    resource_id: None,
    origin: RunOrigin::default(),
    state: None,
    messages: vec![user_message],
    initial_decisions: vec![],  // new, required
};

Durable State Paths

New durable state paths persisted in thread state:

PathTypeContent
__runRunLifecycleStateRun id, status, done_reason, updated_at
__tool_call_scope.<call_id>.tool_call_stateToolCallStatePer-call lifecycle status
__tool_call_scope.<call_id>.suspended_callSuspendedCallStateSuspended call payload

Deleted Crates

  • tirea-interaction-plugin — functionality absorbed into core suspension model.

Pre-0.1 → 0.1

Terminology Updates

  • Session -> Thread
  • Storage traits remain ThreadReader / ThreadWriter / ThreadStore
  • Session routes -> /v1/threads routes

Runtime Surface Updates

  • Prefer AgentOs::run_stream(RunRequest) for app-level integration.
  • Use RunContext as run-scoped mutable workspace.
  • Use protocol adapters (AG-UI, AI SDK v6) for transport-specific request/response mapping.

本文档为中文翻译版本,英文原版请参阅 Introduction

简介

Tirea 是一个用 Rust 构建的不可变状态驱动 Agent 框架。它将类型化 JSON 状态管理与 Agent 循环相结合,提供对状态变更的完整可追溯性、回放能力以及组件隔离。

Crate 概览

Crate描述
tirea-state核心库:类型化状态、JSON 补丁、应用、冲突检测
tirea-state-derive用于 #[derive(State)] 的过程宏
tirea-contract共享契约:Thread / 事件 / 工具 / 插件 / 运行时 / 存储 / 协议
tirea-agentosAgent 运行时:推理引擎、工具执行、编排、插件组合
tirea-extension-*插件:权限、提醒、可观测性、技能、MCP、A2UI
tirea-protocol-ag-uiAG-UI 协议适配器
tirea-protocol-ai-sdk-v6Vercel AI SDK v6 协议适配器
tirea-store-adapters存储适配器:memory / file / postgres / nats-buffered
tirea-agentos-serverHTTP / SSE / NATS 网关服务器
tirea重新导出核心模块的伞形 crate

架构

┌─────────────────────────────────────────────────────┐
│  Application Layer                                    │
│  - Register tools, define agents, call run_stream    │
└─────────────────────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────┐
│  AgentOs                                             │
│  - Prepare run, execute phases, emit events          │
└─────────────────────────────────────────────────────┘
                         │
                         ▼
┌─────────────────────────────────────────────────────┐
│  Thread + State Engine                               │
│  - Thread history, RunContext delta, apply_patch     │
└─────────────────────────────────────────────────────┘

核心原则

所有状态转换均遵循确定性纯函数模型:

State' = apply_patch(State, Patch)
  • 相同的 (State, Patch) 始终产生相同的 State'
  • apply_patch 不会修改其输入
  • 完整的历史记录支持回放到任意时间点

本书内容

  • 教程 — 通过构建第一个 Agent 和第一个工具来学习
  • 操作指南 — 面向具体任务的集成与运维实现指南
  • 参考手册 — API、协议、配置及 Schema 查询页面
  • 原理解析 — 架构与设计决策说明

推荐阅读路径

如果您是首次接触本代码库,建议按以下顺序阅读:

  1. 阅读 First Agent,了解最小可运行流程。
  2. 阅读 First Tool,理解状态读写机制。
  3. 在编写生产级工具前,阅读 Typed Tool Reference
  4. Build an AgentAdd a Tool 作为实现检查清单使用。
  5. 需要了解完整执行模型时,回头阅读 ArchitectureRun Lifecycle and Phases

代码库目录结构

从文档转入代码时,以下路径最为重要:

路径用途
crates/tirea-contract/核心运行时契约:工具、事件、状态 / 运行时接口
crates/tirea-agentos/Agent 运行时:推理引擎、工具执行、编排、扩展
crates/tirea-agentos-server/HTTP / SSE / NATS 服务端接入层
crates/tirea-state/不可变状态 Patch / Apply / 冲突引擎
examples/src/工具、Agent 和状态的小型后端示例
examples/ai-sdk-starter/最简浏览器端对端示例
examples/copilotkit-starter/包含审批与持久化的完整端对端 UI 示例
docs/book/src/本文档源文件

完整的 Rust API 文档请参阅 API Reference

第一个 Agent

本文档为中文翻译版本,英文原版请参阅 First Agent

目标

端到端地运行一个 Agent,并确认能收到完整的事件流。

前置条件

[dependencies]
tirea = "0.5.0-alpha.1"
tirea-agentos-server = "0.5.0-alpha.1"
tirea-store-adapters = "0.5.0-alpha.1"
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
futures = "0.3"
serde_json = "1"

运行前,请先设置模型服务商的密钥:

# OpenAI-compatible models (for gpt-4o-mini)
export OPENAI_API_KEY=<your-key>

# Or DeepSeek models
export DEEPSEEK_API_KEY=<your-key>

1. 创建 src/main.rs

use futures::StreamExt;
use serde_json::{json, Value};
use tirea::contracts::{AgentEvent, Message, RunOrigin, RunRequest, ToolCallContext};
use tirea::composition::{tool_map, AgentDefinition, AgentDefinitionSpec, AgentOsBuilder};
use tirea::prelude::*;

struct EchoTool;

#[async_trait]
impl Tool for EchoTool {
    fn descriptor(&self) -> ToolDescriptor {
        ToolDescriptor::new("echo", "Echo", "Echo input")
            .with_parameters(json!({
                "type": "object",
                "properties": { "text": { "type": "string" } },
                "required": ["text"]
            }))
    }

    async fn execute(
        &self,
        args: Value,
        _ctx: &ToolCallContext<'_>,
    ) -> Result<ToolResult, ToolError> {
        let text = args["text"].as_str().unwrap_or_default();
        Ok(ToolResult::success("echo", json!({ "text": text })))
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let os = AgentOsBuilder::new()
    .with_tools(tool_map([EchoTool]))
    .with_agent_spec(AgentDefinitionSpec::local_with_id(
        "assistant",
        AgentDefinition::new("gpt-4o-mini")
            .with_system_prompt("You are a helpful assistant.")
            .with_allowed_tools(vec!["echo".to_string()]),
    ))
    .build()?;

    let run = os
        .run_stream(RunRequest {
            agent_id: "assistant".to_string(),
            thread_id: Some("thread-1".to_string()),
            run_id: None,
            parent_run_id: None,
            parent_thread_id: None,
            resource_id: None,
            origin: RunOrigin::default(),
            state: None,
            messages: vec![Message::user("Say hello using the echo tool")],
            initial_decisions: vec![],
            source_mailbox_entry_id: None,
        })
        .await?;

    let events: Vec<_> = run.events.collect().await;
    println!("events: {}", events.len());

    let finished = events.iter().any(|e| matches!(e, AgentEvent::RunFinish { .. }));
    println!("run_finish_seen: {}", finished);

    Ok(())
}

2. 运行

cargo run

3. 验证

预期输出包含:

  • events: <n>,其中 n > 0
  • run_finish_seen: true

你创建了什么

本示例在进程内创建一个 AgentOs,并立即执行一次请求。

这意味着该 Agent 已经可以通过三种方式使用:

  1. 在你自己的 Rust 应用代码中直接调用 os.run_stream(...)
  2. 以本地 CLI 风格的二进制程序运行,使用 cargo run
  3. 将同一个 AgentOs 挂载到 HTTP 服务器,供浏览器或远程客户端调用。

本教程演示选项 1 和 2。生产环境的集成通常会转向选项 3。

创建之后如何使用

你实际操作的核心对象是:

let os = AgentOsBuilder::new()
    .with_tools(tool_map([EchoTool]))
    .with_agent_spec(...)
    .build()?;

之后,常规入口为:

let run = os.run_stream(RunRequest { ... }).await?;

常见使用模式:

  • 一次性 CLI 程序:构造 RunRequest,收集事件,打印结果
  • 应用服务:将 os.run_stream(...) 封装在你自己的业务逻辑中
  • HTTP 服务器:将 Arc<AgentOs> 存入应用状态,并暴露协议路由

如何启动

在本教程中,二进制入口为 main(),因此启动方式非常简单:

cargo run

如果 Agent 位于工作区内的某个包中,请使用:

cargo run -p your-package-name

启动成功后,你的进程将:

  • 构建工具注册表
  • 注册 Agent 定义
  • 发送一次 RunRequest
  • 流式接收事件直至完成
  • 退出

因此,本教程是一个可运行的冒烟测试,而非长期运行的服务进程。

如何将其转换为服务器

若要通过 HTTP 暴露同一个 Agent,保留 AgentOsBuilder 的配置,并将其移入服务器状态:

use std::sync::Arc;
use tirea_agentos::contracts::storage::{MailboxStore, ThreadReader, ThreadStore};
use tirea_agentos_server::service::{AppState, MailboxService};
use tirea_agentos_server::{http, protocol};
use tirea_store_adapters::FileStore;

let file_store = Arc::new(FileStore::new("./sessions"));
let agent_os = AgentOsBuilder::new()
    .with_tools(tool_map([EchoTool]))
    .with_agent_spec(AgentDefinitionSpec::local_with_id(
        "assistant",
        AgentDefinition::new("gpt-4o-mini")
            .with_system_prompt("You are a helpful assistant.")
            .with_allowed_tools(vec!["echo".to_string()]),
    ))
    .with_agent_state_store(file_store.clone() as Arc<dyn ThreadStore>)
    .build()?;

let os = Arc::new(agent_os);
let read_store: Arc<dyn ThreadReader> = file_store.clone();
let mailbox_store: Arc<dyn MailboxStore> = file_store;
let mailbox_svc = Arc::new(MailboxService::new(os.clone(), mailbox_store, "my-agent"));

let app = axum::Router::new()
    .merge(http::health_routes())
    .merge(http::thread_routes())
    .merge(http::run_routes())
    .nest("/v1/ag-ui", protocol::ag_ui::http::routes())
    .nest("/v1/ai-sdk", protocol::ai_sdk_v6::http::routes())
    .with_state(AppState::new(os, read_store, mailbox_svc));

然后使用 Axum 监听器启动服务器,而不是直接调用 run_stream(...)

下一步阅读

根据你的需求选择下一篇文档:

常见错误

  • 模型与服务商不匹配:gpt-4o-mini 需要配置兼容 OpenAI 风格的服务商。
  • 密钥缺失:在执行 cargo run 前,请先设置 OPENAI_API_KEYDEEPSEEK_API_KEY
  • 工具未被选用:请确保提示词中明确要求使用 echo 工具。

后续

本文档为中文翻译版本,英文原版请参阅 First Tool

第一个工具

目标

实现一个读取并更新类型化状态的工具。

State 是可选的。 很多工具(API 调用、搜索、Shell 命令等)不需要状态 —— 只需实现 execute 并返回 ToolResult

前置条件

[dependencies]
async-trait = "0.1"
serde_json = "1"
serde = { version = "1", features = ["derive"] }
tirea = "0.5.0-alpha.1"
tirea-state-derive = "0.5.0-alpha.1"

1. 定义带 Action 的类型化状态

Tirea 的状态变更基于 Action 模式:定义一个 action 枚举和 reducer,运行时通过 ToolExecutionEffect 应用变更。直接通过 ctx.state::<T>().set_*() 写入状态会被运行时拒绝。

#[tirea(action = "...")] 属性关联 action 类型并生成 StateSpec。状态作用域默认为 thread(跨 run 持久化);可设置 #[tirea(scope = "run")] 表示 per-run 状态,或 #[tirea(scope = "tool_call")] 表示单次调用的临时数据。

use serde::{Deserialize, Serialize};
use tirea_state_derive::State;

#[derive(Debug, Clone, Default, Serialize, Deserialize, State)]
#[tirea(action = "CounterAction")]
struct Counter {
    value: i64,
    label: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
enum CounterAction {
    Increment(i64),
}

impl Counter {
    fn reduce(&mut self, action: CounterAction) {
        match action {
            CounterAction::Increment(amount) => self.value += amount,
        }
    }
}

2. 实现工具

重写 execute_effect,通过 ToolExecutionEffect 以类型化 action 的形式返回状态变更。

use async_trait::async_trait;
use serde_json::{json, Value};
use tirea::contracts::{AnyStateAction, ToolCallContext};
use tirea::contracts::runtime::tool_call::ToolExecutionEffect;
use tirea::prelude::*;

struct IncrementCounter;

#[async_trait]
impl Tool for IncrementCounter {
    fn descriptor(&self) -> ToolDescriptor {
        ToolDescriptor::new("increment_counter", "Increment Counter", "Increment counter state")
            .with_parameters(json!({
                "type": "object",
                "properties": {
                    "amount": { "type": "integer", "default": 1 }
                }
            }))
    }

    async fn execute(&self, args: Value, ctx: &ToolCallContext<'_>) -> Result<ToolResult, ToolError> {
        Ok(<Self as Tool>::execute_effect(self, args, ctx).await?.result)
    }

    async fn execute_effect(
        &self,
        args: Value,
        ctx: &ToolCallContext<'_>,
    ) -> Result<ToolExecutionEffect, ToolError> {
        let amount = args["amount"].as_i64().unwrap_or(1);
        let current = ctx.snapshot_of::<Counter>()
            .map(|c| c.value)
            .unwrap_or(0);

        Ok(ToolExecutionEffect::new(ToolResult::success(
            "increment_counter",
            json!({ "before": current, "after": current + amount }),
        ))
        .with_action(AnyStateAction::new::<Counter>(
            CounterAction::Increment(amount),
        )))
    }
}

3. 注册工具

use tirea::composition::{tool_map, AgentOsBuilder};

let os = AgentOsBuilder::new()
    .with_tools(tool_map([IncrementCounter]))
    .build()?;

4. 验证行为

发送一个触发 increment_counter 的请求,然后验证:

  • 事件流中包含 increment_counterToolCallDone
  • 线程状态 counter.value 按预期数量增加
  • 线程补丁历史中至少追加了一个新补丁

5. 读取状态

使用 snapshot_of 将当前状态读取为普通 Rust 值:

let snap = ctx.snapshot_of::<Counter>().unwrap_or_default();
println!("current value = {}", snap.value);

说明: ctx.state::<T>("path")ctx.snapshot_at::<T>("path") 用于同一状态类型在不同路径复用的高级场景。大多数工具使用 snapshot_of 即可 —— 它会自动使用状态类型上声明的路径。

6. TypedTool

对于参数结构固定的工具,参阅 TypedTool —— 它从 Rust 结构体自动生成 JSON Schema 并处理反序列化。

常见错误

  • 使用 ctx.state::<T>().set_*() 写入:运行时会拒绝直接状态写入。请改用 ToolExecutionEffect + AnyStateAction
  • 缺少 derive 宏导入:确保存在 use tirea_state_derive::State;
  • 数值解析回退掩盖 bug:如需严格输入校验,请对 amount 进行验证。
  • 过早使用原始 Value 解析:如果参数可以清晰地映射到一个结构体,请切换到 TypedTool
  • 在工具方法内对状态读取使用 ?snapshot_of 返回 TireaResult<T>,但工具方法返回 Result<_, ToolError>,两者之间没有 From 转换;对派生了 Default 的类型请使用 unwrap_or_default()
  • TypedTool::Args 上忘记 #[derive(JsonSchema)]:缺少它会导致编译失败。

下一步

本文档为中文翻译版本,英文原版请参阅 Architecture

架构

Tirea 运行时由三个层次组成:

Application -> AgentOs (orchestration + execution engine) -> Thread/State Engine

1. 应用层

你的应用程序负责定义工具、智能体定义及集成端点。

主要调用路径:

  • 通过 AgentOsBuilder 构建 AgentOs
  • 提交 RunRequest
  • 消费流式 AgentEvent

2. AgentOs(编排 + 执行)

AgentOs 同时负责运行前的编排与循环执行:

编排composition/runtime/):

  • 解析智能体/模型/插件的连接关系(插件实现 AgentBehavior 特征)
  • 加载或创建线程
  • 对传入消息去重
  • 持久化运行前检查点
  • 构建 RunContext

执行引擎engine/runtime/loop_runner/):

循环由阶段驱动:

  • RunStart
  • StepStart -> BeforeInference -> AfterInference -> BeforeToolExecute -> AfterToolExecute -> StepEnd
  • RunEnd

终止条件在 RunFinish.termination 中显式指定。

3. Thread + State 引擎

状态变更基于补丁(patch)机制:

  • State' = apply_patch(State, Patch)
  • Thread 存储基础状态、补丁历史及消息
  • RunContext 累积运行增量,并通过 take_delta() 触发持久化

设计意图

  • 确定性状态转换
  • 追加式持久化,配合版本校验
  • 与传输层无关的运行时(以 AgentEvent 作为核心流)

参见

本文档为中文翻译版本,英文原版请参阅 Immutable State Management

不可变状态管理

tirea-state 提供对 JSON 状态的类型化访问,并自动收集补丁(patch),从而实现确定性的状态转换与完整的重放能力。

补丁模型

状态从不被直接修改。变更以补丁的形式描述——补丁是操作的可序列化记录:

State' = apply_patch(State, Patch)

一个 Patch 包含一组 Op(操作),每个操作针对 JSON 文档中的特定路径。

#![allow(unused)]
fn main() {
extern crate tirea_state;
extern crate serde_json;
use tirea_state::{apply_patch, Patch, Op, path};
use serde_json::json;

let state = json!({"count": 0, "name": "counter"});

let patch = Patch::new()
    .with_op(Op::set(path!("count"), json!(10)))
    .with_op(Op::set(path!("updated"), json!(true)));

let new_state = apply_patch(&state, &patch).unwrap();

assert_eq!(new_state["count"], 10);
assert_eq!(new_state["updated"], true);
assert_eq!(state["count"], 0); // Original unchanged
}

核心类型

  • Patch — 操作的容器。通过 Patch::new().with_op(...) 创建,或由类型化状态访问自动收集。
  • Op — 单个原子操作:SetDeleteAppendMergeObjectIncrementDecrementInsertRemoveLatticeMerge
  • Path — JSON 文档中的路径,例如 path!("users", 0, "name")
  • apply_patch / apply_patches — 纯函数,由旧状态和补丁生成新状态。

StateManager

StateManager 管理带有补丁历史的不可变状态:

  • 追踪所有已应用的补丁(时间戳为可选项,由调用方通过 TrackedPatch 提供,而非由 StateManager 自动生成)
  • 支持通过 replay_to(index: usize) 回放到指定历史索引
  • 通过 detect_conflicts 提供并发补丁间的冲突检测

JsonWriter

若需在不使用类型化结构体的情况下动态操作 JSON,可使用 JsonWriter

#![allow(unused)]
fn main() {
extern crate tirea_state;
extern crate serde_json;
use tirea_state::{JsonWriter, path};
use serde_json::json;

let mut w = JsonWriter::new();
w.set(path!("user", "name"), json!("Alice"));
w.append(path!("user", "roles"), json!("admin"));
w.increment(path!("user", "login_count"), 1i64);

let patch = w.build();
}

冲突检测

当多个补丁修改了重叠路径时,detect_conflicts 会识别出相应的冲突:

  • compute_touched — 确定一个补丁影响了哪些路径
  • detect_conflicts — 比较两组受影响路径,找出重叠部分
  • ConflictKind — 描述冲突的类型(例如,两个操作同时写入同一路径)