synwire-derive: Proc-Macros and When to Use Them

synwire-derive provides two proc-macros that eliminate boilerplate for the most common patterns: #[tool] for defining tools and #[derive(State)] for typed graph state.

📖 Rust note: The #[derive] attribute and attribute macros like #[tool] are procedural macros — Rust code that runs at compile time, reads your source code as input, and outputs new source code. They are zero-cost: the generated code is identical to what you would write by hand.

#[tool]: Defining tools from async functions

Apply #[tool] to an async fn to generate a StructuredTool with an automatically-derived JSON Schema.

#![allow(unused)]
fn main() {
use synwire_derive::tool;
use schemars::JsonSchema;
use serde::Deserialize;

/// Calculate the area of a rectangle.
/// The tool description is taken from this doc comment.
#[tool]
async fn rectangle_area(input: RectangleInput) -> anyhow::Result<String> {
    let area = input.width * input.height;
    Ok(format!("{area} square units"))
}

#[derive(Deserialize, JsonSchema)]
struct RectangleInput {
    /// Width of the rectangle in units.
    width: f64,
    /// Height of the rectangle in units.
    height: f64,
    /// Unit label (optional, defaults to "m").
    unit: Option<String>,
}

// The macro generates rectangle_area_tool():
// let tool = rectangle_area_tool()?;
// let result = tool.call(serde_json::json!({ "width": 5.0, "height": 3.0 })).await?;
// assert_eq!(result.text(), "15 square units");
}

How the schema is derived

The macro calls schemars::JsonSchema on the input type. This means:

  • String / &str"string"
  • Integer types → "integer"
  • Float types → "number"
  • bool"boolean"
  • Vec<T>"array"
  • Option<T> → field is marked not required
  • Structs → "object" with properties
  • #[schemars(description = "...")] attribute → field description in schema
  • #[serde(rename = "...")] attribute → renamed key in schema

#[derive(State)]: Typed graph state

Apply #[derive(State)] to a struct to generate the State trait implementation for StateGraph.

#![allow(unused)]
fn main() {
use synwire_derive::State;
use serde::{Serialize, Deserialize};

#[derive(State, Clone, Debug, Default, Serialize, Deserialize)]
struct ConversationState {
    /// Message history — Topic channel appends each new message.
    #[reducer(topic)]
    messages: Vec<String>,

    /// Current processing step — LastValue channel overwrites each update.
    #[reducer(last_value)]
    current_step: String,

    /// Fields with no attribute default to LastValue.
    response_count: u32,
}
}

📖 Rust note: Generic type parameters like <S> in StateGraph<S> let the graph work with any State-implementing type while retaining type safety. The #[derive(State)] macro generates the implementation for your specific struct.

Field attributes and their channels

AttributeChannelBehaviour
#[reducer(topic)]TopicAppends; accumulates each update (message history, event logs)
#[reducer(last_value)]LastValueOverwrites on each write (default; use for current node, flags)
(none)LastValueDefaults to LastValue

When to use macros vs manual implementation

Use macrosUse manual impl
Tool parameters map cleanly to a Rust structTool schema is dynamic or variadic
State fields have clear LastValue or Topic semanticsState needs BinaryOperator or NamedBarrier channels
Proc-macro error messages are clear enoughYou need better diagnostics during early development
90% of casesComplex edge cases

Dependency requirement

Your parameter types must implement schemars::JsonSchema. Add to Cargo.toml:

[dependencies]
schemars = { version = "0.8", features = ["derive"] }
serde = { version = "1", features = ["derive"] }

See also