Skip to main content

agnt_macros/
lib.rs

1//! # agnt-macros
2//!
3//! Proc-macros for the [agnt](https://crates.io/crates/agnt) agent runtime.
4//!
5//! ## `#[tool]` attribute
6//!
7//! Apply `#[agnt_macros::tool]` (or `#[agnt::tool]` when re-exported from the
8//! flagship crate) to a free function to generate a unit struct plus a
9//! [`TypedTool`](../agnt_core/tool/trait.TypedTool.html) impl.
10//!
11//! ```ignore
12//! use agnt_macros::tool;
13//! use serde::{Deserialize, Serialize};
14//!
15//! #[derive(Deserialize)]
16//! struct AddArgs { a: i64, b: i64 }
17//! #[derive(Serialize)]
18//! struct AddOut { sum: i64 }
19//!
20//! /// Add two integers and return their sum.
21//! #[tool]
22//! fn add(args: AddArgs) -> Result<AddOut, String> {
23//!     Ok(AddOut { sum: args.a + args.b })
24//! }
25//!
26//! // Generates:
27//! //   pub struct Add;
28//! //   impl agnt_core::TypedTool for Add { ... NAME = "add" ... }
29//! ```
30//!
31//! ### Requirements on the annotated function
32//!
33//! * Exactly one argument whose type becomes `TypedTool::Args`.
34//! * Return type must be `Result<Output, Error>`.
35//! * A doc comment is strongly recommended — it becomes the tool description
36//!   the model sees. If absent, the function name is used as a fallback.
37//!
38//! ### ⚠️ Known limitations (v0.3.x)
39//!
40//! **`schema()` is a placeholder — the model sees no field information.**
41//! The generated `TypedTool::schema` returns the literal value
42//! `{"type": "object"}` with no `properties`, no `required`, no field
43//! types. Consequences:
44//!
45//! * The model cannot see what arguments your tool accepts, so it will
46//!   guess field names from the description alone.
47//! * A wrong guess produces a `serde_json` deserialization error that is
48//!   surfaced as the tool result; the model then has to re-plan from the
49//!   error message.
50//! * For any non-trivial tool, the macro currently *reduces* ergonomics
51//!   versus hand-writing a [`TypedTool`] impl where you control
52//!   `schema()` and can emit a real JSON Schema.
53//!
54//! This will be fixed in v0.4 behind an opt-in `#[tool(schema = schemars)]`
55//! attribute that wires the annotated `Args` type through `schemars` to
56//! produce a real JSON Schema. Until then, prefer a hand-written
57//! `TypedTool` impl for any tool whose arguments are non-obvious from the
58//! description alone.
59//!
60//! ### Other limitations
61//!
62//! * Only free functions are supported; methods and closures are not.
63//! * The function is left in place unchanged, so you can still call it
64//!   directly. The generated struct's `TypedTool::call` simply forwards.
65//!
66//! [`TypedTool`]: ../agnt_core/tool/trait.TypedTool.html
67
68use proc_macro::TokenStream;
69use proc_macro2::TokenStream as TokenStream2;
70use quote::{format_ident, quote};
71use syn::{
72    parse_macro_input, spanned::Spanned, Attribute, Expr, ExprLit, FnArg, ItemFn, Lit, Meta,
73    PatType, ReturnType, Type,
74};
75
76/// Generate a [`TypedTool`] impl from a free function.
77///
78/// ⚠️ **v0.3.x limitation**: the generated `schema()` returns a bare
79/// `{"type": "object"}` with no field metadata. The model cannot see
80/// your argument names or types from the schema alone and must infer
81/// them from the description. See the [crate-level docs](crate) for
82/// the full list of limitations and the v0.4 plan.
83#[proc_macro_attribute]
84pub fn tool(_args: TokenStream, input: TokenStream) -> TokenStream {
85    let func = parse_macro_input!(input as ItemFn);
86    match expand_tool(func) {
87        Ok(ts) => ts.into(),
88        Err(e) => e.to_compile_error().into(),
89    }
90}
91
92fn expand_tool(func: ItemFn) -> syn::Result<TokenStream2> {
93    let fn_name = func.sig.ident.clone();
94    let fn_name_str = fn_name.to_string();
95    let struct_name = format_ident!("{}", snake_to_pascal(&fn_name_str));
96
97    // ---- argument type ----
98    let inputs = &func.sig.inputs;
99    if inputs.len() != 1 {
100        return Err(syn::Error::new(
101            func.sig.inputs.span(),
102            format!(
103                "#[tool] expects exactly one function argument (got {}); \
104                 the argument type becomes TypedTool::Args",
105                inputs.len()
106            ),
107        ));
108    }
109    let args_ty: &Type = match inputs.first().unwrap() {
110        FnArg::Typed(PatType { ty, .. }) => ty.as_ref(),
111        FnArg::Receiver(r) => {
112            return Err(syn::Error::new(
113                r.span(),
114                "#[tool] cannot be applied to methods taking `self`",
115            ));
116        }
117    };
118
119    // ---- return type: Result<Output, Error> ----
120    let (output_ty, error_ty) = match &func.sig.output {
121        ReturnType::Default => {
122            return Err(syn::Error::new(
123                func.sig.output.span(),
124                "#[tool] functions must return Result<Output, Error>",
125            ));
126        }
127        ReturnType::Type(_, ty) => extract_result_types(ty)?,
128    };
129
130    // ---- doc comment / description ----
131    let description = extract_doc(&func.attrs).unwrap_or_else(|| fn_name_str.clone());
132
133    // Note: we intentionally do not emit a warning if description is missing;
134    // stable proc-macros have no warning API. Fallback is silent-by-design.
135
136    let vis = &func.vis;
137
138    let expanded = quote! {
139        #func
140
141        #[allow(non_camel_case_types)]
142        #vis struct #struct_name;
143
144        impl ::agnt_core::TypedTool for #struct_name {
145            type Args = #args_ty;
146            type Output = #output_ty;
147            type Error = #error_ty;
148            const NAME: &'static str = #fn_name_str;
149            const DESCRIPTION: &'static str = #description;
150
151            fn schema() -> ::serde_json::Value {
152                ::serde_json::json!({ "type": "object" })
153            }
154
155            fn call(&self, args: Self::Args) -> ::core::result::Result<Self::Output, Self::Error> {
156                #fn_name(args)
157            }
158        }
159    };
160
161    Ok(expanded)
162}
163
164/// Walk `#[doc = "..."]` attributes, trim and join into a single description.
165fn extract_doc(attrs: &[Attribute]) -> Option<String> {
166    let mut parts: Vec<String> = Vec::new();
167    for attr in attrs {
168        if !attr.path().is_ident("doc") {
169            continue;
170        }
171        if let Meta::NameValue(nv) = &attr.meta {
172            if let Expr::Lit(ExprLit {
173                lit: Lit::Str(s), ..
174            }) = &nv.value
175            {
176                parts.push(s.value().trim().to_string());
177            }
178        }
179    }
180    let joined = parts.join(" ").trim().to_string();
181    if joined.is_empty() {
182        None
183    } else {
184        Some(joined)
185    }
186}
187
188/// Given a return type, verify it is `Result<O, E>` and return `(O, E)`.
189fn extract_result_types(ty: &Type) -> syn::Result<(Type, Type)> {
190    let err = || {
191        syn::Error::new(
192            ty.span(),
193            "#[tool] functions must return Result<Output, Error> \
194             (fully-qualified paths like std::result::Result are also accepted)",
195        )
196    };
197    let path = match ty {
198        Type::Path(tp) => &tp.path,
199        _ => return Err(err()),
200    };
201    let seg = path.segments.last().ok_or_else(err)?;
202    if seg.ident != "Result" {
203        return Err(err());
204    }
205    let args = match &seg.arguments {
206        syn::PathArguments::AngleBracketed(a) => &a.args,
207        _ => return Err(err()),
208    };
209    let mut types = args.iter().filter_map(|a| match a {
210        syn::GenericArgument::Type(t) => Some(t.clone()),
211        _ => None,
212    });
213    let ok_ty = types.next().ok_or_else(err)?;
214    let err_ty = types.next().ok_or_else(err)?;
215    Ok((ok_ty, err_ty))
216}
217
218/// Convert `snake_case` (or already-PascalCase) to `PascalCase`.
219fn snake_to_pascal(s: &str) -> String {
220    let mut out = String::with_capacity(s.len());
221    let mut upper = true;
222    for c in s.chars() {
223        if c == '_' {
224            upper = true;
225        } else if upper {
226            out.extend(c.to_uppercase());
227            upper = false;
228        } else {
229            out.push(c);
230        }
231    }
232    if out.is_empty() {
233        // Should be unreachable — syn would reject empty ident — but guard anyway.
234        return "_Tool".to_string();
235    }
236    out
237}
238