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}