mod prompt_macro;
mod resource_macro;
#[cfg(feature = "md-tmpl")]
mod response_struct_gen;
use convert_case::{Case, Casing};
use proc_macro::TokenStream;
use quote::{format_ident, quote};
#[cfg(feature = "md-tmpl")]
use syn::Ident;
use syn::{ItemFn, LitStr, parse_macro_input};
#[proc_macro_attribute]
pub fn llm_tool(attr: TokenStream, item: TokenStream) -> TokenStream {
let func = parse_macro_input!(item as ItemFn);
let tool_attr = if attr.is_empty() {
None
} else {
match syn::parse::<ToolAttr>(attr) {
Ok(parsed) => Some(parsed),
Err(err) => return err.to_compile_error().into(),
}
};
match tool_impl(&func, tool_attr.as_ref()) {
Ok(tokens) => tokens.into(),
Err(err) => err.to_compile_error().into(),
}
}
#[proc_macro_attribute]
pub fn llm_prompt(attr: TokenStream, item: TokenStream) -> TokenStream {
let func = parse_macro_input!(item as ItemFn);
let tool_attr = if attr.is_empty() {
None
} else {
match syn::parse::<ToolAttr>(attr) {
Ok(parsed) => Some(parsed),
Err(err) => return err.to_compile_error().into(),
}
};
match prompt_macro::prompt_impl(&func, tool_attr.as_ref()) {
Ok(tokens) => tokens.into(),
Err(err) => err.to_compile_error().into(),
}
}
#[proc_macro_attribute]
pub fn llm_resource(attr: TokenStream, item: TokenStream) -> TokenStream {
let func = parse_macro_input!(item as ItemFn);
let res_attr = match syn::parse::<resource_macro::ResourceAttr>(attr) {
Ok(parsed) => parsed,
Err(err) => return err.to_compile_error().into(),
};
match resource_macro::resource_impl(&func, &res_attr) {
Ok(tokens) => tokens.into(),
Err(err) => err.to_compile_error().into(),
}
}
struct ToolAttr {
prompt_inline: Option<LitStr>,
prompt_file_path: Option<LitStr>,
response_file_path: Option<LitStr>,
response_inline: Option<LitStr>,
#[cfg(feature = "md-tmpl")]
inline_params: Vec<(Ident, LitStr)>,
#[cfg(feature = "md-tmpl")]
env_vars: Vec<(Ident, syn::Lit)>,
#[cfg(feature = "md-tmpl")]
context_fn: Option<syn::Path>,
has_inline_params: bool,
has_context_fn: bool,
}
const ATTR_PROMPT: &str = "prompt";
const ATTR_PROMPT_FILE: &str = "prompt_file";
const ATTR_RESPONSE_FILE: &str = "response_file";
const ATTR_RESPONSE: &str = "response";
const ATTR_PARAMS: &str = "params";
const ATTR_CONTEXT: &str = "context";
const ATTR_ENV: &str = "env";
const TYPE_OPTION: &str = "Option";
const TYPE_TOOL_CONTEXT: &str = "ToolContext";
const TYPE_STR: &str = "str";
const ATTR_LLM_TOOL: &str = "llm_tool";
#[derive(Default)]
struct ToolAttrBuilder {
prompt_inline: Option<syn::LitStr>,
prompt_file_path: Option<syn::LitStr>,
response_file_path: Option<syn::LitStr>,
response_inline: Option<syn::LitStr>,
#[cfg(feature = "md-tmpl")]
inline_params: Vec<(syn::Ident, syn::LitStr)>,
#[cfg(feature = "md-tmpl")]
env_vars: Vec<(syn::Ident, syn::Lit)>,
#[cfg(feature = "md-tmpl")]
context_fn: Option<syn::Path>,
#[cfg(not(feature = "md-tmpl"))]
has_inline_params: bool,
#[cfg(not(feature = "md-tmpl"))]
has_context_fn: bool,
#[cfg(not(feature = "md-tmpl"))]
has_env: bool,
}
impl ToolAttrBuilder {
fn parse_single(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
let ident: syn::Ident = input.parse()?;
if ident == ATTR_PROMPT {
let _: syn::Token![=] = input.parse()?;
self.prompt_inline = Some(input.parse::<syn::LitStr>()?);
} else if ident == ATTR_PROMPT_FILE {
let _: syn::Token![=] = input.parse()?;
self.prompt_file_path = Some(input.parse::<syn::LitStr>()?);
} else if ident == ATTR_RESPONSE_FILE {
let _: syn::Token![=] = input.parse()?;
self.response_file_path = Some(input.parse::<syn::LitStr>()?);
} else if ident == ATTR_RESPONSE {
let _: syn::Token![=] = input.parse()?;
self.response_inline = Some(input.parse::<syn::LitStr>()?);
} else if ident == ATTR_PARAMS {
let content;
syn::parenthesized!(content in input);
while !content.is_empty() {
let key: syn::Ident = content.parse()?;
let _: syn::Token![=] = content.parse()?;
let value: syn::LitStr = content.parse()?;
#[cfg(feature = "md-tmpl")]
self.inline_params.push((key, value));
#[cfg(not(feature = "md-tmpl"))]
{
drop(key);
drop(value);
}
if !content.is_empty() {
let _: syn::Token![,] = content.parse()?;
}
}
#[cfg(not(feature = "md-tmpl"))]
{
self.has_inline_params = true;
}
} else if ident == ATTR_ENV {
let content;
syn::parenthesized!(content in input);
while !content.is_empty() {
let key: syn::Ident = content.parse()?;
let _: syn::Token![=] = content.parse()?;
let value: syn::Lit = content.parse()?;
match &value {
syn::Lit::Str(_)
| syn::Lit::Int(_)
| syn::Lit::Float(_)
| syn::Lit::Bool(_) => {}
other => {
return Err(syn::Error::new(
other.span(),
"env values must be string, integer, float, or bool literals",
));
}
}
#[cfg(feature = "md-tmpl")]
self.env_vars.push((key, value));
#[cfg(not(feature = "md-tmpl"))]
{
drop(key);
drop(value);
}
if !content.is_empty() {
let _: syn::Token![,] = content.parse()?;
}
}
#[cfg(not(feature = "md-tmpl"))]
{
self.has_env = true;
}
} else if ident == ATTR_CONTEXT {
let _: syn::Token![=] = input.parse()?;
#[cfg(feature = "md-tmpl")]
{
self.context_fn = Some(input.parse::<syn::Path>()?);
}
#[cfg(not(feature = "md-tmpl"))]
{
let _path: syn::Path = input.parse()?;
self.has_context_fn = true;
}
} else {
return Err(syn::Error::new(
ident.span(),
"expected `prompt`, `prompt_file`, `response`, `response_file`, `params`, `env`, or `context`",
));
}
Ok(())
}
}
impl syn::parse::Parse for ToolAttr {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let mut builder = ToolAttrBuilder::default();
while !input.is_empty() {
builder.parse_single(input)?;
if !input.is_empty() {
let _: syn::Token![,] = input.parse()?;
}
}
#[cfg(feature = "md-tmpl")]
let (has_inline_params, has_context_fn, has_env) = (
!builder.inline_params.is_empty(),
builder.context_fn.is_some(),
!builder.env_vars.is_empty(),
);
#[cfg(not(feature = "md-tmpl"))]
let (has_inline_params, has_context_fn, has_env) = (
builder.has_inline_params,
builder.has_context_fn,
builder.has_env,
);
validate_tool_attr(
builder.prompt_inline.as_ref(),
builder.prompt_file_path.as_ref(),
has_inline_params,
has_context_fn,
has_env,
)?;
if builder.response_inline.is_some() && builder.response_file_path.is_some() {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"cannot specify both `response` and `response_file`",
));
}
#[cfg(not(feature = "md-tmpl"))]
if builder.response_file_path.is_some() || builder.response_inline.is_some() {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"the `md-tmpl` feature must be enabled to use `response = \"...\"` or `response_file = \"...\"`",
));
}
Ok(Self {
prompt_inline: builder.prompt_inline,
prompt_file_path: builder.prompt_file_path,
response_file_path: builder.response_file_path,
response_inline: builder.response_inline,
#[cfg(feature = "md-tmpl")]
inline_params: builder.inline_params,
#[cfg(feature = "md-tmpl")]
env_vars: builder.env_vars,
#[cfg(feature = "md-tmpl")]
context_fn: builder.context_fn,
has_inline_params,
has_context_fn,
})
}
}
fn validate_tool_attr(
prompt_inline: Option<&LitStr>,
prompt_file_path: Option<&LitStr>,
has_inline_params: bool,
has_context_fn: bool,
has_env: bool,
) -> syn::Result<()> {
if prompt_inline.is_some() && prompt_file_path.is_some() {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"`prompt` and `prompt_file` are mutually exclusive",
));
}
if prompt_file_path.is_none() && prompt_inline.is_none() && has_inline_params {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"`params(...)` requires `prompt_file = \"...\"` or `prompt = \"...\"`",
));
}
if prompt_file_path.is_none() && prompt_inline.is_none() && has_context_fn {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"`context = ...` requires `prompt_file = \"...\"` or `prompt = \"...\"`",
));
}
if has_env && prompt_file_path.is_none() && prompt_inline.is_none() {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"`env(...)` requires `prompt_file = \"...\"` or `prompt = \"...\"`",
));
}
#[cfg(not(feature = "md-tmpl"))]
if has_env {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"the `md-tmpl` feature must be enabled to use `env(...)`. \
Add `features = [\"md-tmpl\"]` to your llm-tool dependency.",
));
}
if has_inline_params && has_context_fn {
return Err(syn::Error::new(
proc_macro2::Span::call_site(),
"`params(...)` and `context = ...` are mutually exclusive; \
use `params` for compile-time values or `context` for runtime values",
));
}
if prompt_inline.is_none()
&& prompt_file_path.is_none()
&& !has_inline_params
&& !has_context_fn
{
}
Ok(())
}
struct ParamInfo {
name: syn::Ident,
ty: Box<syn::Type>,
doc_attrs: Vec<syn::Attribute>,
is_context: bool,
}
enum ReturnInfo {
ResultType {
ok_type: Box<syn::Type>,
err_type: Box<syn::Type>,
},
BareType,
}
fn tool_impl(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<proc_macro2::TokenStream> {
let crate_path = quote! { ::llm_tool };
let fn_name = &func.sig.ident;
let tool_name_str = fn_name.to_string();
let struct_name = format_ident!("{}", tool_name_str.to_case(Case::Pascal));
let params_name = format_ident!("{}Params", struct_name);
let DescriptionInfo {
static_description,
helper_tokens,
description_method,
dep_tracking,
} = resolve_description(func, attr)?;
let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
let all_params = extract_params(func)?;
let ctx_param = all_params.iter().find(|p| p.is_context);
let params: Vec<&ParamInfo> = all_params.iter().filter(|p| !p.is_context).collect();
for param in ¶ms {
if param.doc_attrs.is_empty() {
return Err(syn::Error::new_spanned(
¶m.name,
format!(
"#[llm_tool] parameter `{}` must have a doc comment \
(used as the parameter description in the JSON schema)",
param.name
),
));
}
}
let return_info = parse_return_type(func)?;
let param_names: Vec<_> = params.iter().map(|p| &p.name).collect();
let param_descriptions: Vec<String> = params
.iter()
.map(|p| extract_doc_string(&p.doc_attrs))
.collect();
let (param_struct_types, borrow_bindings) = build_param_types_and_borrows(¶ms);
let serde_defaults = build_serde_defaults(¶ms);
let body_tokens = build_body_tokens(func, &return_info, &crate_path, &response_info);
let vis = &func.vis;
let params_doc = format!("Auto-generated parameters for the [`{struct_name}`] tool.");
let struct_doc = format!(
"Auto-generated tool struct. See the `#[llm_tool]`-annotated function `{fn_name}` for the implementation."
);
let ctx_binding = if let Some(cp) = ctx_param {
let ctx_name = &cp.name;
quote! { let #ctx_name = _ctx; }
} else {
quote! {}
};
let response_dep_tracking = &response_info.dep_tracking;
let response_helper_tokens = &response_info.helper_tokens;
Ok(quote! {
#dep_tracking
#response_dep_tracking
#helper_tokens
#response_helper_tokens
#[doc = #params_doc]
#[derive(::serde::Deserialize, ::schemars::JsonSchema)]
#vis struct #params_name {
#(
#[schemars(description = #param_descriptions)]
#serde_defaults
pub #param_names: #param_struct_types,
)*
}
#[doc = #struct_doc]
#vis struct #struct_name;
impl #crate_path::RustTool for #struct_name {
type Params = #params_name;
const NAME: &'static str = #tool_name_str;
const DESCRIPTION: &'static str = #static_description;
#description_method
#[allow(unknown_lints, clippy::unused_async_trait_impl)]
async fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
use #crate_path::__private::SerializeFallback as _;
let #params_name { #( #param_names, )* } = params;
#( #borrow_bindings )*
#ctx_binding
#body_tokens
}
}
})
}
struct DescriptionInfo {
static_description: String,
helper_tokens: proc_macro2::TokenStream,
description_method: Option<proc_macro2::TokenStream>,
dep_tracking: proc_macro2::TokenStream,
}
pub(crate) mod desc;
pub(crate) mod helpers;
#[allow(clippy::wildcard_imports)]
pub(crate) use desc::*;
#[allow(clippy::wildcard_imports)]
pub(crate) use helpers::*;