1#[cfg(feature = "prompt-templates")]
12mod response_struct_gen;
13#[cfg(feature = "prompt-templates")]
14mod template_codegen;
15#[cfg(feature = "prompt-templates")]
16mod template_compile;
17
18use convert_case::{Case, Casing};
19use proc_macro::TokenStream;
20use quote::{format_ident, quote};
21#[cfg(feature = "prompt-templates")]
22use syn::Ident;
23use syn::{
24 FnArg, GenericArgument, ItemFn, LitStr, Pat, PatType, PathArguments, Type, parse_macro_input,
25};
26
27#[proc_macro_attribute]
109pub fn llm_tool(attr: TokenStream, item: TokenStream) -> TokenStream {
110 let func = parse_macro_input!(item as ItemFn);
111 let tool_attr = if attr.is_empty() {
112 None
113 } else {
114 match syn::parse::<ToolAttr>(attr) {
115 Ok(parsed) => Some(parsed),
116 Err(err) => return err.to_compile_error().into(),
117 }
118 };
119 match tool_impl(&func, tool_attr.as_ref()) {
120 Ok(tokens) => tokens.into(),
121 Err(err) => err.to_compile_error().into(),
122 }
123}
124
125struct ToolAttr {
136 prompt_inline: Option<LitStr>,
138 prompt_file_path: Option<LitStr>,
140 response_file_path: Option<LitStr>,
142 response_inline: Option<LitStr>,
144 #[cfg(feature = "prompt-templates")]
147 inline_params: Vec<(Ident, LitStr)>,
148 #[cfg(feature = "prompt-templates")]
150 context_fn: Option<syn::Path>,
151 has_inline_params: bool,
152 has_context_fn: bool,
153}
154
155#[derive(Default)]
156struct ToolAttrBuilder {
157 prompt_inline: Option<syn::LitStr>,
158 prompt_file_path: Option<syn::LitStr>,
159 response_file_path: Option<syn::LitStr>,
160 response_inline: Option<syn::LitStr>,
161 #[cfg(feature = "prompt-templates")]
162 inline_params: Vec<(syn::Ident, syn::LitStr)>,
163 #[cfg(feature = "prompt-templates")]
164 context_fn: Option<syn::Path>,
165 #[cfg(not(feature = "prompt-templates"))]
166 has_inline_params: bool,
167 #[cfg(not(feature = "prompt-templates"))]
168 has_context_fn: bool,
169}
170
171impl ToolAttrBuilder {
172 fn parse_single(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
173 let ident: syn::Ident = input.parse()?;
174 if ident == "prompt" {
175 let _: syn::Token![=] = input.parse()?;
176 self.prompt_inline = Some(input.parse::<syn::LitStr>()?);
177 } else if ident == "prompt_file" {
178 let _: syn::Token![=] = input.parse()?;
179 self.prompt_file_path = Some(input.parse::<syn::LitStr>()?);
180 } else if ident == "response_file" {
181 let _: syn::Token![=] = input.parse()?;
182 self.response_file_path = Some(input.parse::<syn::LitStr>()?);
183 } else if ident == "response" {
184 let _: syn::Token![=] = input.parse()?;
185 self.response_inline = Some(input.parse::<syn::LitStr>()?);
186 } else if ident == "params" {
187 let content;
188 syn::parenthesized!(content in input);
189 while !content.is_empty() {
190 let key: syn::Ident = content.parse()?;
191 let _: syn::Token![=] = content.parse()?;
192 let value: syn::LitStr = content.parse()?;
193 #[cfg(feature = "prompt-templates")]
194 self.inline_params.push((key, value));
195 #[cfg(not(feature = "prompt-templates"))]
196 {
197 drop(key);
198 drop(value);
199 }
200 if !content.is_empty() {
201 let _: syn::Token![,] = content.parse()?;
202 }
203 }
204 #[cfg(not(feature = "prompt-templates"))]
205 {
206 self.has_inline_params = true;
207 }
208 } else if ident == "context" {
209 let _: syn::Token![=] = input.parse()?;
210 #[cfg(feature = "prompt-templates")]
211 {
212 self.context_fn = Some(input.parse::<syn::Path>()?);
213 }
214 #[cfg(not(feature = "prompt-templates"))]
215 {
216 let _path: syn::Path = input.parse()?;
217 self.has_context_fn = true;
218 }
219 } else {
220 return Err(syn::Error::new(
221 ident.span(),
222 "expected `prompt`, `prompt_file`, `response`, `response_file`, `params`, or `context`",
223 ));
224 }
225 Ok(())
226 }
227}
228
229impl syn::parse::Parse for ToolAttr {
230 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
231 let mut builder = ToolAttrBuilder::default();
232
233 while !input.is_empty() {
234 builder.parse_single(input)?;
235 if !input.is_empty() {
236 let _: syn::Token![,] = input.parse()?;
237 }
238 }
239
240 #[cfg(feature = "prompt-templates")]
241 let (has_inline_params, has_context_fn) = (
242 !builder.inline_params.is_empty(),
243 builder.context_fn.is_some(),
244 );
245 #[cfg(not(feature = "prompt-templates"))]
246 let (has_inline_params, has_context_fn) =
247 (builder.has_inline_params, builder.has_context_fn);
248
249 validate_tool_attr(
250 builder.prompt_inline.as_ref(),
251 builder.prompt_file_path.as_ref(),
252 has_inline_params,
253 has_context_fn,
254 )?;
255
256 if builder.response_inline.is_some() && builder.response_file_path.is_some() {
257 return Err(syn::Error::new(
258 proc_macro2::Span::call_site(),
259 "cannot specify both `response` and `response_file`",
260 ));
261 }
262
263 #[cfg(not(feature = "prompt-templates"))]
265 if builder.response_file_path.is_some() || builder.response_inline.is_some() {
266 return Err(syn::Error::new(
267 proc_macro2::Span::call_site(),
268 "the `prompt-templates` feature must be enabled to use `response = \"...\"` or `response_file = \"...\"`",
269 ));
270 }
271
272 Ok(Self {
273 prompt_inline: builder.prompt_inline,
274 prompt_file_path: builder.prompt_file_path,
275 response_file_path: builder.response_file_path,
276 response_inline: builder.response_inline,
277 #[cfg(feature = "prompt-templates")]
278 inline_params: builder.inline_params,
279 #[cfg(feature = "prompt-templates")]
280 context_fn: builder.context_fn,
281 has_inline_params,
282 has_context_fn,
283 })
284 }
285}
286
287fn validate_tool_attr(
290 prompt_inline: Option<&LitStr>,
291 prompt_file_path: Option<&LitStr>,
292 has_inline_params: bool,
293 has_context_fn: bool,
294) -> syn::Result<()> {
295 if prompt_inline.is_some() && prompt_file_path.is_some() {
297 return Err(syn::Error::new(
298 proc_macro2::Span::call_site(),
299 "`prompt` and `prompt_file` are mutually exclusive",
300 ));
301 }
302
303 if prompt_file_path.is_none() && has_inline_params {
305 return Err(syn::Error::new(
306 proc_macro2::Span::call_site(),
307 "`params(...)` requires `prompt_file = \"...\"`",
308 ));
309 }
310 if prompt_file_path.is_none() && has_context_fn {
311 return Err(syn::Error::new(
312 proc_macro2::Span::call_site(),
313 "`context = ...` requires `prompt_file = \"...\"`",
314 ));
315 }
316
317 if has_inline_params && has_context_fn {
319 return Err(syn::Error::new(
320 proc_macro2::Span::call_site(),
321 "`params(...)` and `context = ...` are mutually exclusive; \
322 use `params` for compile-time values or `context` for runtime values",
323 ));
324 }
325
326 if prompt_inline.is_none()
329 && prompt_file_path.is_none()
330 && !has_inline_params
331 && !has_context_fn
332 {
333 }
335
336 Ok(())
337}
338
339struct ParamInfo {
343 name: syn::Ident,
344 ty: Box<syn::Type>,
345 doc_attrs: Vec<syn::Attribute>,
346 is_context: bool,
347}
348
349enum ReturnInfo {
351 ResultType {
353 ok_type: Box<syn::Type>,
354 err_type: Box<syn::Type>,
355 },
356 BareType,
358}
359
360fn tool_impl(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<proc_macro2::TokenStream> {
361 let crate_path = quote! { ::llm_tool };
362 let fn_name = &func.sig.ident;
363 let tool_name_str = fn_name.to_string();
364 let struct_name = format_ident!("{}", tool_name_str.to_case(Case::Pascal));
365 let params_name = format_ident!("{}Params", struct_name);
366
367 let DescriptionInfo {
369 static_description,
370 helper_tokens,
371 description_method,
372 dep_tracking,
373 } = resolve_description(func, attr)?;
374
375 let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
377
378 let all_params = extract_params(func)?;
380 let ctx_param = all_params.iter().find(|p| p.is_context);
381 let params: Vec<&ParamInfo> = all_params.iter().filter(|p| !p.is_context).collect();
382
383 for param in ¶ms {
385 if param.doc_attrs.is_empty() {
386 return Err(syn::Error::new_spanned(
387 ¶m.name,
388 format!(
389 "#[llm_tool] parameter `{}` must have a doc comment \
390 (used as the parameter description in the JSON schema)",
391 param.name
392 ),
393 ));
394 }
395 }
396
397 let return_info = parse_return_type(func)?;
399
400 let param_names: Vec<_> = params.iter().map(|p| &p.name).collect();
401 let param_descriptions: Vec<String> = params
402 .iter()
403 .map(|p| extract_doc_string(&p.doc_attrs))
404 .collect();
405
406 let (param_struct_types, borrow_bindings) = build_param_types_and_borrows(¶ms);
407 let serde_defaults = build_serde_defaults(¶ms);
408 let body_tokens = build_body_tokens(func, &return_info, &crate_path, &response_info);
409
410 let vis = &func.vis;
411
412 let params_doc = format!("Auto-generated parameters for the [`{struct_name}`] tool.");
413 let struct_doc = format!(
414 "Auto-generated tool struct. See the `#[llm_tool]`-annotated function `{fn_name}` for the implementation."
415 );
416
417 let ctx_binding = if let Some(cp) = ctx_param {
420 let ctx_name = &cp.name;
421 quote! { let #ctx_name = _ctx; }
422 } else {
423 quote! {}
424 };
425
426 let response_dep_tracking = &response_info.dep_tracking;
427 let response_helper_tokens = &response_info.helper_tokens;
428
429 Ok(quote! {
430 #dep_tracking
431 #response_dep_tracking
432 #helper_tokens
433 #response_helper_tokens
434
435 #[doc = #params_doc]
436 #[derive(::serde::Deserialize, ::schemars::JsonSchema)]
437 #vis struct #params_name {
438 #(
439 #[schemars(description = #param_descriptions)]
440 #serde_defaults
441 pub #param_names: #param_struct_types,
442 )*
443 }
444
445 #[doc = #struct_doc]
446 #vis struct #struct_name;
447
448 impl #crate_path::RustTool for #struct_name {
449 type Params = #params_name;
450 const NAME: &'static str = #tool_name_str;
451 const DESCRIPTION: &'static str = #static_description;
452
453 #description_method
454
455 async fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
456 use #crate_path::__private::SerializeFallback as _;
459 let #params_name { #( #param_names, )* } = params;
462 #( #borrow_bindings )*
464 #ctx_binding
465 #body_tokens
466 }
467 }
468 })
469}
470
471struct DescriptionInfo {
475 static_description: String,
477 helper_tokens: proc_macro2::TokenStream,
479 description_method: Option<proc_macro2::TokenStream>,
481 dep_tracking: proc_macro2::TokenStream,
483}
484
485fn resolve_description(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<DescriptionInfo> {
487 match attr {
488 Some(
490 tool_attr @ ToolAttr {
491 prompt_inline: Some(_),
492 ..
493 },
494 ) => resolve_inline_description(tool_attr),
495 Some(
497 tool_attr @ ToolAttr {
498 prompt_file_path: Some(_),
499 ..
500 },
501 ) => resolve_template_description(tool_attr),
502 _ => {
504 let desc = extract_doc_string(&func.attrs);
505 if desc.is_empty() {
506 return Err(syn::Error::new_spanned(
507 &func.sig.ident,
508 "#[llm_tool] functions must have a doc comment \
509 (used as the tool description), or use \
510 #[llm_tool(prompt = \"...\")]",
511 ));
512 }
513 Ok(DescriptionInfo {
514 static_description: desc,
515 helper_tokens: quote! {},
516 description_method: None,
517 dep_tracking: quote! {},
518 })
519 }
520 }
521}
522
523fn resolve_inline_description(attr: &ToolAttr) -> syn::Result<DescriptionInfo> {
525 #[cfg(not(feature = "prompt-templates"))]
526 {
527 let span = attr
528 .prompt_inline
529 .as_ref()
530 .map_or(proc_macro2::Span::call_site(), LitStr::span);
531 if attr.has_inline_params || attr.has_context_fn {
532 return Err(syn::Error::new(
533 span,
534 "the `prompt-templates` feature must be enabled to use dynamic inline prompts",
535 ));
536 }
537 let desc = attr.prompt_inline.as_ref().unwrap().value();
538 Ok(DescriptionInfo {
539 static_description: desc,
540 helper_tokens: quote! {},
541 description_method: None,
542 dep_tracking: quote! {},
543 })
544 }
545
546 #[cfg(feature = "prompt-templates")]
547 resolve_inline_description_impl(attr)
548}
549
550fn resolve_template_description(attr: &ToolAttr) -> syn::Result<DescriptionInfo> {
552 #[cfg(not(feature = "prompt-templates"))]
553 {
554 let span = attr
555 .prompt_file_path
556 .as_ref()
557 .map_or(proc_macro2::Span::call_site(), LitStr::span);
558 Err(syn::Error::new(
559 span,
560 "the `prompt-templates` feature must be enabled to use \
561 `#[llm_tool(prompt_file = \"...\")]`. \
562 Add `features = [\"prompt-templates\"]` to your llm-tool dependency.",
563 ))
564 }
565
566 #[cfg(feature = "prompt-templates")]
567 resolve_template_description_impl(attr)
568}
569
570#[cfg(feature = "prompt-templates")]
577fn resolve_template_description_impl(attr: &ToolAttr) -> syn::Result<DescriptionInfo> {
578 let template_lit = attr
579 .prompt_file_path
580 .as_ref()
581 .expect("prompt_file_path validated");
582 let rel_path = template_lit.value();
583 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
584 let full_path = std::path::Path::new(&manifest_dir).join(&rel_path);
585
586 let source = std::fs::read_to_string(&full_path).map_err(|e| {
587 syn::Error::new(
588 template_lit.span(),
589 format!("failed to read template '{}': {e}", full_path.display()),
590 )
591 })?;
592
593 let (fm, body) = prompt_templates::parse_frontmatter(&source).map_err(|e| {
594 syn::Error::new(
595 template_lit.span(),
596 format!("template '{rel_path}' error: {e}"),
597 )
598 })?;
599
600 let body_str = body.trim().to_string();
601 let path_str = full_path.to_string_lossy().to_string();
602
603 let dep_tracking = quote! {
606 const _: &str = include_str!(#path_str);
607 };
608
609 let has_params = !attr.inline_params.is_empty();
610 let has_context = attr.context_fn.is_some();
611 let has_declarations = !fm.declarations.is_empty();
612
613 if !has_declarations && !has_params && !has_context {
614 Ok(DescriptionInfo {
616 static_description: body_str,
617 helper_tokens: quote! {},
618 description_method: None,
619 dep_tracking,
620 })
621 } else if has_params {
622 resolve_template_with_params(
624 attr,
625 &fm,
626 &source,
627 &rel_path,
628 template_lit.span(),
629 dep_tracking,
630 )
631 } else if has_context {
632 resolve_context_description(ResolveContextArgs {
634 attr,
635 rel_path: &rel_path,
636 template_lit,
637 source: &source,
638 full_path: &full_path,
639 body_str: &body_str,
640 has_declarations,
641 dep_tracking,
642 })
643 } else {
644 let declared: Vec<&str> = fm.declarations.iter().map(|d| d.name.as_str()).collect();
646 Err(syn::Error::new(
647 template_lit.span(),
648 format!(
649 "template '{rel_path}' declares parameters ({}) but neither \
650 `params(...)` nor `context = ...` was provided",
651 declared.join(", ")
652 ),
653 ))
654 }
655}
656
657#[cfg(feature = "prompt-templates")]
659fn resolve_inline_description_impl(attr: &ToolAttr) -> syn::Result<DescriptionInfo> {
660 let template_lit = attr
661 .prompt_inline
662 .as_ref()
663 .expect("prompt_inline validated");
664 let source = template_lit.value();
665 let trimmed = source.trim_start();
666 if !trimmed.starts_with("---") {
667 return Ok(DescriptionInfo {
668 static_description: source,
669 helper_tokens: quote! {},
670 description_method: None,
671 dep_tracking: quote! {},
672 });
673 }
674
675 let (fm, body) = prompt_templates::parse_frontmatter(&source)
676 .map_err(|e| syn::Error::new(template_lit.span(), format!("inline template error: {e}")))?;
677
678 let body_str = body.trim().to_string();
679
680 let has_params = attr.has_inline_params;
681 let has_context = attr.has_context_fn;
682 let has_declarations = !fm.declarations.is_empty();
683
684 if !has_declarations && !has_params && !has_context {
685 Ok(DescriptionInfo {
687 static_description: body_str,
688 helper_tokens: quote! {},
689 description_method: None,
690 dep_tracking: quote! {},
691 })
692 } else if has_params {
693 resolve_template_with_params(
695 attr,
696 &fm,
697 &source,
698 "<inline>",
699 template_lit.span(),
700 quote! {},
701 )
702 } else if has_context {
703 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
705 let base_dir = std::path::Path::new(&manifest_dir);
706 let ast = template_compile::compile_template_to_ast(&source, base_dir).map_err(|e| {
707 syn::Error::new(
708 template_lit.span(),
709 format!("inline template compilation error: {e}"),
710 )
711 })?;
712 let tmpl_tokens = template_codegen::codegen_template(&ast);
713
714 let context_fn = attr.context_fn.as_ref().unwrap();
715
716 let description_method = quote! {
717 fn description(&self) -> ::llm_tool::__private::Cow<'static, str> {
718 static TEMPLATE: ::llm_tool::__private::Lazy<::llm_tool::__prompt_templates::Template> =
719 ::llm_tool::__private::Lazy::new(|| #tmpl_tokens);
720 let ctx = #context_fn(self);
721 let rendered = TEMPLATE.render_ctx(&ctx)
722 .expect("Failed to render tool description template");
723 ::llm_tool::__private::Cow::Owned(rendered)
724 }
725 };
726
727 Ok(DescriptionInfo {
728 static_description: body_str.clone(),
729 helper_tokens: quote! {},
730 description_method: Some(description_method),
731 dep_tracking: quote! {},
732 })
733 } else {
734 let declared: Vec<&str> = fm.declarations.iter().map(|d| d.name.as_str()).collect();
735 Err(syn::Error::new(
736 template_lit.span(),
737 format!(
738 "inline template declares parameters ({}) but neither \
739 `params(...)` nor `context = ...` was provided",
740 declared.join(", ")
741 ),
742 ))
743 }
744}
745
746#[cfg(feature = "prompt-templates")]
747struct ResolveContextArgs<'a> {
748 attr: &'a ToolAttr,
749 rel_path: &'a str,
750 template_lit: &'a LitStr,
751 source: &'a str,
752 full_path: &'a std::path::Path,
753 body_str: &'a str,
754 has_declarations: bool,
755 dep_tracking: proc_macro2::TokenStream,
756}
757
758#[cfg(feature = "prompt-templates")]
764fn resolve_context_description(args: ResolveContextArgs<'_>) -> syn::Result<DescriptionInfo> {
765 let ResolveContextArgs {
766 attr,
767 rel_path,
768 template_lit,
769 source,
770 full_path,
771 body_str,
772 has_declarations,
773 dep_tracking,
774 } = args;
775 let context_fn = attr.context_fn.as_ref().ok_or_else(|| {
776 syn::Error::new(
777 template_lit.span(),
778 "internal error: resolve_context_description called without context_fn",
779 )
780 })?;
781
782 if !has_declarations {
783 return Err(syn::Error::new(
784 template_lit.span(),
785 format!(
786 "template '{rel_path}' has no declared parameters, \
787 so `context = ...` is unnecessary. Remove `context` \
788 or add params to the template."
789 ),
790 ));
791 }
792
793 let base_dir = full_path.parent().unwrap_or(std::path::Path::new("."));
794 let ast = template_compile::compile_template_to_ast(source, base_dir).map_err(|e| {
795 syn::Error::new(
796 template_lit.span(),
797 format!("template '{rel_path}' compilation error: {e}"),
798 )
799 })?;
800 let tmpl_tokens = template_codegen::codegen_template(&ast);
801
802 let description_method = quote! {
805 fn description(&self) -> ::llm_tool::__private::Cow<'static, str> {
806 static TEMPLATE: ::llm_tool::__private::Lazy<::llm_tool::__prompt_templates::Template> =
807 ::llm_tool::__private::Lazy::new(|| #tmpl_tokens);
808 let ctx = #context_fn(self);
809 let rendered = TEMPLATE.render_ctx(&ctx)
810 .expect("Failed to render tool description template");
811 ::llm_tool::__private::Cow::Owned(rendered)
812 }
813 };
814
815 Ok(DescriptionInfo {
816 static_description: body_str.to_string(),
817 helper_tokens: quote! {},
818 description_method: Some(description_method),
819 dep_tracking,
820 })
821}
822
823#[cfg(feature = "prompt-templates")]
830fn resolve_template_with_params(
831 attr: &ToolAttr,
832 fm: &prompt_templates::Frontmatter,
833 source: &str,
834 rel_path: &str,
835 span: proc_macro2::Span,
836 dep_tracking: proc_macro2::TokenStream,
837) -> syn::Result<DescriptionInfo> {
838 let declared_names: std::collections::HashSet<&str> =
839 fm.declarations.iter().map(|d| d.name.as_str()).collect();
840 let provided_names: std::collections::HashSet<String> = attr
841 .inline_params
842 .iter()
843 .map(|(k, _)| k.to_string())
844 .collect();
845
846 let missing: Vec<&str> = declared_names
848 .iter()
849 .filter(|n| !provided_names.contains(**n))
850 .copied()
851 .collect();
852 if !missing.is_empty() {
853 return Err(syn::Error::new(
854 span,
855 format!(
856 "template '{rel_path}' declares parameters not provided in `params(...)`: {}",
857 missing.join(", ")
858 ),
859 ));
860 }
861
862 for (key, _) in &attr.inline_params {
864 let key_str = key.to_string();
865 if !declared_names.contains(key_str.as_str()) {
866 return Err(syn::Error::new(
867 key.span(),
868 format!(
869 "param `{key_str}` is not declared in template '{rel_path}'. \
870 Declared params: {}",
871 declared_names.into_iter().collect::<Vec<_>>().join(", ")
872 ),
873 ));
874 }
875 }
876
877 let template = prompt_templates::Template::from_source(source)
879 .map_err(|e| syn::Error::new(span, format!("template '{rel_path}' parse error: {e}")))?;
880
881 let mut ctx = prompt_templates::Context::new();
882 for (key, value) in &attr.inline_params {
883 ctx.set(key.to_string(), value.value());
884 }
885
886 let rendered = template
887 .render_ctx(&ctx)
888 .map_err(|e| syn::Error::new(span, format!("template '{rel_path}' render error: {e}")))?;
889
890 Ok(DescriptionInfo {
891 static_description: rendered,
892 helper_tokens: quote! {},
893 description_method: None,
894 dep_tracking,
895 })
896}
897
898fn build_param_types_and_borrows(
900 params: &[&ParamInfo],
901) -> (Vec<proc_macro2::TokenStream>, Vec<proc_macro2::TokenStream>) {
902 params
903 .iter()
904 .map(|p| {
905 if is_str_ref(&p.ty) {
906 let name = &p.name;
908 (quote! { String }, quote! { let #name: &str = &#name; })
909 } else {
910 let ty = &p.ty;
911 (quote! { #ty }, quote! {})
912 }
913 })
914 .unzip()
915}
916
917fn build_serde_defaults(params: &[&ParamInfo]) -> Vec<proc_macro2::TokenStream> {
919 params
920 .iter()
921 .map(|p| {
922 if is_option_type(&p.ty) {
923 quote! { #[serde(default)] }
924 } else {
925 quote! {}
926 }
927 })
928 .collect()
929}
930
931fn build_body_tokens(
942 func: &ItemFn,
943 return_info: &ReturnInfo,
944 crate_path: &proc_macro2::TokenStream,
945 response_info: &ResponseTemplateInfo,
946) -> proc_macro2::TokenStream {
947 let is_async = func.sig.asyncness.is_some();
948 let body_stmts = &func.block.stmts;
949
950 match return_info {
951 ReturnInfo::ResultType { ok_type, err_type } => {
952 let inner = if is_async {
953 quote! {
954 let __r: ::core::result::Result<#ok_type, #err_type> = async move {
955 #( #body_stmts )*
956 }.await;
957 }
958 } else {
959 quote! {
960 let __r: ::core::result::Result<#ok_type, #err_type> = (|| { #( #body_stmts )* })();
961 }
962 };
963 let ok_branch = build_ok_branch(crate_path, response_info);
964 quote! {
965 #inner
966 match __r {
967 ::core::result::Result::Ok(__v) => { #ok_branch },
968 ::core::result::Result::Err(__e) => ::core::result::Result::Err(::core::convert::Into::into(__e)),
969 }
970 }
971 }
972 ReturnInfo::BareType => {
973 let inner = if is_async {
974 quote! {
975 let __v = async move { #( #body_stmts )* }.await;
976 }
977 } else {
978 quote! {
979 let __v = (|| { #( #body_stmts )* })();
980 }
981 };
982 let ok_branch = build_ok_branch(crate_path, response_info);
983 quote! {
984 #inner
985 #ok_branch
986 }
987 }
988 }
989}
990
991fn build_ok_branch(
994 crate_path: &proc_macro2::TokenStream,
995 response_info: &ResponseTemplateInfo,
996) -> proc_macro2::TokenStream {
997 if let Some(ref render_tokens) = response_info.render_tokens {
998 render_tokens.clone()
999 } else {
1000 quote! { #crate_path::__private::Wrap(__v).__convert() }
1001 }
1002}
1003
1004struct ResponseTemplateInfo {
1008 dep_tracking: proc_macro2::TokenStream,
1010 helper_tokens: proc_macro2::TokenStream,
1012 render_tokens: Option<proc_macro2::TokenStream>,
1015}
1016
1017impl Default for ResponseTemplateInfo {
1018 fn default() -> Self {
1019 Self {
1020 dep_tracking: quote! {},
1021 helper_tokens: quote! {},
1022 render_tokens: None,
1023 }
1024 }
1025}
1026
1027#[allow(unused_variables)]
1028fn resolve_response_template(
1029 attr: Option<&ToolAttr>,
1030 struct_name: &syn::Ident,
1031 fn_name: &syn::Ident,
1032) -> syn::Result<ResponseTemplateInfo> {
1033 let Some(attr) = attr else {
1034 return Ok(ResponseTemplateInfo::default());
1035 };
1036
1037 if let Some(response_path) = &attr.response_file_path {
1038 #[cfg(not(feature = "prompt-templates"))]
1039 {
1040 return Err(syn::Error::new(
1041 response_path.span(),
1042 "the `prompt-templates` feature must be enabled to use `response_file`",
1043 ));
1044 }
1045 #[cfg(feature = "prompt-templates")]
1046 {
1047 return resolve_response_template_file(response_path, struct_name, fn_name);
1048 }
1049 }
1050 if let Some(response_inline) = &attr.response_inline {
1051 #[cfg(not(feature = "prompt-templates"))]
1052 {
1053 return Err(syn::Error::new(
1054 response_inline.span(),
1055 "the `prompt-templates` feature must be enabled to use `response`",
1056 ));
1057 }
1058 #[cfg(feature = "prompt-templates")]
1059 {
1060 return resolve_response_template_inline(response_inline, struct_name, fn_name);
1061 }
1062 }
1063 Ok(ResponseTemplateInfo::default())
1064}
1065
1066#[cfg(feature = "prompt-templates")]
1068fn resolve_response_template_file(
1069 response_path: &LitStr,
1070 struct_name: &syn::Ident,
1071 fn_name: &syn::Ident,
1072) -> syn::Result<ResponseTemplateInfo> {
1073 let rel_path = response_path.value();
1074 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
1075 let full_path = std::path::Path::new(&manifest_dir).join(&rel_path);
1076 let path_str = full_path.to_string_lossy().to_string();
1077
1078 let source = std::fs::read_to_string(&full_path).map_err(|e| {
1080 syn::Error::new(
1081 response_path.span(),
1082 format!(
1083 "failed to read response template '{}': {e}",
1084 full_path.display()
1085 ),
1086 )
1087 })?;
1088
1089 let dep_tracking = quote! {
1090 const _: &str = include_str!(#path_str);
1091 };
1092
1093 let (fm, _) = prompt_templates::parse_frontmatter(&source).map_err(|e| {
1094 syn::Error::new(
1095 response_path.span(),
1096 format!("response template '{rel_path}' frontmatter error: {e}"),
1097 )
1098 })?;
1099
1100 let response_struct_name_str = format!("{struct_name}Response");
1101 let generated_idents = response_struct_gen::collect_generated_type_names(
1102 &response_struct_name_str,
1103 &fm.declarations,
1104 );
1105
1106 let response_struct_name = format_ident!("{}", response_struct_name_str);
1107 let response_mod_name = format_ident!("__{}_response_mod", fn_name);
1108
1109 let helper_tokens = quote! {
1110 ::llm_tool::__prompt_templates_macros::include_template!(
1111 #path_str as #response_struct_name => #response_mod_name,
1112 crate = ::llm_tool::__prompt_templates
1113 );
1114 pub use #response_mod_name::{ #( #generated_idents ),* };
1115 };
1116
1117 let render_tokens = quote! {
1118 {
1119 let __rendered = #response_mod_name::template().render(&__v)
1120 .map_err(|e| ::llm_tool::ToolError::new(
1121 format!("response template render error: {e}")
1122 ))?;
1123 ::llm_tool::ToolOutput::new(__rendered)
1124 .with_metadata(&__v)
1125 .map_err(|e| ::llm_tool::ToolError::new(
1126 format!("response metadata error: {e}")
1127 ))
1128 }
1129 };
1130
1131 Ok(ResponseTemplateInfo {
1132 dep_tracking,
1133 helper_tokens,
1134 render_tokens: Some(render_tokens),
1135 })
1136}
1137
1138#[cfg(feature = "prompt-templates")]
1140fn resolve_response_template_inline(
1141 response_inline: &LitStr,
1142 struct_name: &syn::Ident,
1143 fn_name: &syn::Ident,
1144) -> syn::Result<ResponseTemplateInfo> {
1145 let source = response_inline.value();
1146
1147 let fm = match prompt_templates::parse_frontmatter(&source) {
1149 Ok((fm, _)) => fm,
1150 Err(e) => {
1151 return Err(syn::Error::new(
1152 response_inline.span(),
1153 format!("inline response template error: {e}"),
1154 ));
1155 }
1156 };
1157
1158 let response_struct_name_str = format!("{struct_name}Response");
1159 let generated_idents = response_struct_gen::collect_generated_type_names(
1160 &response_struct_name_str,
1161 &fm.declarations,
1162 );
1163
1164 let response_struct_name = format_ident!("{}", response_struct_name_str);
1165 let response_mod_name = format_ident!("__{}_response_mod", fn_name);
1166
1167 let helper_tokens = quote! {
1168 ::llm_tool::__prompt_templates_macros::template!(
1169 #response_inline as #response_struct_name => #response_mod_name,
1170 crate = ::llm_tool::__prompt_templates
1171 );
1172 pub use #response_mod_name::{ #( #generated_idents ),* };
1173 };
1174
1175 let render_tokens = quote! {
1176 {
1177 let __rendered = #response_mod_name::template().render(&__v)
1178 .map_err(|e| ::llm_tool::ToolError::new(
1179 format!("response template render error: {e}")
1180 ))?;
1181 ::llm_tool::ToolOutput::new(__rendered)
1182 .with_metadata(&__v)
1183 .map_err(|e| ::llm_tool::ToolError::new(
1184 format!("response metadata error: {e}")
1185 ))
1186 }
1187 };
1188
1189 Ok(ResponseTemplateInfo {
1190 dep_tracking: quote! {},
1191 helper_tokens,
1192 render_tokens: Some(render_tokens),
1193 })
1194}
1195
1196fn is_option_type(ty: &syn::Type) -> bool {
1198 let Type::Path(type_path) = ty else {
1199 return false;
1200 };
1201 let Some(last_seg) = type_path.path.segments.last() else {
1202 return false;
1203 };
1204 if last_seg.ident != "Option" {
1205 return false;
1206 }
1207 matches!(&last_seg.arguments, PathArguments::AngleBracketed(args)
1208 if args.args.len() == 1
1209 && matches!(args.args.first(), Some(GenericArgument::Type(_))))
1210}
1211
1212fn is_tool_context_type(ty: &syn::Type) -> bool {
1215 let inner = match ty {
1216 Type::Reference(r) => r.elem.as_ref(),
1217 other => other,
1218 };
1219 let Type::Path(type_path) = inner else {
1220 return false;
1221 };
1222 type_path
1223 .path
1224 .segments
1225 .last()
1226 .is_some_and(|seg| seg.ident == "ToolContext")
1227}
1228
1229fn is_str_ref(ty: &syn::Type) -> bool {
1231 let Type::Reference(ref_type) = ty else {
1232 return false;
1233 };
1234 if ref_type.mutability.is_some() {
1235 return false;
1236 }
1237 let Type::Path(type_path) = ref_type.elem.as_ref() else {
1238 return false;
1239 };
1240 type_path
1241 .path
1242 .segments
1243 .last()
1244 .is_some_and(|seg| seg.ident == "str" && seg.arguments.is_none())
1245}
1246
1247fn is_explicit_context_attr(attr: &syn::Attribute) -> syn::Result<bool> {
1248 if !attr.path().is_ident("llm_tool") {
1249 return Ok(false);
1250 }
1251 let mut is_context = false;
1252 attr.parse_nested_meta(|meta| {
1253 if meta.path.is_ident("context") {
1254 is_context = true;
1255 Ok(())
1256 } else {
1257 Err(meta.error("unsupported llm_tool attribute"))
1258 }
1259 })?;
1260 Ok(is_context)
1261}
1262
1263fn extract_params(func: &ItemFn) -> syn::Result<Vec<ParamInfo>> {
1264 let mut params = Vec::new();
1265 for arg in &func.sig.inputs {
1266 match arg {
1267 FnArg::Receiver(r) => {
1268 return Err(syn::Error::new_spanned(
1269 r,
1270 "#[llm_tool] functions must be free functions (no `self`)",
1271 ));
1272 }
1273 FnArg::Typed(PatType { pat, ty, attrs, .. }) => {
1274 let name = match pat.as_ref() {
1275 Pat::Ident(ident) => ident.ident.clone(),
1276 other => {
1277 return Err(syn::Error::new_spanned(
1278 other,
1279 "#[llm_tool] parameters must be simple identifiers",
1280 ));
1281 }
1282 };
1283
1284 let mut has_context_attr = false;
1285 for a in attrs {
1286 has_context_attr |= is_explicit_context_attr(a)?;
1287 }
1288 let is_tool_context = is_tool_context_type(ty);
1289 let is_context = has_context_attr || is_tool_context;
1290
1291 if is_tool_context && !matches!(ty.as_ref(), syn::Type::Reference(_)) {
1292 return Err(syn::Error::new_spanned(
1293 ty,
1294 "ToolContext parameter must be a reference type (e.g., `&ToolContext` or `&'a ToolContext`)",
1295 ));
1296 }
1297
1298 let doc_attrs: Vec<syn::Attribute> = attrs
1299 .iter()
1300 .filter(|a| a.path().is_ident("doc"))
1301 .cloned()
1302 .collect();
1303 params.push(ParamInfo {
1304 name,
1305 ty: ty.clone(),
1306 doc_attrs,
1307 is_context,
1308 });
1309 }
1310 }
1311 }
1312 Ok(params)
1313}
1314
1315fn extract_doc_string(attrs: &[syn::Attribute]) -> String {
1316 let lines: Vec<String> = attrs
1317 .iter()
1318 .filter_map(|attr| {
1319 if !attr.path().is_ident("doc") {
1320 return None;
1321 }
1322 if let syn::Meta::NameValue(nv) = &attr.meta
1323 && let syn::Expr::Lit(lit) = &nv.value
1324 && let syn::Lit::Str(s) = &lit.lit
1325 {
1326 return Some(s.value());
1327 }
1328 None
1329 })
1330 .collect();
1331 lines
1332 .iter()
1333 .map(|l| l.trim())
1334 .collect::<Vec<_>>()
1335 .join("\n")
1336 .trim()
1337 .to_string()
1338}
1339
1340fn parse_return_type(func: &ItemFn) -> syn::Result<ReturnInfo> {
1342 let syn::ReturnType::Type(_, ty) = &func.sig.output else {
1343 return Err(syn::Error::new_spanned(
1344 &func.sig,
1345 "#[llm_tool] functions must have an explicit return type",
1346 ));
1347 };
1348
1349 if let Some(result_types) = try_extract_result_types(ty) {
1351 return Ok(result_types);
1352 }
1353
1354 Ok(ReturnInfo::BareType)
1356}
1357
1358fn try_extract_result_types(ty: &syn::Type) -> Option<ReturnInfo> {
1361 let Type::Path(type_path) = ty else {
1362 return None;
1363 };
1364
1365 let last_seg = type_path.path.segments.last()?;
1366
1367 if last_seg.ident != "Result" {
1368 return None;
1369 }
1370
1371 let PathArguments::AngleBracketed(args) = &last_seg.arguments else {
1372 return None;
1373 };
1374
1375 if args.args.len() != 2 {
1376 return None;
1377 }
1378
1379 let GenericArgument::Type(ok_type) = &args.args[0] else {
1380 return None;
1381 };
1382
1383 let GenericArgument::Type(err_type) = &args.args[1] else {
1384 return None;
1385 };
1386
1387 Some(ReturnInfo::ResultType {
1388 ok_type: Box::new(ok_type.clone()),
1389 err_type: Box::new(err_type.clone()),
1390 })
1391}