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