| Crates.io | riglr-macros |
| lib.rs | riglr-macros |
| version | 0.3.0 |
| created_at | 2025-09-10 18:19:19.814493+00 |
| updated_at | 2025-09-10 18:19:19.814493+00 |
| description | Procedural macros for riglr - reducing boilerplate when creating rig-compatible tools. |
| homepage | |
| repository | https://github.com/riglr/riglr |
| max_upload_size | |
| id | 1832835 |
| size | 195,033 |
Procedural macros for the riglr ecosystem, providing code generation for tool definitions and error handling with automatic dependency injection.
#[tool] macro: Automatically implement riglr_core::Tool for functions and structsToolError with proper retry classificationriglr-macros provides compile-time code generation for the riglr ecosystem, transforming annotated functions into fully-featured tools with dependency injection and error handling.
For each #[tool] annotated function, the macro generates:
{name}_tool() function returning Arc<dyn Tool>#[tool] MacroThe #[tool] macro transforms async functions and structs into riglr tools with automatic dependency injection by generating:
ToolErrorThe macro uses type-based detection to automatically identify and inject dependencies. Functions with ApplicationContext parameters are automatically detected and the context is injected at runtime - no attributes required!
use riglr_macros::tool;
use riglr_core::{ToolError, provider::ApplicationContext};
/// Checks the SOL balance for a given Solana address
#[tool]
async fn check_sol_balance(
context: &ApplicationContext, // Automatically detected and injected
address: String,
) -> Result<f64, ToolError> {
// Access blockchain clients through context
let solana_client = context.solana_client()?;
// Implementation would check actual balance
let balance = solana_client.get_balance(&address).await?;
Ok(balance as f64 / 1_000_000_000.0) // Convert lamports to SOL
}
The macro generates:
// Generated parameter struct (only for user parameters)
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct CheckSolBalanceArgs {
address: String, // ApplicationContext is excluded from Args struct
}
// Generated Tool implementation with automatic context injection
#[async_trait::async_trait]
impl riglr_core::Tool for CheckSolBalanceTool {
async fn execute(
&self,
params: serde_json::Value,
context: &ApplicationContext // Context automatically passed here
) -> Result<JobResult, ToolError> {
let args: CheckSolBalanceArgs = serde_json::from_value(params)?;
// Call original function with injected context + user params
let result = check_sol_balance(context, args.address).await?;
Ok(JobResult::Success {
value: serde_json::to_value(result)?,
tx_hash: None
})
}
fn name(&self) -> &str {
"check_sol_balance"
}
fn description(&self) -> &str {
"Checks the SOL balance for a given Solana address"
}
}
This section shows exactly what code the #[tool] macro generates for you. Understanding this helps you debug issues and understand the macro's behavior.
use riglr_macros::tool;
use riglr_core::{ToolError, provider::ApplicationContext};
/// Transfer SOL tokens between accounts
#[tool]
async fn transfer_sol(
context: &ApplicationContext, // This will be injected
to_address: String, // These become the Args struct
amount: f64,
) -> Result<String, ToolError> {
let client = context.solana_client()?;
let tx_hash = client.transfer(&to_address, amount).await?;
Ok(tx_hash)
}
// 1. Args struct for user parameters (context excluded)
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct TransferSolArgs {
to_address: String,
amount: f64,
}
// 2. Tool struct
struct TransferSolTool;
// 3. Tool trait implementation
#[async_trait::async_trait]
impl riglr_core::Tool for TransferSolTool {
async fn execute(
&self,
params: serde_json::Value,
context: &ApplicationContext, // Context passed by framework
) -> Result<riglr_core::JobResult, ToolError> {
// Deserialize user parameters
let args: TransferSolArgs = serde_json::from_value(params)
.map_err(|e| ToolError::invalid_input_with_source(
e,
"Failed to parse parameters"
))?;
// Call the original function with injected context
let result = transfer_sol(
context, // Injected from execute
args.to_address, // From deserialized args
args.amount,
).await?;
// Package the result
Ok(riglr_core::JobResult::Success {
value: serde_json::to_value(result)?,
tx_hash: None,
})
}
fn name(&self) -> &str {
"transfer_sol"
}
fn description(&self) -> &str {
"Transfer SOL tokens between accounts"
}
}
// 4. Factory function to create tool instances
pub fn transfer_sol_tool() -> Arc<dyn riglr_core::Tool> {
Arc::new(TransferSolTool)
}
The macro automatically identifies parameters by their type signature:
&ApplicationContext, &riglr_core::provider::ApplicationContext, or ending in ::ApplicationContext is automatically detectedThe macro recognizes these type patterns:
context: &ApplicationContextctx: &riglr_core::provider::ApplicationContextapp_context: &my_crate::provider::ApplicationContextYou can override the description with an attribute:
use riglr_core::{ToolError, provider::ApplicationContext};
#[tool(description = "Gets current ETH price in USD from external API")]
async fn get_eth_price(
context: &ApplicationContext,
) -> Result<f64, ToolError> {
let web_client = context.web_client()?;
let price_data = web_client.get_eth_price().await?;
Ok(price_data.usd)
}
For more complex tools, implement them as structs. The macro handles context injection automatically:
use riglr_core::{Tool, JobResult, ToolError, provider::ApplicationContext};
use riglr_macros::tool;
use serde::{Serialize, Deserialize};
/// A comprehensive wallet management tool
#[derive(Serialize, Deserialize, schemars::JsonSchema, Clone)]
#[tool(description = "Manages wallet operations across multiple chains")]
struct WalletManager {
operation: String,
amount: Option<f64>,
address: Option<String>,
}
impl WalletManager {
/// Execute the wallet operation with the provided context
pub async fn execute(&self, context: &ApplicationContext) -> Result<String, ToolError> {
match self.operation.as_str() {
"balance" => {
let address = self.address.as_ref()
.ok_or_else(|| ToolError::invalid_input_string("Address required for balance check"))?;
if let Ok(solana_client) = context.solana_client() {
let balance = self.check_solana_balance(context, address).await?;
Ok(format!("Solana balance: {} SOL", balance))
} else if let Ok(evm_client) = context.evm_client() {
let balance = self.check_evm_balance(context, address).await?;
Ok(format!("EVM balance: {} ETH", balance))
} else {
Err(ToolError::permanent_string("No supported blockchain client available"))
}
}
"transfer" => {
let amount = self.amount
.ok_or_else(|| ToolError::invalid_input_string("Amount required for transfer"))?;
let to_address = self.address.as_ref()
.ok_or_else(|| ToolError::invalid_input_string("Destination address required"))?;
let tx_hash = self.transfer_funds(context, to_address, amount).await?;
Ok(format!("Transferred {} - tx: {}", amount, tx_hash))
}
_ => Err(ToolError::invalid_input_string(format!("Unknown operation: {}", self.operation)))
}
}
async fn check_solana_balance(&self, context: &ApplicationContext, address: &str) -> Result<f64, ToolError> {
let client = context.solana_client()?;
// Implementation for Solana balance check using context's client
Ok(1.5)
}
async fn check_evm_balance(&self, context: &ApplicationContext, address: &str) -> Result<f64, ToolError> {
let client = context.evm_client()?;
// Implementation for EVM balance check using context's client
Ok(0.25)
}
async fn transfer_funds(&self, context: &ApplicationContext, to_address: &str, amount: f64) -> Result<String, ToolError> {
if let Ok(solana_client) = context.solana_client() {
// Perform Solana transfer
Ok("solana_tx_hash_123".to_string())
} else if let Ok(evm_client) = context.evm_client() {
// Perform EVM transfer
Ok("0xevm_tx_hash_456".to_string())
} else {
Err(ToolError::permanent_string("No supported blockchain client available"))
}
}
}
The #[tool] macro automatically maps function errors to ToolError types. You can use the enhanced error handling:
use riglr_core::{ToolError, provider::ApplicationContext};
#[tool]
async fn transfer_tokens(
context: &ApplicationContext,
to_address: String,
amount: f64,
token_mint: String,
) -> Result<String, ToolError> {
// Input validation
if amount <= 0.0 {
return Err(ToolError::invalid_input_string("Amount must be positive"));
}
// Access blockchain clients through context
let solana_client = context.solana_client()
.map_err(|_| ToolError::permanent_string("Solana client not available for token transfers"))?;
// Simulate network error that should be retried
if let Err(e) = perform_transfer(context, &to_address, amount, &token_mint).await {
return Err(ToolError::retriable_with_source(e, "Failed to submit transaction"));
}
// Simulate rate limiting with proper retry delay
if is_rate_limited(context).await {
return Err(ToolError::rate_limited_string_with_delay(
"API rate limit exceeded",
Some(std::time::Duration::from_secs(60))
));
}
Ok("transaction_hash_123".to_string())
}
async fn perform_transfer(
context: &ApplicationContext,
to: &str,
amount: f64,
token: &str
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = context.solana_client()?;
// Implementation would perform actual transfer using context's client
Ok(())
}
async fn is_rate_limited(context: &ApplicationContext) -> bool {
// Check if we're being rate limited via context's rate limiter
false
}
Tools automatically have access to blockchain clients and other services through ApplicationContext:
use riglr_core::{ToolError, provider::ApplicationContext};
#[tool]
async fn multi_chain_balance(
context: &ApplicationContext,
address: String,
) -> Result<serde_json::Value, ToolError> {
let mut balances = serde_json::Map::new();
// Try to get Solana balance
if let Ok(solana_client) = context.solana_client() {
match get_solana_balance(context, &address).await {
Ok(sol_balance) => {
balances.insert("solana".to_string(), serde_json::json!({
"balance": sol_balance,
"currency": "SOL"
}));
}
Err(e) => {
// Log error but continue to other chains
eprintln!("Failed to get Solana balance: {}", e);
}
}
}
// Try to get EVM balance
if let Ok(evm_client) = context.evm_client() {
match get_ethereum_balance(context, &address).await {
Ok(eth_balance) => {
balances.insert("ethereum".to_string(), serde_json::json!({
"balance": eth_balance,
"currency": "ETH"
}));
}
Err(e) => {
eprintln!("Failed to get Ethereum balance: {}", e);
}
}
}
if balances.is_empty() {
return Err(ToolError::permanent_string("No supported blockchain clients available"));
}
Ok(serde_json::Value::Object(balances))
}
async fn get_solana_balance(context: &ApplicationContext, address: &str) -> Result<f64, ToolError> {
let client = context.solana_client()?;
// Implementation using the Solana client from context
Ok(1.5)
}
async fn get_ethereum_balance(context: &ApplicationContext, address: &str) -> Result<f64, ToolError> {
let client = context.evm_client()?;
// Implementation using the EVM client from context
Ok(0.25)
}
The new architecture provides several advantages over the previous #[context] attribute approach:
#[context]_with_context patterns are no longer neededThe macro extracts descriptions in priority order:
description = "...": Explicit description override/// This is the primary description from doc comments
/// Additional documentation here is ignored for the tool description
#[tool]
async fn documented_tool() -> Result<String, ToolError> {
Ok("result".to_string())
}
#[tool(description = "This explicit description overrides doc comments")]
/// This doc comment will be ignored for tool description
async fn explicit_description_tool() -> Result<String, ToolError> {
Ok("result".to_string())
}
Tools generated by the macro integrate seamlessly with riglr-core and the rig framework:
use riglr_core::{ToolWorker, ExecutionConfig, Job, provider::ApplicationContext};
use riglr_macros::tool;
use std::sync::Arc;
#[tool]
async fn example_tool(
context: &ApplicationContext,
param: String,
) -> Result<String, riglr_core::ToolError> {
// Use context to access services
let web_client = context.web_client()?;
Ok(format!("Processed: {}", param))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create application context with necessary clients
let context = ApplicationContext::builder()
.with_web_client()
.build()?;
// Create worker with context
let worker = ToolWorker::new(ExecutionConfig::default(), context);
// Register the generated tool using the factory function
worker.register_tool(example_tool_tool()).await;
// Create and process a job - context is automatically injected
let job = Job::new(
"example_tool",
&serde_json::json!({"param": "test data"}),
3
)?;
let result = worker.process_job(job).await?;
println!("Result: {:?}", result);
Ok(())
}
The generated parameter structs support full serde validation. Parameters are automatically validated when the tool is executed:
use serde::{Deserialize, Serialize};
use riglr_core::{ToolError, provider::ApplicationContext};
use riglr_macros::tool;
#[tool]
async fn transfer_with_validation(
context: &ApplicationContext,
#[serde(alias = "to")]
recipient_address: String,
#[serde(deserialize_with = "validate_positive_amount")]
amount: f64,
#[serde(default = "default_slippage")]
slippage_bps: u16,
) -> Result<String, ToolError> {
// Parameters are already validated by serde before this function is called
let solana_client = context.solana_client()?;
// Perform transfer with validated parameters
let tx_hash = solana_client.transfer(
&recipient_address,
amount,
slippage_bps
).await?;
Ok(format!(
"Transferred {} to {} with {}bps slippage - tx: {}",
amount,
recipient_address,
slippage_bps,
tx_hash
))
}
fn validate_positive_amount<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: serde::Deserializer<'de>,
{
let amount = f64::deserialize(deserializer)?;
if amount <= 0.0 {
return Err(serde::de::Error::custom("Amount must be positive"));
}
Ok(amount)
}
fn default_slippage() -> u16 {
100 // 1% default slippage
}
ApplicationContext as the first parameter for tools that need external services#[serde(default)] where applicableREQUIRED: All tool functions must return error types that implement Into<ToolError>. The #[tool] macro no longer provides automatic conversion for standard library error types like std::io::Error or reqwest::Error. You must define custom error enums using the #[derive(IntoToolError)] macro or manually implement From<YourError> for ToolError.
Use the structured error types for better retry logic:
use riglr_core::{ToolError, provider::ApplicationContext};
#[tool]
async fn robust_tool(
context: &ApplicationContext,
param: String,
) -> Result<String, ToolError> {
// Validate input
if param.is_empty() {
return Err(ToolError::invalid_input_string("Parameter cannot be empty"));
}
// Handle permanent errors (don't retry)
if !has_required_permissions(context).await {
return Err(ToolError::permanent_string("Insufficient permissions"));
}
// Handle retriable errors (retry with backoff)
match make_network_call(context, ¶m).await {
Ok(result) => Ok(result),
Err(e) if is_network_error(&e) => {
Err(ToolError::retriable_with_source(e, "Network call failed"))
}
Err(e) if is_rate_limited(&e) => {
Err(ToolError::rate_limited_string_with_delay(
"API rate limited",
Some(std::time::Duration::from_secs(30))
))
}
Err(e) => Err(ToolError::permanent_with_source(e, "Unexpected error"))
}
}
async fn has_required_permissions(context: &ApplicationContext) -> bool {
// Check permissions using context services
true
}
async fn make_network_call(
context: &ApplicationContext,
param: &str
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let client = context.web_client()?;
// Make network call using context's HTTP client
Ok(param.to_string())
}
fn is_network_error(_e: &dyn std::error::Error) -> bool { false }
fn is_rate_limited(_e: &dyn std::error::Error) -> bool { false }
For production applications, define custom error types with the #[derive(IntoToolError)] macro when appropriate:
use riglr_core::ToolError;
use riglr_macros::{tool, IntoToolError};
use thiserror::Error;
#[derive(Debug, Error, IntoToolError)]
enum MyToolError {
#[error("Invalid input: {reason}")]
#[permanent] // Won't be retried
InvalidInput { reason: String },
#[error("Network timeout after {attempts} attempts")]
#[retriable] // Will be retried with exponential backoff
NetworkTimeout { attempts: u32 },
#[error("API rate limit exceeded")]
#[rate_limited(retry_after = 60)] // Retry after 60 seconds
RateLimited,
#[error("Blockchain error: {0}")]
#[retriable]
BlockchainError(String),
}
#[tool]
async fn production_ready_tool(
input: String,
retries: u32,
) -> Result<String, MyToolError> {
// Validation returns permanent errors
if input.is_empty() {
return Err(MyToolError::InvalidInput {
reason: "Input cannot be empty".to_string(),
});
}
// Network operations return retriable errors
for attempt in 1..=retries {
match perform_operation(&input).await {
Ok(result) => return Ok(result),
Err(_) if attempt == retries => {
return Err(MyToolError::NetworkTimeout { attempts: retries });
}
Err(_) => continue,
}
}
Err(MyToolError::NetworkTimeout { attempts: retries })
}
This approach provides:
Some error types require more complex logic than the IntoToolError macro can provide. For example, SolanaToolError in riglr-solana-tools uses a manual From<SolanaToolError> for ToolError implementation because it needs:
ToolError variantsExample of when manual implementation is needed:
// SolanaToolError requires manual implementation due to complex requirements
impl From<SolanaToolError> for ToolError {
fn from(err: SolanaToolError) -> Self {
// Passthrough ToolError without re-wrapping
if let SolanaToolError::ToolError(tool_err) = err {
return tool_err;
}
// Dynamic rate-limit detection based on message content
if err.is_rate_limited() {
return ToolError::rate_limited_with_source(err, "Solana operation", err.retry_delay());
}
// Complex retriability logic based on error type
if err.is_retriable() {
return ToolError::retriable_with_source(err, "Solana operation");
}
// Preserve source for downcasting
ToolError::permanent_with_source(err, "Solana operation")
}
}
Use the IntoToolError macro for simpler error enums with static classification. Use manual implementation when you need runtime logic or special handling.
Always check client availability and handle graceful fallbacks:
#[tool]
async fn chain_specific_tool(
context: &ApplicationContext,
operation: String,
) -> Result<String, ToolError> {
match operation.as_str() {
"solana_op" => {
let solana_client = context.solana_client()
.map_err(|_| ToolError::permanent_string("Solana client not available"))?;
// Perform Solana operation
let result = solana_client.get_latest_blockhash().await?;
Ok(format!("Solana operation completed: {}", result))
}
"evm_op" => {
let evm_client = context.evm_client()
.map_err(|_| ToolError::permanent_string("EVM client not available"))?;
// Perform EVM operation
let block_number = evm_client.get_block_number().await?;
Ok(format!("EVM operation completed at block: {}", block_number))
}
"web_op" => {
let web_client = context.web_client()
.map_err(|_| ToolError::permanent_string("Web client not available"))?;
// Perform web operation
let response = web_client.get("https://api.example.com").await?;
Ok(format!("Web operation completed: {}", response.status()))
}
_ => Err(ToolError::invalid_input_string("Unknown operation"))
}
}
Design tools that gracefully adapt to available services:
#[tool]
async fn adaptive_balance_check(
context: &ApplicationContext,
address: String,
) -> Result<serde_json::Value, ToolError> {
let mut results = serde_json::Map::new();
// Try each available blockchain client
if let Ok(solana_client) = context.solana_client() {
match solana_client.get_balance(&address).await {
Ok(balance) => {
results.insert("solana".to_string(), serde_json::json!({
"balance": balance,
"status": "success"
}));
}
Err(e) => {
results.insert("solana".to_string(), serde_json::json!({
"error": e.to_string(),
"status": "error"
}));
}
}
}
if let Ok(evm_client) = context.evm_client() {
match evm_client.get_balance(&address).await {
Ok(balance) => {
results.insert("ethereum".to_string(), serde_json::json!({
"balance": balance.to_string(),
"status": "success"
}));
}
Err(e) => {
results.insert("ethereum".to_string(), serde_json::json!({
"error": e.to_string(),
"status": "error"
}));
}
}
}
if results.is_empty() {
return Err(ToolError::permanent_string("No blockchain clients available"));
}
Ok(serde_json::Value::Object(results))
}
If you're upgrading from a previous version that used #[context] attributes or SignerContext, here's how to migrate:
#[tool]
async fn old_transfer(
#[context] _ctx: &SignerContext, // ❌ Old way
to_address: String,
amount: f64,
) -> Result<String, ToolError> {
let signer = SignerContext::current().await?; // ❌ Old way
// ... implementation
}
#[tool]
async fn new_transfer(
context: &ApplicationContext, // ✅ New way - automatically detected
to_address: String,
amount: f64,
) -> Result<String, ToolError> {
let client = context.solana_client()?; // ✅ New way - use context directly
// ... implementation
}
#[context] attributes - they're no longer neededSignerContext::current().await? with direct context usageApplicationContext parameter to function signaturescontext.solana_client(), context.evm_client(), etc.Add to your Cargo.toml:
[dependencies]
riglr-macros = "0.3.0"
riglr-core = "0.3.0"
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
async-trait = "0.1"
use riglr_macros::tool;
use riglr_core::{ToolError, provider::ApplicationContext};
#[tool]
async fn hello_world(
context: &ApplicationContext,
name: String,
) -> Result<String, ToolError> {
Ok(format!("Hello, {}!", name))
}
fn main() {
// Your tool is ready to use!
let tool = hello_world_tool();
println!("Created tool: {}", tool.name());
}
See the examples/ directory in the riglr-core crate for complete working examples using the #[tool] macro with the new ApplicationContext architecture.
Result<T, E> where E: Into<ToolError>. Standard library errors like std::io::Error do not implement this automatically - you must wrap them in custom error types.ApplicationContext parameter is required for dependency injectionSerialize + Deserialize + JsonSchemacargo test --workspace
MIT License - see LICENSE file for details