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