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
| Crate | Description |
|---|---|
tirea-state | Core library: typed state, JSON patches, apply, conflict detection |
tirea-state-derive | Proc-macro for #[derive(State)] |
tirea-contract | Shared contracts: thread/events/tools/plugins/runtime/storage/protocol |
tirea-agentos | Agent runtime: inference engine, tool execution, orchestration, plugin composition |
tirea-extension-* | Plugins: permission, reminder, observability, skills, MCP, A2UI |
tirea-protocol-ag-ui | AG-UI protocol adapters |
tirea-protocol-ai-sdk-v6 | Vercel AI SDK v6 protocol adapters |
tirea-store-adapters | Storage adapters: memory/file/postgres/nats-buffered |
tirea-agentos-server | HTTP/SSE/NATS gateway server |
tirea | Umbrella 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 sameState' apply_patchnever 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
Recommended Reading Path
If you are new to the repository, use this order:
- Read First Agent to see the smallest runnable flow.
- Read First Tool to understand state reads and writes.
- Read Typed Tool Reference before writing production tools.
- Use Build an Agent and Add a Tool as implementation checklists.
- 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:
| Path | Purpose |
|---|---|
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>wheren > 0run_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:
- Call
os.run_stream(...)from your own Rust application code. - Start it as a local CLI-style binary with
cargo run. - Mount the same
AgentOsinto 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(...).
Which Doc To Read Next
Use the next page based on what you want:
- keep calling the agent from Rust code: Build an Agent
- expose the agent to browsers or remote clients: Expose HTTP SSE
- connect it to AI SDK or CopilotKit: Integrate AI SDK Frontend and Integrate CopilotKit (AG-UI)
Common Errors
- Model/provider mismatch:
gpt-4o-minirequires a compatible OpenAI-style provider setup. - Missing key: set
OPENAI_API_KEYorDEEPSEEK_API_KEYbeforecargo 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
executeand return aToolResult.
Prerequisites
- Complete First Agent first.
- Reuse the runtime dependencies from First Agent.
Statederive 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
ToolCallDoneforincrement_counter - Thread state
counter.valueincreases 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")andctx.snapshot_at::<T>("path")exist for advanced cases where the same state type is reused at different paths. For most tools,snapshot_ofis 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. UseToolExecutionEffect+AnyStateActioninstead. - Numeric parse fallback hides bugs: validate
amountif strict input is required. - Reaching for raw
Valueparsing too early: if your arguments map cleanly to one struct, switch toTypedTool. - Using
?on state reads inside tool methods:snapshot_ofreturnsTireaResult<T>but tool methods returnResult<_, ToolError>with noFromconversion; useunwrap_or_default()for types that deriveDefault. - Forgetting
#[derive(JsonSchema)]onTypedTool::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_KEYforgpt-4o-mini). - You have at least one tool implementation.
- You know whether the deployment needs persistent storage.
Steps
- Define tool set.
.with_tools(tool_map([SearchTool, SummarizeTool]))
- 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()]),
))
- Wire persistence.
.with_agent_state_store(store.clone())
- 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?;
- 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
RunStartand oneRunFinishevent. RunFinish.terminationmatches 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:
- In-process execution: call
os.run_stream(RunRequest { ... }).await? - Long-lived backend service: put
Arc<AgentOs>into server state and expose HTTP protocol routes - 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_toolsif whitelist is enabled. - Empty runs with no meaningful output:
Confirm user message is appended in
RunRequest.messages.
Related Example
examples/ai-sdk-starter/README.mdis the fastest browser-facing backend integrationexamples/copilotkit-starter/README.mdshows the same runtime exposed through AG-UI with richer UI state
Key Files
examples/src/starter_backend/mod.rscrates/tirea-agentos/src/composition/agent_definition.rscrates/tirea-agentos/src/composition/builder.rscrates/tirea-agentos-server/src/main.rs
Related
- Expose HTTP SSE
- Expose NATS
- Integrate AI SDK Frontend
- Integrate CopilotKit (AG-UI)
- Run Lifecycle and Phases
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_roundsis lowered intoStopConditionSpec::MaxRoundsduring agent wiring unless you already declared an explicitmax_roundsstop spec.AgentOsBuilderwires the internalstop_policybehavior automatically when stop specs or stop-condition ids are present.stop_policyis 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 (StopPolicytrait). - You have a way to observe terminal run status through events or the Run API.
Steps
- 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(),
},
]);
- Keep
max_roundsaligned with your stop strategy.
max_roundsstill acts as the default loop-depth guard.- If you already added
StopConditionSpec::MaxRounds, do not expectmax_roundsto stack on top of it.
- 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()?;
- 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/:idreflects 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_policyas a normal behavior id. - Expecting
max_roundsandStopConditionSpec::MaxRoundsto 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.
Related Example
examples/src/starter_backend/mod.rsdefines astopperagent that terminates onStopConditionSpec::StopOnTool { tool_name: "finish" }
Key Files
crates/tirea-agentos/src/runtime/plugin/stop_policy.rscrates/tirea-agentos/src/composition/wiring.rscrates/tirea-agentos/src/composition/agent_definition.rscrates/tirea-agentos/src/runtime/tests.rs
Related
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
AgentOsBuilderwiring. - 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
- Choose
TypedToolfor fixed schemas, orToolfor dynamic/manual schemas. - Implement the tool with a stable descriptor id (
tool_id()forTypedTool,descriptor().idforTool). - Validate arguments explicitly (
validate()forTypedTool,executeorvalidate_argsforTool). - Keep execution deterministic on the same
(args, state)when possible. - Register tool with
AgentOsBuilder::with_tools(...). - 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
ToolCallStartandToolCallDoneformy_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. Usesnapshot_atonly for advanced cases with dynamic paths. - Writing: implement
execute_effectand returnToolExecutionEffect+AnyStateAction::new::<T>(action). Direct writes viactx.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 conversationrun— reset at the start of each agent runtool_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_toolsmust use identical id. - Missing derives for
TypedTool:Argsmust implement bothDeserializeandJsonSchema. - Silent argument defaults: prefer explicit validation for required fields.
- Non-deterministic side effects: hard to replay/debug and can break tests.
- Choosing plain
Toolfor a fixed schema: this usually adds parsing noise and drifts schema away from Rust types.
Related Example
examples/ai-sdk-starter/README.mdis the shortest end-to-end path for adding tools to a browser demoexamples/copilotkit-starter/README.mdshows tool rendering, approval, and persisted-thread integration
Key Files
examples/src/starter_backend/tools.rsexamples/src/travel/tools.rsexamples/src/research/tools.rscrates/tirea-contract/src/runtime/tool_call/tool.rs
Related
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
- Implement
AgentBehaviorand assign a stableid(). - Return phase actions with
ActionSet<...>from the phase hooks you need. - Register behavior in
AgentOsBuilder::with_registered_behavior("id", plugin). - Attach behavior id in
AgentDefinition.behavior_idsorwith_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.
Related Example
examples/src/travel.rsshows a productionLLMMetryPluginregistration pathexamples/src/starter_backend/mod.rswires permission and tool-policy behaviors into multiple agents
Key Files
crates/tirea-contract/src/runtime/behavior.rscrates/tirea-agentos/src/composition/builder.rscrates/tirea-extension-reminder/src/lib.rscrates/tirea-extension-permission/src/plugin.rs
Related
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
- Create file store.
use std::sync::Arc;
use tirea_store_adapters::FileStore;
let store = Arc::new(FileStore::new("./threads"));
- 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()?;
- 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.
Related Example
examples/ai-sdk-starter/README.mdandexamples/copilotkit-starter/README.mdboth default to local file-backed storage for their starter backends
Key Files
crates/tirea-store-adapters/src/file_store.rscrates/tirea-store-adapters/src/file_run_store.rsexamples/src/lib.rsexamples/src/starter_backend/mod.rs
Related
Use Postgres Store
Use PostgresStore when you need shared durable storage across instances.
Prerequisites
tirea-store-adaptersis enabled with featurepostgres.- A reachable PostgreSQL DSN is available.
- Tables auto-initialize on first store access; call
ensure_table()only if you want eager startup validation.
Steps
- 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 }
- 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?;
- 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()?;
- 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")returnsSome(Thread)after a run.load_messagesreturns 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_URLand database permissions. - Feature not enabled:
Confirm
postgresfeature is enabled ontirea-store-adapters.
Related Example
- 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.rscrates/tirea-agentos-server/tests/e2e_nats_postgres.rscrates/tirea-agentos-server/src/main.rs
Related
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-adapterswithnatsandpostgresfeatures.- Reachable PostgreSQL and NATS JetStream.
Steps
- 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?;
- 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?);
- Recover pending deltas on startup.
let recovered = buffered.recover().await?;
eprintln!("recovered {} buffered deltas", recovered);
- 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.
Related Example
- No dedicated UI starter ships for this storage path; use
crates/tirea-agentos-server/tests/e2e_nats_postgres.rsas the end-to-end integration fixture
Key Files
crates/tirea-store-adapters/src/nats_buffered.rscrates/tirea-store-adapters/src/postgres_store.rscrates/tirea-agentos-server/tests/e2e_nats_postgres.rs
Related
- Use Postgres Store
- Expose NATS
crates/tirea-agentos-server/tests/e2e_nats_postgres.rs
Expose HTTP SSE
Use this when clients consume run events over HTTP streaming.
Prerequisites
AgentOsis wired with tools and agents.ThreadReaderis available for query routes.ThreadReaderis wired to the same state store used by run/query APIs.
Endpoints
Run streams:
POST /v1/ag-ui/agents/:agent_id/runsPOST /v1/ai-sdk/agents/:agent_id/runsPOST /v1/runs
Run stream resume:
GET /v1/ai-sdk/agents/:agent_id/chats/:chat_id/stream
Query APIs:
GET /v1/threadsGET /v1/threads/summariesGET /v1/threads/:idGET /v1/threads/:id/messagesPATCH /v1/threads/:id/metadataDELETE /v1/threads/:idGET /v1/runsGET /v1/runs/:id
Steps
- 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,
});
- 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
- 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
- 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
200withcontent-type: text/event-stream. - AI SDK stream returns
x-vercel-ai-ui-message-stream: v1anddata: [DONE]trailer. - AG-UI stream includes lifecycle events (for example
RUN_STARTEDandRUN_FINISHED). GET /v1/runs/:idreturns run projection metadata.
Common Errors
400for payload validation (id,threadId,runId, or message/decision rules).404for unknownagent_id.- Run/A2A routes fail with internal error when run service is not initialized.
Related Example
examples/ai-sdk-starter/README.mdexercises AI SDK HTTP streaming end to endexamples/copilotkit-starter/README.mdexercises AG-UI streaming end to end
Key Files
crates/tirea-agentos-server/src/http.rscrates/tirea-agentos-server/src/protocol/ag_ui/http.rscrates/tirea-agentos-server/src/protocol/ai_sdk_v6/http.rsexamples/src/starter_backend/mod.rs
Related
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.runsagentos.ai-sdk.runs
Steps
- Start gateway (example).
AGENTOS_NATS_URL=nats://127.0.0.1:4222 cargo run --package tirea-agentos-server -- --config ./agentos.json
- Publish AI SDK request with auto-reply inbox.
nats req agentos.ai-sdk.runs '{"agentId":"assistant","sessionId":"thread-1","input":"hello"}'
- 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_STARTEDandRUN_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
agentIdreturns one error event on reply subject.
Related Example
- No dedicated UI starter ships for NATS yet; use
crates/tirea-agentos-server/tests/nats_gateway.rsas the end-to-end fixture
Key Files
crates/tirea-agentos-server/src/protocol/ag_ui/nats.rscrates/tirea-agentos-server/src/protocol/ai_sdk_v6/nats.rscrates/tirea-agentos-server/src/transport/nats.rscrates/tirea-agentos-server/tests/nats_gateway.rs
Related
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
useChatwith 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/runsGET /v1/ai-sdk/agents/:agent_id/chats/:chat_id/streamGET /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
- Install frontend deps.
npm install @ai-sdk/react ai
- Create
app/api/chat/route.tsas 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,
},
});
}
- Build transport with explicit
id/messagespayload.
"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 })
}
- Optional: load history from
GET /v1/ai-sdk/threads/:id/messagesbefore 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:
idmaps to thread idmessagescontains the new user messages to submitrunIdis used for decision-forwarding correlationtriggerandmessageIdare 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:
- backend suspends a tool call and emits AI SDK stream events
- frontend renders an approval UI from the streamed message parts
- frontend submits a decision-only payload using the same
idandrunId - server forwards the decision to the active run when possible
- 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/chatresponds withtext/event-stream.- Response includes
x-vercel-ai-ui-message-stream: v1. - Normal chat streams tokens.
- Regenerate flow (
trigger=regenerate-message) works withmessageId. - History hydration from
GET /v1/ai-sdk/threads/:id/messagesreplays the same thread in the browser.
Common Errors
- Sending legacy
sessionId/inputpayload to backend HTTP v6 route. - Missing
id(thread id) in forwarded payload. - Missing stream headers, causing client parser failures.
Related Example
examples/ai-sdk-starter/README.mdis the canonical AI SDK v6 integration in this repo
Key Files
examples/ai-sdk-starter/src/lib/transport.tsexamples/ai-sdk-starter/src/pages/playground-page.tsxexamples/ai-sdk-starter/src/lib/api-client.ts
Related
- AI SDK v6 Protocol
- Ecosystem Integrations
- HTTP API
examples/ai-sdk-starter/src/lib/transport.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-serveris reachable (defaulthttp://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
- Add frontend dependencies:
npm install @copilotkit/react-core @copilotkit/react-ui @copilotkit/runtime @ag-ui/client
- Create
lib/copilotkit-app.tsto 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 };
- Create route handler
app/api/copilotkit/route.ts.
import { handleRequest } from "@/lib/copilotkit-app";
export const POST = handleRequest;
- Wrap app with
CopilotKitprovider inapp/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>
);
}
- Add chat UI in
app/page.tsxusingCopilotChatorCopilotSidebar.
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:
- model selects a tool that is marked as frontend-executed
- backend does not run that tool locally
- backend emits a pending tool interaction via AG-UI
- frontend renders the tool UI or approval card
- frontend responds with
resumeorcancel - 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:
- backend permission/plugin/tool logic suspends execution
- AG-UI emits a pending interaction event
- CopilotKit renders an approval or data-entry component
- user responds
- runtime sends the decision back to the backend
- 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/copilotkitstreams 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. agentinCopilotKitprovider does not match runtimeagentskey.- Package version mismatch may require temporary
as anycast forHttpAgent.
Related Example
examples/copilotkit-starter/README.mdis the full-featured AG-UI + CopilotKit starterexamples/travel-ui/README.mdis the smaller travel-specific CopilotKit scenario demoexamples/research-ui/README.mdis the research-specific CopilotKit scenario demo
Key Files
examples/copilotkit-starter/lib/copilotkit-app.tsexamples/copilotkit-starter/lib/persisted-http-agent.tsexamples/travel-ui/lib/copilotkit-app.tsexamples/research-ui/lib/copilotkit-app.tsexamples/copilotkit-starter/app/api/copilotkit/route.ts
Related
- AG-UI Protocol
- Ecosystem Integrations
- Frontend Interaction and Approval Model
- HTTP API
examples/copilotkit-starter/README.mdexamples/copilotkit-starter/lib/copilotkit-app.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-skillsavailable.
Steps
- Discover skills from filesystem.
use tirea::skills::FsSkill;
let discovered = FsSkill::discover("./skills")?;
let skills = FsSkill::into_arc_skills(discovered.skills);
- 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
- (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
skilltool names.
Related Example
- No dedicated starter ships with skills enabled yet; the closest wiring surface is
examples/src/starter_backend/mod.rsonce you add skills discovery/config there
Key Files
crates/tirea-extension-skills/src/subsystem.rscrates/tirea-extension-skills/src/lib.rscrates/tirea-agentos/src/runtime/tests.rs
Related
- Capability Matrix
- Config
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-mcpdependency is available.mcp = { package = "model-context-protocol", version = "0.2", default-features = false, features = ["client"] }in yourCargo.toml.- One or more reachable MCP servers.
- Runtime uses Tokio.
Steps
- Build MCP server configs.
use mcp::transport::McpServerConnectionConfig;
let cfg = McpServerConnectionConfig::stdio(
"mcp_demo",
"python3",
vec!["-u".to_string(), "./mcp_server.py".to_string()],
);
- 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();
- 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()?;
- Keep
manageralive 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.resourceUriand 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.
Related Example
examples/ai-sdk-starter/README.mdcan surface MCP tool cards when the starter backend is run withMCP_SERVER_CMD
Key Files
crates/tirea-extension-mcp/src/lib.rscrates/tirea-extension-mcp/src/client_transport.rsexamples/src/starter_backend/mod.rs
Related
- Capability Matrix
- Expose HTTP SSE
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-permissionis enabled.- Frontend can return approval decisions to run inputs.
Steps
- 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()?;
- 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,
});
- 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.
- 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 (
permissionbeforetool_policy) makes out-of-scope checks less strict. - Missing
target_id/ malformed decisions prevents resume.
Related Example
examples/copilotkit-starter/README.mdis the most complete approval-focused frontend integrationexamples/travel-ui/README.mdshows approval-gated trip creationexamples/research-ui/README.mdshows approval-gated resource deletion
Key Files
crates/tirea-extension-permission/src/plugin.rsexamples/src/starter_backend/mod.rsexamples/ai-sdk-starter/src/components/tools/permission-dialog.tsxexamples/travel-ui/hooks/useTripApproval.tsxexamples/research-ui/hooks/useDeleteApproval.tsx
Related
Use Reminder Plugin
Use this when reminders should be injected into inference context from persisted state.
Prerequisites
tirea-extension-reminderis enabled.- Agent includes reminder behavior id.
Steps
- 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()?;
- 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>.
- 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.
Related Example
- No dedicated starter ships with reminders enabled by default; layer this plugin onto
examples/ai-sdk-starter/README.mdorexamples/copilotkit-starter/README.md
Key Files
crates/tirea-extension-reminder/src/lib.rscrates/tirea-extension-reminder/src/actions.rscrates/tirea-extension-reminder/src/state.rs
Related
Enable LLMMetry Observability
Use this when you need per-run inference/tool metrics and OpenTelemetry GenAI-aligned spans.
Prerequisites
tirea-extension-observabilitydependency is enabled.- Optional: tracing/OTel exporter configured in your runtime.
Steps
- 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());
}
}
- Register
LLMMetryPluginand 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.
Related Example
examples/src/travel.rswiresLLMMetryPlugininto a real runnable backend
Key Files
crates/tirea-extension-observability/src/lib.rsexamples/src/travel.rscrates/tirea-agentos-server/tests/phoenix_observability_e2e.rs
Related
- Add a Plugin
examples/src/travel.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
- 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()?;
- In orchestrator prompt/tool flow, call delegation tools.
- start or resume:
agent_run - stop background run tree:
agent_stop - fetch output snapshot:
agent_output
- Choose foreground/background execution per
agent_runcall.
background=false: parent waits and receives child progressbackground=true: child runs asynchronously and can be resumed/stopped later
Verify
- Orchestrator can call
agent_runfor allowed child agents. - Child run status transitions are visible (
running,completed,failed,stopped). agent_outputreturns child-thread outputs for the requestedrun_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.
Related Example
- No dedicated UI starter focuses on sub-agents yet; use
crates/tirea-agentos/tests/real_multi_subagent_deepseek.rsfor the main end-to-end example
Key Files
crates/tirea-agentos/src/runtime/agent_tools/manager.rscrates/tirea-agentos/src/runtime/agent_tools/tools/crates/tirea-agentos/tests/real_multi_subagent_deepseek.rs
Related
- Sub-Agent Delegation
- Capability Matrix
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_idand (if available)run_id. - Access to the emitted event stream and persisted thread data.
Steps
- Confirm termination reason from
AgentEvent::RunFinish { termination, .. }.
termination is authoritative and usually one of:
NaturalEndBehaviorRequestedStopped(...)CancelledSuspendedError
- Inspect event timeline ordering:
StepStart/StepEndToolCallStart/ToolCallDoneInferenceCompleteError
- Verify persisted delta in storage:
- New messages
- New patches
- Metadata version increment
- Check plugin phase behavior if execution is phase-dependent:
BeforeInferenceBeforeToolExecuteAfterToolExecute
- 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
AgentEventsemantics.
Related Example
examples/ai-sdk-starter/README.mdincludes thread-history verification that is useful when debugging replay and persistence issuesexamples/copilotkit-starter/README.mdincludes persisted-thread and canvas flows that surface event-ordering and approval issues quickly
Key Files
crates/tirea-agentos/src/runtime/loop_runner/mod.rscrates/tirea-agentos-server/src/http.rscrates/tirea-agentos-server/tests/run_api.rs
Related
Reference Overview
This section is normative and lookup-oriented.
Use these pages by question type:
- Capability-to-doc/example mapping: Capability Matrix
- Runtime and plugin action types: Actions
- Tool authoring with typed arguments: Typed Tool
- State operations and patch semantics: State Ops
- Runtime objects and data model: Thread Model, Run Context
- Event schema and termination semantics: Events
- Transport contracts: HTTP API, Run API, AG-UI, AI SDK v6, A2A, NATS
- Configuration and environment variables: Config
- Error taxonomy: Errors
- Rust API index: API
Capability Matrix
This matrix maps each framework capability to the authoritative docs and concrete implementation paths in this repository.
| Capability | Primary docs | Example / implementation paths |
|---|---|---|
Agent composition (AgentDefinition, behaviors, stop specs) | reference/config.md, how-to/build-an-agent.md | crates/tirea-agentos/src/composition/agent_definition.rs, examples/src/starter_backend/mod.rs |
| Stop policies and termination controls | how-to/configure-stop-policies.md, reference/config.md, explanation/run-lifecycle-and-phases.md | crates/tirea-agentos/src/runtime/plugin/stop_policy.rs, examples/src/starter_backend/mod.rs, crates/tirea-agentos/src/runtime/tests.rs |
| Tool execution modes | reference/config.md, explanation/hitl-and-decision-flow.md | crates/tirea-agentos/src/composition/agent_definition.rs, examples/src/starter_backend/mod.rs |
| Tool authoring and registration | tutorials/first-tool.md, how-to/add-a-tool.md, reference/typed-tool.md | examples/src/starter_backend/tools.rs, examples/src/travel/tools.rs |
| Plugin authoring and registration | how-to/add-a-plugin.md, reference/derive-macro.md | crates/tirea-extension-reminder/src/lib.rs, crates/tirea-extension-permission/src/plugin.rs |
| State patch operations + conflict model | reference/state-ops.md, explanation/state-and-patch-model.md | crates/tirea-state/src/op.rs, crates/tirea-state/src/apply.rs |
Typed state derive (#[derive(State)]) | reference/derive-macro.md | crates/tirea-state-derive/src/ |
| State scopes + run-scoped cleanup | explanation/persistence-and-versioning.md, reference/config.md | crates/tirea-contract/src/lib.rs, crates/tirea-agentos/src/runtime/tests.rs, crates/tirea-agentos/src/runtime/plugin/stop_policy.rs |
| HTTP SSE server surface | reference/http-api.md, how-to/expose-http-sse.md | crates/tirea-agentos-server/src/http.rs, examples/src/starter_backend/mod.rs |
| Canonical Run API (list/get/start/inputs/cancel) | reference/run-api.md | crates/tirea-agentos-server/src/http.rs, crates/tirea-agentos-server/tests/run_api.rs |
| Decision forwarding / suspend / replay | explanation/hitl-and-decision-flow.md, reference/run-api.md, how-to/enable-tool-permission-hitl.md | crates/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 protocol | reference/protocols/ag-ui.md, how-to/integrate-copilotkit-ag-ui.md | crates/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 protocol | reference/protocols/ai-sdk-v6.md, how-to/integrate-ai-sdk-frontend.md | crates/tirea-agentos-server/src/protocol/ai_sdk_v6/http.rs, examples/ai-sdk-starter/src/lib/transport.ts |
| A2A protocol | reference/protocols/a2a.md | crates/tirea-agentos-server/src/protocol/a2a/http.rs, crates/tirea-agentos-server/tests/a2a_http.rs |
| NATS gateway transport | reference/protocols/nats.md, how-to/expose-nats.md | crates/tirea-agentos-server/src/protocol/ag_ui/nats.rs, crates/tirea-agentos-server/src/protocol/ai_sdk_v6/nats.rs |
| File thread/run storage | how-to/use-file-store.md | crates/tirea-store-adapters/src/file_store.rs, crates/tirea-store-adapters/src/file_run_store.rs |
| Postgres thread/run storage | how-to/use-postgres-store.md | crates/tirea-store-adapters/src/postgres_store.rs |
| NATS-buffered + Postgres durability | how-to/use-nats-buffered-postgres-store.md | crates/tirea-store-adapters/src/nats_buffered.rs, crates/tirea-agentos-server/tests/e2e_nats_postgres.rs |
| Tool permission + HITL approval | how-to/enable-tool-permission-hitl.md, explanation/hitl-and-decision-flow.md | crates/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 plugin | how-to/use-reminder-plugin.md | crates/tirea-extension-reminder/src/ |
| LLM telemetry / observability | how-to/enable-llmmetry-observability.md | crates/tirea-extension-observability/src/, examples/src/travel.rs |
| Skills subsystem | how-to/use-skills-subsystem.md | crates/tirea-extension-skills/src/subsystem.rs |
| MCP tool bridge | how-to/use-mcp-tools.md | crates/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.md | crates/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 enum | Valid phase | What it can do | Typical use |
|---|---|---|---|
LifecycleAction | RunStart, StepStart, StepEnd, RunEnd | State(AnyStateAction) | lifecycle bookkeeping, run metadata |
BeforeInferenceAction | BeforeInference | AddSystemContext, AddSessionContext, ExcludeTool, IncludeOnlyTools, AddRequestTransform, Terminate, State | prompt injection, tool filtering, context-window shaping, early termination |
AfterInferenceAction | AfterInference | Terminate, State | inspect model response and stop or persist derived state |
BeforeToolExecuteAction | BeforeToolExecute | Block, Suspend, SetToolResult, State | permission checks, frontend approval, short-circuiting tool execution |
AfterToolExecuteAction | AfterToolExecute | AddSystemReminder, AddUserMessage, State | append 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:
- Return
ToolResult - Write state through
ToolCallContext - Return
ToolExecutionEffectwithActions
In practice, a tool can safely do:
- direct typed state writes through
ctx.state_of::<T>()orctx.state::<T>(...) - explicit
AnyStateAction - built-in
AfterToolExecuteAction - custom
Actionimplementations only when no built-inAfterToolExecuteActionvariant matches
The most common tool-side actions are:
AnyStateActionAfterToolExecuteAction::AddUserMessageAfterToolExecuteAction::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
AfterToolExecuteterms - they should not try to behave like
BeforeInferenceorBeforeToolExecuteplugins
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 stateAnyStateAction::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 orAnyStateAction::new...for reducer-style domain actions
What Plugins Can Emit
Plugins emit phase-specific core action enums through ActionSet<...>.
Typical patterns:
BeforeInferenceActionfor prompt/context/tool selection shapingBeforeToolExecuteActionfor gating, approval, or short-circuiting toolsAfterToolExecuteActionfor injecting reminders/messages after a toolLifecycleActionfor 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
| Plugin | Actions used | Scenario |
|---|---|---|
ReminderPlugin | BeforeInferenceAction::AddSessionContext, BeforeInferenceAction::State | inject reminder text into the next inference and optionally clear reminder state |
PermissionPlugin | BeforeToolExecuteAction::Block, BeforeToolExecuteAction::Suspend | deny a tool or suspend for permission approval |
ToolPolicyPlugin | BeforeInferenceAction::IncludeOnlyTools, BeforeInferenceAction::ExcludeTool, BeforeToolExecuteAction::Block | constrain visible tools up front and enforce scope at execution time |
SkillDiscoveryPlugin | BeforeInferenceAction::AddSystemContext | inject the active skill catalog or skill usage instructions into the prompt |
LLMMetryPlugin | no runtime-mutating actions; returns empty ActionSet | observability only, collects spans and metrics without changing behavior |
Built-in runtime / integration plugins
| Plugin | Actions used | Scenario |
|---|---|---|
ContextPlugin | BeforeInferenceAction::AddRequestTransform | compact + trim history and enable prompt caching before the provider request is sent |
AG-UI ContextInjectionPlugin | BeforeInferenceAction::AddSystemContext | inject frontend-provided context into the prompt |
AG-UI FrontendToolPendingPlugin | BeforeToolExecuteAction::Suspend, BeforeToolExecuteAction::SetToolResult | forward 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 AnyStateActionforSkillStateAction::Activate(...)- permission-domain state actions via
permission_state_action(...) AfterToolExecuteAction::AddUserMessageto 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
| Scenario | Recommended action form |
|---|---|
| Add prompt context before the next model call | BeforeInferenceAction::AddSystemContext or AddSessionContext |
| Hide or narrow tools for one run | BeforeInferenceAction::IncludeOnlyTools / ExcludeTool |
| Enforce approval before a tool executes | BeforeToolExecuteAction::Suspend |
| Reject tool execution with an explicit reason | BeforeToolExecuteAction::Block |
| Return a synthetic tool result without running the tool | BeforeToolExecuteAction::SetToolResult |
| Persist typed state from a tool or plugin | AnyStateAction or direct ctx.state... writes |
| Add follow-up instructions/messages after a tool completes | AfterToolExecuteAction::AddUserMessage |
| Modify request assembly itself | BeforeInferenceAction::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 orAnyStateAction::new...for reducer-style actions.
Related
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
Valueparsing - 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:
- The runtime deserializes incoming JSON into
Argswithserde_json::from_value. validate(&Args)runs for business rules that schema alone cannot express.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:
- Read current state via
snapshot_of - Return
ToolExecutionEffect::new(result).with_action(AnyStateAction::new::<T>(action)) - The runtime applies the action through the state’s
reducemethod
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, reportsrun: temporary execution state for one run, such as a plan, current objective, or runtime bookkeepingtool_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.rscontainsSelectTripTool, a realTypedToolimplementation.crates/tirea-contract/src/runtime/tool_call/tool.rscontains the authoritative trait definition.
Common Mistakes
- Deriving
Deserializebut forgettingJsonSchemaonArgs - Putting business validation into
executeinstead ofvalidate - Falling back to untyped
Valueparsing even though the input is fixed - Assuming
validate_argsstill uses JSON Schema at runtime forTypedTool
For TypedTool, the runtime skips Tool::validate_args and relies on deserialization plus validate(&Args).
Related
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_DOCSis set totrue
The published site is available at https://tirea-ai.github.io/tirea/.
Crate Index
| Crate | Description | API Docs |
|---|---|---|
tirea_state | Core state management | tirea_state |
tirea_state_derive | Derive macros | tirea_state_derive |
tirea_contract | Shared contracts | tirea_contract |
tirea_agentos | Agent runtime & orchestration | tirea_agentos |
tirea_store_adapters | Persistence adapters | tirea_store_adapters |
tirea_agentos_server | Server gateway | tirea_agentos_server |
tirea | Umbrella re-export crate | tirea |
Key Entry Points
tirea_state
apply_patch— Apply a single patch to statePatch— Patch containerOp— Operation typesStateContext— Typed state accessJsonWriter— Dynamic patch builderTireaError— Error types
tirea_contract
Thread— Persisted thread modelRunContext— Run-scoped execution contextRunRequest— Unified protocol requestTool— Tool traitThreadStore— Persistence abstraction
tirea_agentos
AgentOs— Registry + run orchestrationAgentOsBuilder— Builder for wiringAgentDefinition— Declarative agent config
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(...)andlattice_keys()are emitted for this type
Validation Rules
Compile-time errors are raised for invalid combinations:
flatten+renamelattice+nestedlattice+flattenlatticeonOption<T>,Vec<T>,Map<K,V>flattenon non-struct/non-Statefield
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 (
Stringkey):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 Ttype Ref<'a> = TRef<'a>const PATH: &'static strfrom_value/to_value- optimized
diff_ops(field-level) - optional
impl StateSpecwhenactionis 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
Setsemantics
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 applicationapply_patch_with_registry/apply_patches_with_registry: enables lattice-aware merge forOp::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 identifierresource_id: optional ownership key for listing/filteringparent_thread_id: lineage for delegated/sub-agent runsmessages: ordered message historystate: base snapshot valuepatches: tracked patch history since base snapshotmetadata.version: persisted version cursor
Core Methods
Thread::new(id)Thread::with_initial_state(id, state)with_message/with_messageswith_patch/with_patchesrebuild_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
RunStartStepStartInferenceCompleteToolCallStart/ToolCallDelta/ToolCallReady/ToolCallDone(see note below)StepEndRunFinish
State and UI Events
TextDeltaReasoningDelta/ReasoningEncryptedValueStateSnapshot/StateDeltaMessagesSnapshotActivitySnapshot/ActivityDeltaToolCallResumedError
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) —TrackedPatchrepresenting state changes produced by the tool call.message_id— pre-generated ID for the stored tool result message.outcome—ToolCallOutcomederived 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
limitis clamped to1..=200. - Canonical Run API and A2A task APIs rely on the configured
ThreadReader/state store.
Endpoint Map
Health:
GET /health
Threads:
GET /v1/threadsGET /v1/threads/summariesGET /v1/threads/:idGET /v1/threads/:id/messagesPOST /v1/threads/:id/interruptGET /v1/threads/:id/mailboxPATCH /v1/threads/:id/metadataDELETE /v1/threads/:id
Canonical runs:
GET /v1/runsGET /v1/runs/:idPOST /v1/runsPOST /v1/runs/:id/inputsPOST /v1/runs/:id/cancel
AG-UI:
POST /v1/ag-ui/agents/:agent_id/runsGET /v1/ag-ui/threads/:id/messages
AI SDK v6:
POST /v1/ai-sdk/agents/:agent_id/runsGET /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.jsonGET /v1/a2a/agentsGET /v1/a2a/agents/:agent_id/agent-cardPOST /v1/a2a/agents/:agent_id/message:sendGET /v1/a2a/agents/:agent_id/tasks/:task_idPOST /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-messagewithout validmessageId->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
agentIdon run creation ->400 /inputswith bothmessagesanddecisionsempty ->400/inputswith messages but missingagentId->400
A2A:
- action not
message:send->400 - decision-only submit without
taskId->400 - cancel path without
:cancelsuffix ->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/runsGET /v1/runs/:idPOST /v1/runsPOST /v1/runs/:id/inputsPOST /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:
usersubagentag_uiai_sdka2ainternal
status:
runningwaitingdone
List Runs
GET /v1/runs query params:
offset(default0)limit(clamped1..=200, default50)thread_idparent_run_idstatusorigintermination_codecreated_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.
- 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"
}
- 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:
messagesanddecisionscannot both be empty.- If
messagesis present,agentIdis 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 failures500: 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(default127.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 toid)description(optional, default empty string)model(optional, defaults toAgentDefinition::default().model)system_prompt(optional, default empty string)max_rounds(optional)tool_execution_mode(optional, defaultparallel_streaming)behavior_ids(optional, default[])stop_condition_specs(optional, default[])
Remote A2A agent file fields:
id(required)name(optional, defaults toid)description(optional, default empty string)endpoint(required, A2A base URL)remote_agent_id(optional, defaults toid)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:
sequentialparallel_batch_approvalparallel_streaming
Tool Execution Mode Semantics
| Mode | Scheduler | Suspension handling | Use when |
|---|---|---|---|
sequential | One tool call at a time | At most one call is actively executing at a time | Deterministic debugging and strict call ordering matter more than latency |
parallel_batch_approval | Parallel tool execution per round | Approval/suspension outcomes are applied after the tool round commits | Multiple tools may fan out, but you still want batch-style resume behavior |
parallel_streaming | Parallel tool execution per round | Stream mode can surface progress and apply resume decisions while tools are still in flight | Rich 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_roundstimeouttoken_budgetconsecutive_errorsstop_on_toolcontent_matchloop_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:
| Spec | What it measures | Use when |
|---|---|---|
max_rounds | Completed tool/inference rounds | You want a hard upper bound on loop depth |
timeout | Wall-clock elapsed time | Long-running agents must terminate predictably |
token_budget | Cumulative prompt + completion tokens | Spend must stay inside a token budget |
consecutive_errors | Back-to-back failing tool rounds | You want to halt repeated tool failure cascades |
stop_on_tool | Specific tool id emitted by the model | A tool should act as an explicit finish/escape hatch |
content_match | Literal text pattern in model output | You need a simple semantic stop trigger without a dedicated tool |
loop_detection | Repeated tool-call name patterns | You 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
- tools:
Model Fallbacks and Retries
fallback_models: ordered fallback model ids that are tried after the primarymodelllm_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_toolsallowed_skills/excluded_skillsallowed_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
| Variant | When It Occurs |
|---|---|
PathNotFound | Accessing a path that doesn’t exist in the document |
IndexOutOfBounds | Array index exceeds array length |
TypeMismatch | Expected one type, found another (e.g., expected object, found string) |
NumericOperationOnNonNumber | Increment/Decrement on a non-numeric value |
MergeRequiresObject | MergeObject on a non-object value |
AppendRequiresArray | Append on a non-array value |
InvalidOperation | General invalid operation |
Serialization | serde_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 executionAgentLoopError— Errors in the agent loop (LLM failures, tool errors)ThreadStoreError— Thread persistence failuresAgentOsRunError— Run preparation/execution errors in orchestrationAgentOsBuildError/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:
| Need | Preferred path |
|---|---|
Fastest chat-style integration with useChat | AI SDK v6 |
| Rich shared state, frontend tools, generative UI, HITL | AG-UI / CopilotKit |
| Backend-only tools with a thin frontend adapter | AI SDK v6 |
| Frontend-executed tools that suspend and resume runs | AG-UI / CopilotKit |
| Canvas-style or co-agent-like UX | AG-UI / CopilotKit |
Repo-specific mapping:
| Integration | Backend endpoint | Frontend runtime shape | Best for |
|---|---|---|---|
| AI SDK v6 | POST /v1/ai-sdk/agents/:agent_id/runs | useChat + SSE adapter route | plain chat, minimal glue, backend-centric tools |
| AG-UI / CopilotKit | POST /v1/ag-ui/agents/:agent_id/runs | CopilotKit runtime proxy + HttpAgent | shared 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). HttpAgentpoints to backend AG-UI run endpoint.
Examples:
Pattern B: Runtime Proxy + framework-specific adapter
Used when framework has a dedicated CopilotKit adapter.
LangGraphHttpAgent/LangGraphAgentMastraAgent
Examples:
Pattern C: Canvas and HITL focused apps
Used for shared state, generative UI, and human-in-the-loop workflows.
Examples:
- canvas-with-langgraph-python
- canvas-with-mastra
- canvas-with-llamaindex
- with-langgraph-fastapi-persisted-threads
Official Starter Ecosystem Support
The table below combines AG-UI upstream support status with CopilotKit official starter availability.
| Framework/Spec | AG-UI Upstream Status | CopilotKit Starter Availability | Starter Repositories |
|---|---|---|---|
| LangGraph | Supported | with-*, canvas-*, coagents-*, persisted threads | with-langgraph-fastapi, with-langgraph-fastapi-persisted-threads, with-langgraph-js, with-langgraph-python, canvas-with-langgraph-python, coagents-starter-langgraph |
| Mastra | Supported | with-*, canvas-* | with-mastra, canvas-with-mastra |
| Pydantic AI | Supported | with-* | with-pydantic-ai |
| LlamaIndex | Supported | with-*, canvas-* | with-llamaindex, canvas-with-llamaindex, canvas-with-llamaindex-composio |
| CrewAI Flows | Supported (CrewAI) | with-*, coagents-* | with-crewai-flows, coagents-starter-crewai-flows |
| Microsoft Agent Framework | Supported | with-* | with-microsoft-agent-framework-dotnet, with-microsoft-agent-framework-python |
| Google ADK | Supported | with-* | with-adk |
| AWS Strands | Supported | with-* | with-strands-python |
| Agno | Supported | with-* | with-agno |
| AG2 | Supported | demo-level only | ag2-feature-viewer |
| A2A Protocol | Supported | protocol starters | with-a2a-middleware, with-a2a-a2ui |
| Oracle Agent Spec | Supported | protocol/spec starter | with-agent-spec |
| MCP Apps | Supported | protocol/spec starter | with-mcp-apps |
| AWS Bedrock Agents | In Progress | none yet | (no official starter repo found) |
| OpenAI Agent SDK | In Progress | none yet | (no official starter repo found) |
| Cloudflare Agents | In Progress | none 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.
| Starter | Runtime Adapter | Shared State | Frontend Actions | Generative UI | HITL |
|---|---|---|---|---|---|
with-langgraph-fastapi | LangGraphHttpAgent | Yes | Yes | Yes | Yes |
with-langgraph-js | LangGraphAgent | Yes | Yes | Yes | No |
with-mastra | MastraAgent | Yes | Yes | Yes | Yes |
with-pydantic-ai | HttpAgent | Yes | Yes | Yes | Yes |
with-llamaindex | LlamaIndexAgent | Yes | Yes | Yes | No |
with-crewai-flows | HttpAgent | Yes | Yes | Yes | No |
with-agno | HttpAgent | No | Yes | Yes | No |
with-adk | HttpAgent | Yes | Yes | Yes | Yes |
with-strands-python | HttpAgent | Yes | Yes | Yes | No |
with-microsoft-agent-framework-dotnet | HttpAgent | Yes | Yes | Yes | Yes |
with-microsoft-agent-framework-python | HttpAgent | Yes | Yes | Yes | Yes |
with-langgraph-fastapi-persisted-threads | LangGraphHttpAgent | Yes | Yes | Yes | Yes |
Notes:
- Most starters use Next.js
copilotRuntimeNextJSAppRouterEndpointas the frontend runtime bridge. - Most non-LangGraph/Mastra starters use generic
HttpAgenttransport. with-langgraph-fastapi-persisted-threadsis 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.
- Upstream list: AG-UI README integrations table
AI SDK Frontend Pattern
For @ai-sdk/react, the common pattern is:
useChatin the browser.- Next.js
/api/chatroute adapts UI messages to backend request body. - 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:
- CopilotKit runtime runs behind a same-origin Next.js route.
HttpAgentpoints at Tirea’s AG-UI endpoint.- AG-UI SSE events drive chat, shared state, activity updates, and frontend tool suspensions.
- 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.mdexamples/travel-ui/README.mdexamples/research-ui/README.md
AI SDK:
examples/ai-sdk-starter/README.md
AG-UI Protocol
Endpoints
POST /v1/ag-ui/agents/:agent_id/runsGET /v1/ag-ui/threads/:id/messages
Request Model (RunAgentInput)
Required:
threadIdrunId
Core optional fields:
messagestoolscontextstateparentRunIdparentThreadIdmodelsystemPromptconfigforwardedProps
Minimal request:
{
"threadId": "thread-1",
"runId": "run-1",
"messages": [{ "role": "user", "content": "Plan my weekend" }],
"tools": []
}
Runtime Mapping
RunAgentInputis converted to internalRunRequest.model/systemPromptoverride resolved agent defaults for this run.configcan override tool execution mode and selected chat options.configoverrides are applied at runtime (tool execution mode, chat options) but not persisted to state.forwardedPropsis 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-streamdata: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/runsGET /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 idmessages(required): UI messages arrayparentThreadId(optional)trigger(optional):submit-messageorregenerate-messagemessageId(required whentrigger=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-streamx-vercel-ai-ui-message-stream: v1x-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 Contentif no active fanout stream exists foragent_id:chat_id200SSE 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.jsonGET /v1/a2a/agentsGET /v1/a2a/agents/:agent_id/agent-card
Task APIs:
POST /v1/a2a/agents/:agent_id/message:sendGET /v1/a2a/agents/:agent_id/tasks/:task_idPOST /v1/a2a/agents/:agent_id/tasks/:task_id:cancel
Discovery Semantics
/.well-known/agent-card.json:
- Returns a gateway card with
taskManagement,streaming, andagentDiscoverycapability flags. - Adds HTTP caching headers:
cache-control: public, max-age=30, must-revalidateetag: W/"a2a-agents-..."
- Supports
if-none-match(*and CSV values).
Single-agent deployment:
- Well-known card
urlpoints directly to/v1/a2a/agents/<id>/message:send.
Multi-agent deployment:
- Well-known card
urlpoints 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(optionalToolCallDecision[])
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:
messageis the latest public assistant output when one exists; otherwise it isnull.artifactscontains the latest public assistant output normalized as text artifacts.historycontains publicuser/assistant/systemthread 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/taskIdexists. - Cancel path must end with
:canceland must bePOST. 404for unknown agent or missing task/run.400when 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.runsagentos.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:
- NATS message reply inbox (
msg.reply) - 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.
Related
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
AgentOsviaAgentOsBuilder - 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
AgentBehaviortrait) - Load or create thread
- Deduplicate incoming messages
- Persist pre-run checkpoint
- Construct
RunContext
Execution engine (engine/, runtime/loop_runner/):
Loop is phase-driven:
RunStartStepStart -> BeforeInference -> AfterInference -> BeforeToolExecute -> AfterToolExecute -> StepEndRunEnd
Termination is explicit in RunFinish.termination.
3. Thread + State Engine
State mutation is patch-based:
State' = apply_patch(State, Patch)Threadstores base state + patch history + messagesRunContextaccumulates run delta and emitstake_delta()for persistence
Design Intent
- Deterministic state transitions
- Append-style persistence with version checks
- Transport-independent runtime (
AgentEventas core stream)
See Also
- Run Lifecycle and Phases
- Frontend Interaction and Approval Model
- Persistence and Versioning
- HTTP API
- Events
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 viaPatch::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 byStateManager) - 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 affectsdetect_conflicts— Compares two sets of touched paths to find overlapsConflictKind— 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
| Status | Meaning |
|---|---|
Running | Run is actively executing (inference or tools) |
Waiting | Run is paused waiting for external resume decisions |
Done | Terminal — 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
| Status | Meaning |
|---|---|
New | Call observed but not yet started |
Running | Call is executing |
Suspended | Call paused waiting for external decision |
Resuming | External decision received, replay in progress |
Succeeded | Terminal — execution succeeded |
Failed | Terminal — execution failed |
Cancelled | Terminal — call cancelled |
How the Layers Connect
- During a tool round, each tool call transitions through its own lifecycle.
- After the round commits, the run evaluates all outcomes:
- If all outcomes are
Suspended→ run transitions toWaitingand terminates withTerminationReason::Suspended. - If any outcome is non-suspended → run stays
Runningand loops back to inference.
- If all outcomes are
- An inbound
ToolCallDecisiontriggers:- Tool call:
Suspended→Resuming→ replay → terminal state. - Run:
Waiting→Running(if the run was suspended).
- Tool call:
Durable State Paths
| Path | Content |
|---|---|
__run | RunLifecycleState (id, status, done_reason, updated_at) |
__tool_call_scope.<call_id>.tool_call_state | ToolCallState (per-call status) |
__tool_call_scope.<call_id>.suspended_call | SuspendedCallState (suspended call payload) |
Canonical Top-Level Flow
RunStartcommit(UserMessage)+ optional resume replay (apply_decisions_and_replay)- Loop:
RESUME_TOOL_CALL(apply inbound decisions + replay if any)StepStartBeforeInferenceLLM_CALLAfterInferenceStepEnd- Optional
TOOL_CALLround:BeforeToolExecute- Tool execution
AfterToolExecute- apply tool results + commit
RESUME_TOOL_CALLagain (consume decisions received during tool round)
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:
UserMessage- Trigger: after
RunStartside effects are applied.
- Trigger: after
ToolResultsCommitted(run-start replay)- Trigger: run-start replay actually executed at least one resumed call.
AssistantTurnCommitted- Trigger: each successful
LLM_CALLresult is applied (AfterInference+ assistant message +StepEnd).
- Trigger: each successful
ToolResultsCommitted(tool round)- Trigger: each completed tool round is applied to session state/messages.
ToolResultsCommitted(decision replay in-loop)- Trigger: inbound decision resolved and replay produced effects.
RunFinished(force=true)- Trigger: any terminal exit path (
NaturalEnd/Suspended/Cancelled/Error/ plugin-requested termination).
- Trigger: any terminal exit path (
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
Suspendedevaluation; - 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:
NoDecisionexits with no replay commit.CommitReplayis 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:
ToolCallOutcome | ToolCallStatus |
|---|---|
Succeeded | Succeeded |
Failed | Failed |
Suspended | Suspended |
Important:
Blockedis a pre-execution gate outcome (fromBeforeToolExecute) and is returned to model asToolResult::errorwithToolCallOutcome::Failed.- In canonical loop execution,
ExecuteToolitself 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:
- ToolCallOutcome::Failed: plugin/tool-gate blocks and returns tool error.
- 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_CALLphase.
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
CommitToolis 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
AgentBehaviortrait.
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:
- execution cannot proceed right now
- the reason must be persisted durably
- an external actor must provide input
- 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::SuspendBeforeToolExecuteAction::SetToolResultBeforeToolExecuteAction::BlockSuspendTicket- 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 progressingWaiting: the run cannot continue until an external decision arrivesDone: 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:
allowdenyask
In code these map to ToolPermissionBehavior variants: Allow (no action emitted),
Deny → BeforeToolExecuteAction::Block(reason), Ask → BeforeToolExecuteAction::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:
- records the decision
- resolves the matching suspended call
- marks the call
Resuming - 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.
What To Read Next
- For the full phase/run/tool state machines: Run Lifecycle and Phases
- For approval and decision semantics: HITL and Decision Flow
- For AG-UI frontend tool transport behavior: AG-UI Protocol
- For permission wiring: Enable Tool Permission HITL
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
- A tool call suspends.
- The run transitions to
Waitingif all active tool calls are suspended. - 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
ToolCallDecisionpayloads 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:
- A tool returns
Pending(...)or a behavior suspends it before execution. - Suspended call payload is stored in runtime state.
- Client posts a
ToolCallDecisionto the active run. - 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_idlinks 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:
| Mode | Practical effect on HITL |
|---|---|
sequential | Simplest mental model; one call is active at a time |
parallel_batch_approval | Multiple calls may suspend in a round, then resume behavior is applied after batch commit |
parallel_streaming | Stream 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:
__runtracks run lifecycle__tool_call_scope.<call_id>.tool_call_statetracks per-call status__tool_call_scope.<call_id>.suspended_callstores suspended call payloads
Lineage fields keep the larger execution graph understandable:
run_idthread_idparent_run_idparent_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/inputsforwards decisions to the active run - AG-UI: protocol adapters forward decisions onto the same runtime channel
- A2A:
decision-only requests map to the same
ToolCallDecisioncontract
The transport changes payload shape and encoding, but the runtime semantics stay the same.
When to Reach for Which Doc
- Use Enable Tool Permission HITL to wire approval behavior
- Use Run API for concrete HTTP payloads
- Use Frontend Interaction and Approval Model for the unified design behind frontend tools and approvals
- Use Run Lifecycle and Phases for full internal state-machine detail
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_outputdelegation toolsAgentDefinitionwithallowed_agents/excluded_agentsSuspendTicketfor 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
| Pattern | Tirea Mechanism | Deterministic Flow? |
|---|---|---|
| Coordinator | agent_run + prompt routing | No (LLM decides) |
| Sequential Pipeline | Chained agent_run calls | No (LLM relays) |
| Parallel Fan-Out/Gather | Parallel agent_run + agent_output | No (best-effort) |
| Hierarchical Decomposition | Nested agent_run | No |
| Generator-Critic | Generator as main agent, critic as child via agent_run | No |
| Iterative Refinement | Same as Generator-Critic with additional refiner child | No |
| Human-in-the-Loop | SuspendTicket + PermissionPlugin | Yes (runtime gating) |
| Swarm / Peer Handoff | TODO — 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_modetoParallelBatchApprovalorParallelStreamingto 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_idinRunRequest), not a separate orchestrator. - The critic should exclude delegation tools to prevent recursion.
- Set
max_roundshigh 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:
PermissionPluginintercepts tool calls and emitsSuspendactionsToolCallDecisionchannel 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_streamingtool 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 for the runtime model behind delegation tools
- Use Sub-Agent Delegation for setup instructions
- HITL and Decision Flow for suspension mechanics
- Architecture for the three-layer runtime model
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 runagent_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:
-
In-memory handle table (
SubAgentHandleTable)- live
SubAgentHandleperrun_id, keyed byrun_id - owner thread check (
owner_thread_id) - epoch-based stale completion guard
- cancellation token per handle
- live
-
Persisted state in the owner thread:
SubAgentStateat pathsub_agents(scope:Thread)runs: HashMap<String, SubAgent>— lightweight metadata perrun_id- each
SubAgentcarries: agent id, execution ref (localthread_idor 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, oragent_output
Policy and Visibility
Target-agent visibility is filtered by scope policy:
RunPolicy.allowed_agentsRunPolicy.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
- Load thread + current version.
- Build/apply run delta (
messages,patches, optional state snapshot). - Append with exact expected version.
- Store returns committed next version.
Checkpoint Mechanism
The runtime persists state through incremental checkpoints.
- Delta source:
RunContext::take_delta()— returnsRunDelta { messages, patches, state_actions } - Persisted payload:
ThreadChangeSet { run_id, parent_run_id, run_meta, reason, messages, patches, state_actions, snapshot }— assembled byStateCommitterfrom theRunDelta - 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
snapshotwhen request state replaces thread state
B) Runtime checkpoints (loop execution path)
During run_loop / run_loop_stream execution:
- After
RunStartphase side effects are applied:- Reason:
UserMessage - Purpose: persist immediate inbound side effects before any replay
- Reason:
- If RunStart outbox replay executes:
- Reason:
ToolResultsCommitted - Purpose: persist replayed tool outputs/patches
- Reason:
- After assistant turn is finalized (
AfterInference+ assistant message +StepEnd):- Reason:
AssistantTurnCommitted
- Reason:
- After tool results are applied (including suspension state updates):
- Reason:
ToolResultsCommitted
- Reason:
- On termination:
- Reason:
RunFinished - Forced commit (even if no new delta) to mark end-of-run boundary
- Reason:
Failure Semantics
- Non-final checkpoint failure is treated as run failure:
- emits state error
- run terminates with error
- Final
RunFinishedcheckpoint 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:
| Scope | Lifetime | Cleanup |
|---|---|---|
Thread | Persists across runs | Never cleaned automatically |
Run | Per-run | Deleted by prepare_run before each new run |
ToolCall | Per-call | Scoped under __tool_call_scope.<call_id>, cleaned after call completes |
Run-scoped cleanup
At run preparation (prepare_run), the framework:
- Queries
StateScopeRegistry::run_scoped_paths()for allRun-scoped state paths - Emits
Op::deletepatches for any paths present in the current thread state - Applies deletions to in-memory state before the lifecycle
Runningpatch
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 shape | Recommended scope | Why |
|---|---|---|
| User-visible business state (threads, notes, trips, reports) | Thread | Must survive across runs and reloads |
Execution bookkeeping (__run, stop-policy counters, per-run temp state) | Run | Useful only while one run is active and must not leak into the next run |
| Pending approval / per-call scratch state | ToolCall | Bound to a single tool invocation and cleaned when that call resolves |
In practice:
- prefer
Threadfor state a user would expect to see after a page reload; - prefer
Runfor coordination state owned by plugins or the runtime; - prefer
ToolCallwhen 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
AgentBehaviortrait.
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 throughToolExecutionEffect
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 ToolExecutionEffectcontains:- a
ToolResult - zero or more
Actions applied duringAfterToolExecute
- a
- State changes are represented as
AnyStateActionand 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(viasnapshot_of,snapshot_at, or live references) - return a
ToolResult - emit state mutations and other
Actions fromexecute_effect(viaToolExecutionEffect+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:
AnyStateActionto update reducer-backed state- user-message insertion actions
- other custom
Actionimplementations valid inAfterToolExecute
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:
- Returns a success
ToolResult - Emits a state action to activate the skill in persisted state
- 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
BeforeInferenceActionto include/exclude tools - can emit
BeforeToolExecuteActionto 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
AgentEventstream 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.2 | 0.3 |
|---|---|
AgentPlugin | AgentBehavior |
PluginRegistry | BehaviorRegistry |
InMemoryPluginRegistry | InMemoryBehaviorRegistry |
builder.with_registered_plugin(...) | builder.with_registered_behavior(...) |
AgentDefinition.plugin_ids | AgentDefinition.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 pathsregister_state_scopes(registry)— register state scope metadataregister_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.2 | 0.3 |
|---|---|
StepContext::run_config() | ReadOnlyContext::run_policy() |
ToolExecutionRequest field run_config | field 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:
| Scope | Lifetime |
|---|---|
Thread (default) | Persists across runs |
Run | Deleted at start of each new run |
ToolCall | Scoped 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)]structsOp::LatticeMergeoperation variantLatticeRegistryfor 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.2 | 0.3 |
|---|---|
DelegationStatus | SubAgentStatus |
DelegationRecord | SubAgent (lightweight metadata) |
DelegationState | SubAgentState |
AgentRunManager | SubAgentHandleTable |
State path agent_runs | State 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.2 | 0.3 |
|---|---|
PermissionContextExt | PermissionAction + permission_state_action() |
ReminderContextExt | ReminderAction + add_reminder_action() / clear_reminder_action() |
SkillPlugin | SkillDiscoveryPlugin |
Type Renames
| 0.2 | 0.3 |
|---|---|
RunState | RunLifecycleState (with #[tirea(scope = "run")]) |
ToolCallStatesMap | Removed — ToolCallState is now per-call scoped |
SuspendedToolCallsState | SuspendedCallState (per-call scoped) |
InferenceErrorState | Removed — errors carried in LLMResponse |
TerminationReason::PluginRequested | TerminationReason::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 recordlist_runs(query)— paginated run listing with filtersactive_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.1 | 0.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 fields | Removed — use RunAction / ToolCallAction on typed contexts |
Event Stream Changes
Removed variants:
| Removed | Replacement |
|---|---|
AgentEvent::InteractionRequested | Tool-call suspension emitted via ToolCallDone with pending status |
AgentEvent::InteractionResolved | AgentEvent::ToolCallResumed { target_id, result } |
AgentEvent::Pending | Run 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::InferenceCompletenow includesduration_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.1 | 0.2 |
|---|---|
ToolSuspension | SuspendTicket (deprecated alias provided) |
pending_interaction() | suspended_calls() |
pending_frontend_invocation() | Removed |
| Single pending slot | Per-call SuspendedCall map |
| Resume via outbox replay | Resume 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.1 | 0.2 |
|---|---|
RunLifecycleStatus | RunStatus |
RunLifecycleAction | RunAction (flow control: Continue/Terminate) |
ToolCallLifecycleAction | ToolCallAction (gate: Proceed/Suspend/Block) |
ToolCallLifecycleState | ToolCallState |
ToolSuspension | SuspendTicket |
Method Renames
Deprecated forwarding methods are provided. Update call sites to suppress warnings:
| 0.1 | 0.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:
| Path | Type | Content |
|---|---|---|
__run | RunLifecycleState | Run id, status, done_reason, updated_at |
__tool_call_scope.<call_id>.tool_call_state | ToolCallState | Per-call lifecycle status |
__tool_call_scope.<call_id>.suspended_call | SuspendedCallState | Suspended 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/threadsroutes
Runtime Surface Updates
- Prefer
AgentOs::run_stream(RunRequest)for app-level integration. - Use
RunContextas 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-agentos | Agent 运行时:推理引擎、工具执行、编排、插件组合 |
tirea-extension-* | 插件:权限、提醒、可观测性、技能、MCP、A2UI |
tirea-protocol-ag-ui | AG-UI 协议适配器 |
tirea-protocol-ai-sdk-v6 | Vercel AI SDK v6 协议适配器 |
tirea-store-adapters | 存储适配器:memory / file / postgres / nats-buffered |
tirea-agentos-server | HTTP / 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 查询页面
- 原理解析 — 架构与设计决策说明
推荐阅读路径
如果您是首次接触本代码库,建议按以下顺序阅读:
- 阅读 First Agent,了解最小可运行流程。
- 阅读 First Tool,理解状态读写机制。
- 在编写生产级工具前,阅读 Typed Tool Reference。
- 将 Build an Agent 和 Add a Tool 作为实现检查清单使用。
- 需要了解完整执行模型时,回头阅读 Architecture 和 Run 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 > 0run_finish_seen: true
你创建了什么
本示例在进程内创建一个 AgentOs,并立即执行一次请求。
这意味着该 Agent 已经可以通过三种方式使用:
- 在你自己的 Rust 应用代码中直接调用
os.run_stream(...)。 - 以本地 CLI 风格的二进制程序运行,使用
cargo run。 - 将同一个
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(...)。
下一步阅读
根据你的需求选择下一篇文档:
- 继续从 Rust 代码调用 Agent:构建一个 Agent
- 将 Agent 暴露给浏览器或远程客户端:暴露 HTTP SSE
- 接入 AI SDK 或 CopilotKit:集成 AI SDK 前端 和 集成 CopilotKit (AG-UI)
常见错误
- 模型与服务商不匹配:
gpt-4o-mini需要配置兼容 OpenAI 风格的服务商。 - 密钥缺失:在执行
cargo run前,请先设置OPENAI_API_KEY或DEEPSEEK_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_counter的ToolCallDone - 线程状态
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/):
循环由阶段驱动:
RunStartStepStart -> BeforeInference -> AfterInference -> BeforeToolExecute -> AfterToolExecute -> StepEndRunEnd
终止条件在 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— 单个原子操作:Set、Delete、Append、MergeObject、Increment、Decrement、Insert、Remove、LatticeMerge。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— 描述冲突的类型(例如,两个操作同时写入同一路径)