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