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.