Skip to main content

aither_derive/
lib.rs

1//! # aither-derive
2//!
3//! Procedural macros for converting Rust functions into AI tools that can be called by language models.
4//!
5//! This crate provides the `#[tool]` attribute macro that automatically generates the necessary
6//! boilerplate code to make your async functions callable by AI models through the `aither` framework.
7//!
8//! ## Quick Start
9//!
10//! Transform any async function into an AI tool by adding the `#[tool]` attribute.
11//! The tool description is the function's rustdoc, or, when the function has
12//! none, the rustdoc on its single Args struct:
13//!
14//! ```rust
15//! use aither::Result;
16//! use aither_derive::tool;
17//! use schemars::JsonSchema;
18//! use serde::Deserialize;
19//!
20//! /// Get the current UTC time.
21//! #[derive(JsonSchema, Deserialize)]
22//! pub struct GetTimeArgs;
23//!
24//! #[tool]
25//! pub async fn get_time(_args: GetTimeArgs) -> Result<&'static str> {
26//!     Ok("2023-10-01T12:00:00Z")
27//! }
28//! ```
29//!
30//! ## Function Patterns
31//!
32//! ### Simple Parameters
33//!
34//! ```rust
35//! use schemars::JsonSchema;
36//! use serde::Deserialize;
37//!
38//! /// Search the web for content.
39//! #[derive(JsonSchema, Deserialize)]
40//! pub struct SearchArgs {
41//!     pub keywords: Vec<String>,
42//!     pub limit: u32,
43//! }
44//!
45//! #[tool]
46//! pub async fn search(args: SearchArgs) -> Result<String> {
47//!     Ok(format!("No results for {}.", args.keywords.join(", ")))
48//! }
49//! ```
50//!
51//! ### Complex Parameters with Documentation
52//!
53//! ```rust
54//! use schemars::JsonSchema;
55//! use serde::Deserialize;
56//!
57//! /// Generate an image from a text prompt.
58//! #[derive(Debug, JsonSchema, Deserialize)]
59//! pub struct ImageArgs {
60//!     /// The text prompt for image generation
61//!     pub prompt: String,
62//!     /// Image width in pixels
63//!     #[serde(default = "default_width")]
64//!     pub width: u32,
65//!     /// Image height in pixels
66//!     #[serde(default = "default_height")]
67//!     pub height: u32,
68//! }
69//!
70//! fn default_width() -> u32 { 512 }
71//! fn default_height() -> u32 { 512 }
72//!
73//! #[tool]
74//! pub async fn generate_image(args: ImageArgs) -> Result<String> {
75//!     Ok(format!("Generated image: {}", args.prompt))
76//! }
77//! ```
78//!
79//! ### Reporting Progress
80//!
81//! A parameter of type `ToolContext` receives the call's context instead of a
82//! model-supplied argument; it is left out of the argument schema. Report
83//! progress through it — a no-op when the caller does not listen.
84//!
85//! ```rust
86//! use aither::Result;
87//! use aither::llm::ToolContext;
88//! use aither::llm::tool::Progress;
89//! use schemars::JsonSchema;
90//! use serde::Deserialize;
91//!
92//! /// Index the given documents.
93//! #[derive(JsonSchema, Deserialize)]
94//! pub struct IndexArgs {
95//!     pub documents: Vec<String>,
96//! }
97//!
98//! #[tool]
99//! pub async fn index(args: IndexArgs, mut cx: ToolContext) -> Result<String> {
100//!     for (done, _document) in args.documents.iter().enumerate() {
101//!         cx.report_progress(Progress::new(done as f64 + 1.0)).await?;
102//!     }
103//!     Ok(format!("Indexed {} documents.", args.documents.len()))
104//! }
105//! ```
106//!
107//! ## Requirements
108//!
109//! - Functions must be `async`
110//! - Return type must be `Result<T>` where `T: IntoToolResult`
111//! - Parameters must implement `serde::Deserialize` and `schemars::JsonSchema`
112//! - No `self` parameters (static functions only)
113//! - No lifetime or generic parameters
114
115use convert_case::{Case, Casing};
116use proc_macro::TokenStream;
117use quote::{format_ident, quote};
118use syn::{
119    FnArg, Ident, ItemFn, LitStr, Token, Type, Visibility,
120    parse::{Parse, ParseStream},
121    parse_macro_input, parse_quote,
122};
123
124/// Arguments for the `#[tool]` attribute macro
125struct ToolArgs {
126    rename: Option<String>,
127}
128
129impl Parse for ToolArgs {
130    /// Parse the arguments from the `#[tool(...)]` attribute.
131    ///
132    /// Supports:
133    /// - `rename = "..."` (optional): Custom name for the tool (defaults to function name)
134    fn parse(input: ParseStream) -> syn::Result<Self> {
135        let mut rename = None;
136
137        while !input.is_empty() {
138            let ident: Ident = input.parse()?;
139            let _: Token![=] = input.parse()?;
140            let value: LitStr = input.parse()?;
141
142            match ident.to_string().as_str() {
143                "rename" => rename = Some(value.value()),
144                _ => {
145                    return Err(syn::Error::new_spanned(
146                        ident,
147                        "unknown attribute. Supported: rename",
148                    ));
149                }
150            }
151
152            if input.peek(Token![,]) {
153                let _: Token![,] = input.parse()?;
154            }
155        }
156
157        Ok(Self { rename })
158    }
159}
160
161/// Converts an async function into an AI tool that can be called by language models.
162///
163/// This procedural macro generates the necessary boilerplate code to make your function
164/// callable through the `aither::llm::Tool` trait.
165///
166/// The tool description is the function's rustdoc. A function without one
167/// falls back to the rustdoc on its single Args struct, which
168/// `schemars::JsonSchema` records.
169///
170/// # Arguments
171///
172/// - `rename` (optional): A custom name for the tool. If not provided, uses the function name.
173///
174/// # Examples
175///
176/// ## Basic Usage
177///
178/// ```rust
179/// use aither::Result;
180/// use aither_derive::tool;
181/// use schemars::JsonSchema;
182/// use serde::Deserialize;
183///
184/// /// Get the current system time.
185/// #[derive(JsonSchema, Deserialize)]
186/// pub struct CurrentTimeArgs;
187///
188/// #[tool]
189/// pub async fn current_time(_args: CurrentTimeArgs) -> Result<String> {
190///     Ok(chrono::Utc::now().to_rfc3339())
191/// }
192/// ```
193///
194/// ## With Parameters
195///
196/// ```rust
197/// use schemars::JsonSchema;
198/// use serde::Deserialize;
199///
200/// /// Send an email to a recipient.
201/// #[derive(JsonSchema, Deserialize)]
202/// pub struct EmailRequest {
203///     /// Recipient email address
204///     pub to: String,
205///     /// Email subject line
206///     pub subject: String,
207///     /// Email body content
208///     pub body: String,
209/// }
210///
211/// #[tool]
212/// pub async fn send_email(request: EmailRequest) -> Result<String> {
213///     Ok(format!("Email sent to {}", request.to))
214/// }
215/// ```
216///
217/// ## With Custom Name
218///
219/// ```rust
220/// /// Perform complex mathematical calculations.
221/// #[derive(JsonSchema, Deserialize)]
222/// pub struct CalcArgs {
223///     pub expression: String,
224/// }
225///
226/// #[tool(rename = "calculator")]
227/// pub async fn complex_math_function(args: CalcArgs) -> Result<String> {
228///     Ok("42".to_string())
229/// }
230/// ```
231///
232/// # Generated Code
233///
234/// For a function named `search`, the macro generates:
235///
236/// 1. A `SearchArgs` struct (if the function has multiple parameters)
237/// 2. A `Search` struct that implements `aither::llm::Tool`
238/// 3. All necessary trait implementations for JSON schema generation and deserialization
239///
240/// # Requirements
241///
242/// - Function must be `async`
243/// - Return type must be `Result<T>` where `T: IntoToolResult`
244/// - Parameters must implement `serde::Deserialize` and `schemars::JsonSchema`
245/// - No `self` parameters (only free functions are supported)
246/// - No lifetime parameters or generics
247///
248/// # Errors
249///
250/// The macro will produce compile-time errors if:
251/// - The function is not async
252/// - The function has `self` parameters
253/// - The function has more than the supported number of parameters
254/// - Required attributes are missing
255#[proc_macro_attribute]
256pub fn tool(args: TokenStream, input: TokenStream) -> TokenStream {
257    let args = parse_macro_input!(args as ToolArgs);
258    let input_fn = parse_macro_input!(input as ItemFn);
259
260    match tool_impl(args, input_fn) {
261        Ok(tokens) => tokens.into(),
262        Err(err) => err.to_compile_error().into(),
263    }
264}
265
266/// Implementation details for the `#[tool]` macro.
267///
268/// This function performs the actual code generation, transforming the annotated async function
269/// into a struct that implements the `Tool` trait.
270fn tool_impl(args: ToolArgs, input_fn: ItemFn) -> syn::Result<proc_macro2::TokenStream> {
271    let fn_name = &input_fn.sig.ident;
272    let tool_name = args.rename.unwrap_or_else(|| fn_name.to_string());
273    let fn_vis = &input_fn.vis;
274
275    let tool_struct_name = format_ident!("{}", fn_name.to_string().to_case(Case::Pascal));
276
277    // The function's rustdoc describes the tool. Without it the description
278    // falls back to the rustdoc on the arguments type, which only a
279    // single-parameter tool has.
280    let description = doc_text(&input_fn.attrs).map(|text| {
281        quote! {
282            fn description(&self) -> ::aither::__hidden::CowStr {
283                #text.into()
284            }
285        }
286    });
287
288    // The context parameter, if any, is not an argument the model fills in.
289    let (context_position, data_inputs) = split_context_parameter(&input_fn.sig.inputs)?;
290
291    // Analyze function signature
292    let AnalyzedArgs {
293        args_type,
294        params,
295        stream,
296    } = analyze_function_args(fn_vis, &tool_struct_name, &data_inputs)?;
297
298    if input_fn.sig.asyncness.is_none() {
299        return Err(syn::Error::new_spanned(
300            input_fn.sig,
301            "Tool functions must be async",
302        ));
303    }
304
305    // Call the function with its parameters in declaration order, the
306    // context in the position the function declared it.
307    let mut call_args: Vec<proc_macro2::TokenStream> =
308        params.iter().map(|param| quote! { #param }).collect();
309    if let Some(position) = context_position {
310        call_args.insert(position, quote! { cx });
311    }
312    let call_expr = quote! { #fn_name(#(#call_args),*).await };
313
314    let extractor = if params.len() <= 1 {
315        quote! {}
316    } else {
317        quote! { let Self::Arguments { #(#params),* } = args; }
318    };
319
320    let args_binding = if params.is_empty() {
321        quote! { _args }
322    } else {
323        quote! { args }
324    };
325    let context_binding = if context_position.is_some() {
326        quote! { cx }
327    } else {
328        quote! { _cx }
329    };
330
331    let expanded = quote! {
332        #input_fn
333
334        #stream
335
336
337        #[derive(::core::default::Default,::core::fmt::Debug)]
338        #fn_vis struct #tool_struct_name;
339
340        impl ::aither::llm::Tool for #tool_struct_name {
341            fn name(&self) -> ::aither::__hidden::CowStr {
342                #tool_name.into()
343            }
344            #description
345            type Arguments = #args_type;
346            type Res = ::aither::llm::ToolResult;
347
348            async fn call(
349                &self,
350                #args_binding: Self::Arguments,
351                #context_binding: ::aither::llm::ToolContext,
352            ) -> ::aither::Result<Self::Res> {
353                #extractor
354                ::aither::llm::IntoToolResult::into_tool_result(#call_expr)
355            }
356        }
357    };
358
359    Ok(expanded)
360}
361
362/// The text of the `///` comments in `attrs`, one line per attribute with the
363/// single leading space rustdoc strips removed, or `None` when there are none.
364fn doc_text(attrs: &[syn::Attribute]) -> Option<String> {
365    let lines: Vec<String> = attrs
366        .iter()
367        .filter(|attr| attr.path().is_ident("doc"))
368        .filter_map(|attr| match &attr.meta {
369            syn::Meta::NameValue(syn::MetaNameValue {
370                value:
371                    syn::Expr::Lit(syn::ExprLit {
372                        lit: syn::Lit::Str(text),
373                        ..
374                    }),
375                ..
376            }) => Some(text.value()),
377            _ => None,
378        })
379        .map(|line| {
380            line.strip_prefix(' ')
381                .map_or_else(|| line.clone(), str::to_owned)
382        })
383        .collect();
384    let text = lines.join("\n");
385    let text = text.trim();
386    (!text.is_empty()).then(|| text.to_owned())
387}
388
389/// Separates the call's `ToolContext` parameter from the model-facing ones.
390///
391/// A parameter is the context when its type is a path ending in
392/// `ToolContext`; a proc macro sees tokens, not resolved types, so the name is
393/// what identifies it. Returns the context's position among all parameters
394/// and the remaining parameters. More than one context parameter is an error.
395fn split_context_parameter(
396    inputs: &syn::punctuated::Punctuated<FnArg, syn::Token![,]>,
397) -> syn::Result<(
398    Option<usize>,
399    syn::punctuated::Punctuated<FnArg, syn::Token![,]>,
400)> {
401    let mut position = None;
402    let mut data = syn::punctuated::Punctuated::new();
403    for (index, input) in inputs.iter().enumerate() {
404        if is_context_parameter(input) {
405            if position.is_some() {
406                return Err(syn::Error::new_spanned(
407                    input,
408                    "a tool function takes at most one `ToolContext` parameter",
409                ));
410            }
411            position = Some(index);
412        } else {
413            data.push(input.clone());
414        }
415    }
416    Ok((position, data))
417}
418
419fn is_context_parameter(input: &FnArg) -> bool {
420    let FnArg::Typed(pat_type) = input else {
421        return false;
422    };
423    let Type::Path(path) = pat_type.ty.as_ref() else {
424        return false;
425    };
426    path.qself.is_none()
427        && path
428            .path
429            .segments
430            .last()
431            .is_some_and(|segment| segment.ident == "ToolContext")
432}
433
434/// Container for analyzed function arguments and generated types.
435struct AnalyzedArgs {
436    /// The type used for the Tool's Arguments associated type
437    args_type: Type,
438    /// Parameter names extracted from the function signature  
439    params: Vec<Ident>,
440    /// Generated argument struct definition (if needed)
441    stream: proc_macro2::TokenStream,
442}
443
444/// Analyzes function parameters and generates appropriate argument types.
445///
446/// This function handles three cases:
447/// - No parameters: Uses unit type `()`
448/// - Single parameter: Uses the parameter type directly
449/// - Multiple parameters: Generates a new struct with all parameters as fields
450fn analyze_function_args(
451    fn_vis: &Visibility,
452    struct_name: &Ident,
453    inputs: &syn::punctuated::Punctuated<FnArg, syn::Token![,]>,
454) -> syn::Result<AnalyzedArgs> {
455    match inputs.len() {
456        0 => {
457            // No arguments - use unit type
458
459            Ok(AnalyzedArgs {
460                args_type: parse_quote! { () },
461                params: vec![],
462                stream: quote! {},
463            })
464        }
465        1 => {
466            // Single argument
467            if let FnArg::Typed(pat_type) = &inputs[0] {
468                Ok(AnalyzedArgs {
469                    args_type: (*pat_type.ty).clone(),
470                    params: vec![format_ident!("args")],
471                    stream: quote! {},
472                })
473            } else {
474                Err(syn::Error::new_spanned(
475                    &inputs[0],
476                    "self parameters are not supported in tool functions",
477                ))
478            }
479        }
480        _ => {
481            let mut attributes = Vec::new();
482
483            for arg in inputs {
484                if let FnArg::Typed(pat_type) = arg {
485                    let pat = &pat_type.pat;
486                    let ty = &pat_type.ty;
487                    attributes.push(quote! {
488                        #pat: #ty,
489                    });
490                } else {
491                    return Err(syn::Error::new_spanned(
492                        arg,
493                        "self parameters are not supported in tool functions",
494                    ));
495                }
496            }
497
498            let arg_struct_name = format_ident!("{}Args", struct_name);
499
500            let new_type_gen = quote! {
501                #[derive(::schemars::JsonSchema, ::serde::Deserialize,::core::fmt::Debug)]
502                #fn_vis struct #arg_struct_name {
503                    #(
504                        #attributes
505                    )*
506                }
507            };
508
509            let params = inputs
510                .iter()
511                .map(|arg| match arg {
512                    FnArg::Typed(pat_type) => {
513                        let pat = &pat_type.pat;
514                        Ok(format_ident!("{}", quote! {#pat}.to_string()))
515                    }
516                    // A `self` receiver: point at it rather than panicking, so
517                    // the user gets a diagnostic on the offending argument
518                    // instead of "proc macro panicked".
519                    FnArg::Receiver(receiver) => Err(syn::Error::new_spanned(
520                        receiver,
521                        "#[tool] cannot be applied to a method taking `self`; \
522                         use a free function",
523                    )),
524                })
525                .collect::<Result<Vec<_>, syn::Error>>()?;
526
527            Ok(AnalyzedArgs {
528                args_type: parse_quote! { #arg_struct_name },
529                params,
530                stream: new_type_gen,
531            })
532        }
533    }
534}