use convert_case::{Case, Casing};
use proc_macro::TokenStream;
use quote::{format_ident, quote};
use syn::{
FnArg, Ident, ItemFn, LitStr, Token, Type, Visibility,
parse::{Parse, ParseStream},
parse_macro_input, parse_quote,
};
struct ToolArgs {
rename: Option<String>,
}
impl Parse for ToolArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut rename = None;
while !input.is_empty() {
let ident: Ident = input.parse()?;
let _: Token![=] = input.parse()?;
let value: LitStr = input.parse()?;
match ident.to_string().as_str() {
"rename" => rename = Some(value.value()),
_ => {
return Err(syn::Error::new_spanned(
ident,
"unknown attribute. Supported: rename",
));
}
}
if input.peek(Token![,]) {
let _: Token![,] = input.parse()?;
}
}
Ok(Self { rename })
}
}
#[proc_macro_attribute]
pub fn tool(args: TokenStream, input: TokenStream) -> TokenStream {
let args = parse_macro_input!(args as ToolArgs);
let input_fn = parse_macro_input!(input as ItemFn);
match tool_impl(args, input_fn) {
Ok(tokens) => tokens.into(),
Err(err) => err.to_compile_error().into(),
}
}
fn tool_impl(args: ToolArgs, input_fn: ItemFn) -> syn::Result<proc_macro2::TokenStream> {
let fn_name = &input_fn.sig.ident;
let tool_name = args.rename.unwrap_or_else(|| fn_name.to_string());
let fn_vis = &input_fn.vis;
let tool_struct_name = format_ident!("{}", fn_name.to_string().to_case(Case::Pascal));
let description = doc_text(&input_fn.attrs).map(|text| {
quote! {
fn description(&self) -> ::aither::__hidden::CowStr {
#text.into()
}
}
});
let (context_position, data_inputs) = split_context_parameter(&input_fn.sig.inputs)?;
let AnalyzedArgs {
args_type,
params,
stream,
} = analyze_function_args(fn_vis, &tool_struct_name, &data_inputs)?;
if input_fn.sig.asyncness.is_none() {
return Err(syn::Error::new_spanned(
input_fn.sig,
"Tool functions must be async",
));
}
let mut call_args: Vec<proc_macro2::TokenStream> =
params.iter().map(|param| quote! { #param }).collect();
if let Some(position) = context_position {
call_args.insert(position, quote! { cx });
}
let call_expr = quote! { #fn_name(#(#call_args),*).await };
let extractor = if params.len() <= 1 {
quote! {}
} else {
quote! { let Self::Arguments { #(#params),* } = args; }
};
let args_binding = if params.is_empty() {
quote! { _args }
} else {
quote! { args }
};
let context_binding = if context_position.is_some() {
quote! { cx }
} else {
quote! { _cx }
};
let expanded = quote! {
#input_fn
#stream
#[derive(::core::default::Default,::core::fmt::Debug)]
#fn_vis struct #tool_struct_name;
impl ::aither::llm::Tool for #tool_struct_name {
fn name(&self) -> ::aither::__hidden::CowStr {
#tool_name.into()
}
#description
type Arguments = #args_type;
type Res = ::aither::llm::ToolResult;
async fn call(
&self,
#args_binding: Self::Arguments,
#context_binding: ::aither::llm::ToolContext,
) -> ::aither::Result<Self::Res> {
#extractor
::aither::llm::IntoToolResult::into_tool_result(#call_expr)
}
}
};
Ok(expanded)
}
fn doc_text(attrs: &[syn::Attribute]) -> Option<String> {
let lines: Vec<String> = attrs
.iter()
.filter(|attr| attr.path().is_ident("doc"))
.filter_map(|attr| match &attr.meta {
syn::Meta::NameValue(syn::MetaNameValue {
value:
syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(text),
..
}),
..
}) => Some(text.value()),
_ => None,
})
.map(|line| {
line.strip_prefix(' ')
.map_or_else(|| line.clone(), str::to_owned)
})
.collect();
let text = lines.join("\n");
let text = text.trim();
(!text.is_empty()).then(|| text.to_owned())
}
fn split_context_parameter(
inputs: &syn::punctuated::Punctuated<FnArg, syn::Token![,]>,
) -> syn::Result<(
Option<usize>,
syn::punctuated::Punctuated<FnArg, syn::Token![,]>,
)> {
let mut position = None;
let mut data = syn::punctuated::Punctuated::new();
for (index, input) in inputs.iter().enumerate() {
if is_context_parameter(input) {
if position.is_some() {
return Err(syn::Error::new_spanned(
input,
"a tool function takes at most one `ToolContext` parameter",
));
}
position = Some(index);
} else {
data.push(input.clone());
}
}
Ok((position, data))
}
fn is_context_parameter(input: &FnArg) -> bool {
let FnArg::Typed(pat_type) = input else {
return false;
};
let Type::Path(path) = pat_type.ty.as_ref() else {
return false;
};
path.qself.is_none()
&& path
.path
.segments
.last()
.is_some_and(|segment| segment.ident == "ToolContext")
}
struct AnalyzedArgs {
args_type: Type,
params: Vec<Ident>,
stream: proc_macro2::TokenStream,
}
fn analyze_function_args(
fn_vis: &Visibility,
struct_name: &Ident,
inputs: &syn::punctuated::Punctuated<FnArg, syn::Token![,]>,
) -> syn::Result<AnalyzedArgs> {
match inputs.len() {
0 => {
Ok(AnalyzedArgs {
args_type: parse_quote! { () },
params: vec![],
stream: quote! {},
})
}
1 => {
if let FnArg::Typed(pat_type) = &inputs[0] {
Ok(AnalyzedArgs {
args_type: (*pat_type.ty).clone(),
params: vec![format_ident!("args")],
stream: quote! {},
})
} else {
Err(syn::Error::new_spanned(
&inputs[0],
"self parameters are not supported in tool functions",
))
}
}
_ => {
let mut attributes = Vec::new();
for arg in inputs {
if let FnArg::Typed(pat_type) = arg {
let pat = &pat_type.pat;
let ty = &pat_type.ty;
attributes.push(quote! {
#pat: #ty,
});
} else {
return Err(syn::Error::new_spanned(
arg,
"self parameters are not supported in tool functions",
));
}
}
let arg_struct_name = format_ident!("{}Args", struct_name);
let new_type_gen = quote! {
#[derive(::schemars::JsonSchema, ::serde::Deserialize,::core::fmt::Debug)]
#fn_vis struct #arg_struct_name {
#(
#attributes
)*
}
};
let params = inputs
.iter()
.map(|arg| match arg {
FnArg::Typed(pat_type) => {
let pat = &pat_type.pat;
Ok(format_ident!("{}", quote! {#pat}.to_string()))
}
FnArg::Receiver(receiver) => Err(syn::Error::new_spanned(
receiver,
"#[tool] cannot be applied to a method taking `self`; \
use a free function",
)),
})
.collect::<Result<Vec<_>, syn::Error>>()?;
Ok(AnalyzedArgs {
args_type: parse_quote! { #arg_struct_name },
params,
stream: new_type_gen,
})
}
}
}