Skip to main content

agentai_macros/
lib.rs

1use heck::ToUpperCamelCase;
2use proc_macro::TokenStream;
3use proc_macro2::{Span, TokenStream as TokenStream2};
4use quote::{quote, ToTokens};
5use std::collections::HashSet;
6use syn::{
7    parse_macro_input, Error, Expr, FnArg, Ident, ImplItem, ItemImpl, Lit, Meta, MetaNameValue, Pat,
8};
9
10/// # Macro for Generating `ToolBox` Implementations
11///
12/// The `#[toolbox]` attribute macro streamlines the process of implementing the `ToolBox` trait
13/// for a given struct. By applying this macro to an `impl` block, you can designate specific
14/// methods as "tools" that are discoverable and callable.
15///
16/// This macro handles the following:
17/// - **Tool Definition**: It automatically generates metadata for each tool, including its name,
18///   description, and a JSON schema for its parameters.
19/// - **Dispatch Logic**: It creates the necessary logic to dispatch calls to the appropriate tool method.
20///
21/// ## Prerequisites
22///
23/// Ensure your `Cargo.toml` includes the following dependencies:
24///
25/// ```toml
26/// serde = { version = "1.0", features = ["derive"] }
27/// serde_json = "1.0"
28/// schemars = { version = "0.9", features = ["derive"] }
29/// async-trait = "0.1"
30/// ```
31///
32/// You must also import the necessary components from the `agentai::tool` module:
33///
34/// ```ignore
35/// use agentai::tool::{Tool, ToolBox, ToolError, toolbox};
36/// ```
37///
38/// ## Usage Guide
39///
40/// ### 1. Defining Your ToolBox Struct
41///
42/// First, define a struct that will serve as your `ToolBox`. This struct can hold state,
43/// such as API keys or a shared HTTP client, which can be accessed by your tools.
44///
45/// The `impl` block for this struct must be annotated with `#[toolbox]`.
46///
47/// ```ignore
48/// struct MyToolBox {
49///     api_key: String,
50/// }
51///
52/// #[toolbox]
53/// impl MyToolBox {
54///     pub fn new(api_key: String) -> Self {
55///         Self { api_key }
56///     }
57///
58///     // Tool methods will be defined here
59/// }
60/// ```
61///
62/// ### 2. Exposing Methods as Tools with `#[tool]`
63///
64/// To expose a method as a tool, annotate it with the `#[tool]` attribute. This attribute is a marker
65/// and does not need to be imported. Both synchronous and asynchronous methods are supported.
66///
67/// #### 2.1. Default Behavior
68///
69/// - **Tool Name**: The tool's name is inferred from the method's name. It must be unique within the toolbox.
70/// - **Tool Description**: The method's documentation comments (`///` or `#[doc = "..."]`) are used as the tool's description.
71/// - **Parameter Schema**: A JSON schema is automatically generated from the method's parameters.
72///
73/// #### 2.2. Requirements and Limitations
74///
75/// - **Method Receiver**: Exposed tools must be methods that take `&self` as the first argument. Static methods are not supported.
76/// - **Return Type**: The return type must be `ToolResult` which is `Result<String, ToolError>`.
77/// - **Serializable Parameters**: All method parameters must be (de)serializable by `serde`.
78///
79/// ### 3. Advanced Configuration
80///
81/// The `#[tool(...)]` attribute gives you broad control over the configuration of declared tools.
82/// You can change any of the options using `name=value` pairs. The following options are supported:
83/// - `name`: Overrides the default tool name. This name must be unique within the toolbox.
84///
85/// ### 4. Tool Arguments
86/// The tool's schema is generated based on the method's arguments, which is why they must be serializable.
87/// This is primarily syntactic sugar, as all arguments are copied into a new helper structure as serializable fields.
88/// This struct derives `serde::Serialize`, `serde::Deserialize`, and `schemars::JsonSchema` to handle argument
89/// serialization, deserialization, and schema generation.
90///
91/// All attributes for the arguments will be moved from the method implementation to the newly generated arguments structure.
92/// This allows you to not only provide documentation for the purpose of an argument but also to modify its behavior using
93/// `serde` or `schemars` attributes. For more information, refer to the following pages:
94/// - [serde](https://serde.rs/field-attrs.html)
95/// - [schemars](https://graham.cool/schemars/examples/3-schemars_attrs/)
96///
97/// # Examples
98///
99/// ```ignore
100/// use agentai::tool::{Tool, ToolBox, ToolError, toolbox};
101///
102/// struct MyToolBox {
103///     my_field: i32,
104/// }
105///
106/// #[toolbox]
107/// impl MyToolBox {
108///     pub fn new() -> Self {
109///         Self { my_field: 69 }
110///     }
111///
112///     /// This tool demonstrates accessing a field on the struct.
113///     #[tool]
114///     async fn tool_one(&self) -> ToolResult {
115///         Ok(format!("Result from tool one: {}", self.my_field))
116///     }
117///
118///     /// This tool takes a parameter with documentation.
119///     #[tool]
120///     async fn tool_two(&self, #[doc = "The input string."] input: String) -> ToolResult {
121///         Ok(format!("Tool two received: {}", input))
122///     }
123///
124///     /// This tool has an altered name.
125///     #[tool(name = "my_special_tool")]
126///     fn tool_three(
127///         &self,
128///         /// You can use both methods of providing documentation for an argument
129///         value: i32
130///     ) -> ToolResult {
131///         Ok(format!("Result from tool three with special name and value: {}", value))
132///     }
133///
134///     /// This is a sync tool method example.
135///     #[tool]
136///     fn tool_sync(&self) -> ToolResult {
137///          Ok("This is a synchronous tool result".to_string())
138///     }
139///
140///     // This method will not be exposed as a tool because it lacks the #[tool] attribute.
141///     pub fn helper_method(&self) -> i32 {
142///         42
143///     }
144/// }
145/// ```
146///
147/// ## Generated Code
148///
149/// The `#[toolbox]` macro generates the following:
150///
151/// 1.  **Parameter Structs**: For each tool with parameters, a private struct is generated
152///     (e.g., `ToolTwoParams`). These structs derive `serde::Serialize`, `serde::Deserialize`,
153///     and `schemars::JsonSchema` to manage parameter handling and schema generation.
154///
155/// 2.  **`ToolBox` Implementation**: It generates the `impl ToolBox for YourStruct` block.
156///     -   **`tools_definitions`**: This method returns a `Vec<Tool>`, providing the metadata for each exposed tool.
157///     -   **`call_tool`**: This method acts as a dispatcher. It matches the `tool_name`,
158///         deserializes the JSON `parameters` into the corresponding parameter struct,
159///         and invokes the actual method.
160#[proc_macro_attribute]
161pub fn toolbox(_attr: TokenStream, item: TokenStream) -> TokenStream {
162    // Parse the original impl block
163    let mut item_impl = parse_macro_input!(item as ItemImpl);
164
165    let struct_name = &item_impl.self_ty;
166    let struct_ident = match &**struct_name {
167        syn::Type::Path(type_path) => type_path
168            .path
169            .get_ident()
170            .expect("Expected an identifier for the struct"),
171        _ => {
172            return Error::new(
173                Span::call_site(),
174                "toolbox! macro only supports impl blocks for structs",
175            )
176            .to_compile_error()
177            .into()
178        }
179    };
180
181    let mut generated_code = TokenStream2::new();
182    let mut tool_definitions = TokenStream2::new();
183    let mut match_arms = TokenStream2::new();
184
185    // TODO: Maybe we should use BTreeHash to preserve order of tools?
186    let mut found_tools = HashSet::new();
187
188    // Pass 1: Collect information for tool definitions and call dispatch
189    // We iterate over a reference here because we need the original items again in Pass 2
190    for item in item_impl.items.iter_mut() {
191        if let ImplItem::Fn(ref mut method) = item {
192            // Find the #[tool] attribute
193            if let Some(tool_attr) = method
194                .attrs
195                .clone()
196                .iter()
197                .find(|attr| attr.path().is_ident("tool"))
198            {
199                // Remove #[tool] attribute
200                // #[tool] is used only to mark functions that will be converted into tools
201                method.attrs.retain(|attr| !attr.path().is_ident("tool"));
202
203                let fn_name_sig = &method.sig.ident;
204                let fn_name = fn_name_sig.to_string();
205                let mut tool_name = fn_name.clone();
206
207                // Parse the #[tool] attribute for name = "..." using parse_args_with with Meta
208                let mut name_arg_found = false;
209                let parser = syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated;
210                if let Ok(args) = tool_attr.parse_args_with(parser) {
211                    // Iterate over the parsed Meta items to find 'name'. #[tool(name = "...")]
212                    for arg_meta in args {
213                        match arg_meta {
214                            Meta::NameValue(name_value) if name_value.path.is_ident("name") => {
215                                if name_arg_found {
216                                    // Error: Duplicate 'name' argument
217                                    return Error::new_spanned(
218                                        name_value.to_token_stream(),
219                                        "Duplicate 'name' argument in tool attribute",
220                                    )
221                                    .to_compile_error()
222                                    .into();
223                                }
224                                let Expr::Lit(expr_lit) = &name_value.value else {
225                                    // Error: Expected literal value for name
226                                    return Error::new_spanned(
227                                        name_value.value.to_token_stream(),
228                                        "Expected literal value for tool name",
229                                    )
230                                    .to_compile_error()
231                                    .into();
232                                };
233                                let Lit::Str(lit_str) = &expr_lit.lit else {
234                                    // Error: Expected string literal for name
235                                    return Error::new_spanned(
236                                        expr_lit.to_token_stream(),
237                                        "Expected string literal for tool name",
238                                    )
239                                    .to_compile_error()
240                                    .into();
241                                };
242                                tool_name = lit_str.value();
243                                name_arg_found = true;
244                            }
245                            _ => {
246                                // Error: If arguments are present, they must be 'name = "..."'
247                                return Error::new_spanned(
248                                    arg_meta.to_token_stream(),
249                                    "Expected name = \"...\" in tool attribute",
250                                )
251                                .to_compile_error()
252                                .into();
253                            }
254                        };
255                    }
256                }
257
258                // Check for duplicate tool names AFTER determining the final tool_name
259                if !found_tools.insert(tool_name.clone()) {
260                    return Error::new_spanned(
261                        tool_attr.to_token_stream(),
262                        format!("Duplicate tool name found: {tool_name}"),
263                    )
264                    .to_compile_error()
265                    .into();
266                }
267
268                // Extract doc comments for description from #[doc = "..."] attributes (handles /// and /* */) from method
269                let description = method
270                    .attrs
271                    .iter()
272                    .filter_map(|attr| match attr.meta.clone() {
273                        Meta::NameValue(MetaNameValue {
274                            path,
275                            value: Expr::Lit(expr_lit),
276                            ..
277                        }) if path.is_ident("doc") => {
278                            match expr_lit.lit {
279                                Lit::Str(lit_str) => {
280                                    // Remove leading slashes, stars, and whitespace
281                                    Some(
282                                        lit_str
283                                            .value()
284                                            .trim()
285                                            .trim_start_matches(|c: char| {
286                                                c == '/' || c == '*' || c.is_whitespace()
287                                            })
288                                            .to_string(),
289                                    )
290                                }
291                                _ => None, // Not a string literal
292                            }
293                        }
294                        _ => None, // Not a #[doc = ...] attribute or error
295                    })
296                    .collect::<Vec<String>>()
297                    .join("\n");
298
299                let description_token = if description.trim().is_empty() {
300                    quote! { None }
301                } else {
302                    let desc = description.trim().to_string();
303                    quote! { Some(#desc.to_string()) }
304                };
305
306                // Generate parameter struct
307                let params_struct_name = Ident::new(
308                    &format!("{}Params", fn_name.to_upper_camel_case()),
309                    fn_name_sig.span(),
310                );
311                let mut param_fields = TokenStream2::new();
312                let mut param_assignments = TokenStream2::new();
313
314                for arg in method.sig.inputs.iter_mut() {
315                    // self attribute are type FnArg::Receiver()
316                    if let FnArg::Typed(ref mut pat_type) = arg {
317                        // #[doc = "Documentation"]    // < pat_type.attrs
318                        // attribute: Type,            // < pat_type.pat: pat_type.ty
319                        // ...
320                        let ty = pat_type.ty.clone();
321
322                        // Clone all attributes that will be moved to new structure
323                        let attrs = pat_type.attrs.clone();
324
325                        // Clean attributes for tool definition
326                        pat_type.attrs.clear();
327
328                        let Pat::Ident(ref pat_ident) = *pat_type.pat else {
329                            // Handle other patterns if necessary, or return an error
330                            return Error::new_spanned(
331                                pat_type.pat.to_token_stream(),
332                                "Tool function parameters must be simple identifiers",
333                            )
334                            .to_compile_error()
335                            .into();
336                        };
337
338                        let arg_name = &pat_ident.ident;
339                        // TODO: Change pub to pub(crate), this structures will be used only inside generated code
340                        param_fields.extend(quote! {
341                            #(#attrs)* pub #arg_name: #ty,
342                        });
343
344                        param_assignments.extend(quote! {
345                            params.#arg_name,
346                        });
347                    }
348                }
349
350                if !param_fields.is_empty() {
351                    generated_code.extend(quote! {
352                       // Parameters struct for #original_fn_name_str
353                       #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
354                       #[allow(dead_code)]
355                       #[allow(clippy::all)]
356                       struct #params_struct_name {
357                           #param_fields
358                       }
359                    });
360                }
361
362                // Add to tool definitions
363                let schema_token = if param_fields.is_empty() {
364                    quote! { None }
365                } else {
366                    // Use the generated parameter struct name for schemars::schema_for!
367                    // quote! { Some(generate_tool_schema::<#params_struct_name>()) }
368                    quote! {
369                        Some({
370                            let generator = ::schemars::generate::SchemaSettings::draft2020_12().with(|s| {
371                                s.meta_schema = None;
372                            }).into_generator();
373                            generator.into_root_schema_for::<#params_struct_name>().into()
374                        })
375                    }
376                };
377
378                tool_definitions.extend(quote! {
379                    Tool {
380                        name: #tool_name.to_string(),
381                        description: #description_token,
382                        schema: #schema_token,
383                    },
384                });
385
386                // Add to match arms for call_tool
387                let mut method_call = TokenStream2::new();
388
389                if !param_fields.is_empty() {
390                    method_call.extend(quote! {
391                        let params: #params_struct_name = serde_json::from_value(parameters)
392                            .map_err(|e| {
393                                eprintln!("Tool parameter deserialization error for '{}': {:?}", #tool_name, e);
394                                ToolError::ExecutionError
395                            })?;
396                    });
397                }
398
399                method_call.extend(quote! { self.#fn_name_sig(#param_assignments) });
400                if method.sig.asyncness.is_some() {
401                    method_call.extend(quote! {.await});
402                }
403
404                method_call.extend(quote! { .map_err(|e| {
405                    eprintln!("Tool execution error for '{}': {:?}", #tool_name, e);
406                    ToolError::ExecutionError
407                }) });
408
409                match_arms.extend(quote! {
410                    #tool_name => {
411                        #method_call
412                    },
413                });
414            }
415        }
416    }
417
418    if found_tools.is_empty() {
419        return Error::new(Span::call_site(), "No #[tool] definition in impl block")
420            .to_compile_error()
421            .into();
422    }
423
424    // Generate the ToolBox implementation
425    let toolbox_impl = quote! {
426        #[::async_trait::async_trait]
427        impl ToolBox for #struct_ident {
428
429            fn tools_definitions(&self) -> Result<Vec<Tool>, ToolError> {
430                Ok(vec![
431                    #tool_definitions
432                ])
433            }
434
435            async fn call_tool(&self, tool_name: String, parameters: serde_json::Value) -> ToolResult {
436                 match tool_name.as_str() {
437                     #match_arms
438                     _ => {
439                         Err(ToolError::NoToolFound(tool_name))
440                     }
441                 }
442            }
443        }
444    };
445
446    // Combine generated code, the ToolBox impl, and the modified original impl block
447    let final_code = quote! {
448        #item_impl
449
450        #toolbox_impl
451
452        #generated_code
453    };
454
455    final_code.into()
456}