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}