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}