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