1mod prompt_macro;
11mod resource_macro;
12mod response_struct_gen;
13
14use convert_case::{Case, Casing};
15use proc_macro::TokenStream;
16use quote::{format_ident, quote};
17use syn::{Ident, ItemFn, LitStr, parse_macro_input};
18
19#[proc_macro_attribute]
127pub fn llm_tool(attr: TokenStream, item: TokenStream) -> TokenStream {
128 let func = parse_macro_input!(item as ItemFn);
129 let tool_attr = if attr.is_empty() {
130 None
131 } else {
132 match syn::parse::<ToolAttr>(attr) {
133 Ok(parsed) => Some(parsed),
134 Err(err) => return err.to_compile_error().into(),
135 }
136 };
137 match tool_impl(&func, tool_attr.as_ref()) {
138 Ok(tokens) => tokens.into(),
139 Err(err) => err.to_compile_error().into(),
140 }
141}
142
143#[proc_macro_attribute]
145pub fn llm_prompt(attr: TokenStream, item: TokenStream) -> TokenStream {
146 let func = parse_macro_input!(item as ItemFn);
147 let tool_attr = if attr.is_empty() {
148 None
149 } else {
150 match syn::parse::<ToolAttr>(attr) {
151 Ok(parsed) => Some(parsed),
152 Err(err) => return err.to_compile_error().into(),
153 }
154 };
155 match prompt_macro::prompt_impl(&func, tool_attr.as_ref()) {
156 Ok(tokens) => tokens.into(),
157 Err(err) => err.to_compile_error().into(),
158 }
159}
160
161#[proc_macro_attribute]
163pub fn llm_resource(attr: TokenStream, item: TokenStream) -> TokenStream {
164 let func = parse_macro_input!(item as ItemFn);
165 let res_attr = match syn::parse::<resource_macro::ResourceAttr>(attr) {
166 Ok(parsed) => parsed,
167 Err(err) => return err.to_compile_error().into(),
168 };
169 match resource_macro::resource_impl(&func, &res_attr) {
170 Ok(tokens) => tokens.into(),
171 Err(err) => err.to_compile_error().into(),
172 }
173}
174
175struct ToolAttr {
187 description_inline: Option<LitStr>,
189 description_file_path: Option<LitStr>,
191 response_file_path: Option<LitStr>,
193 response_inline: Option<LitStr>,
195 inline_params: Vec<(Ident, LitStr)>,
198 env_vars: Vec<(Ident, syn::Lit)>,
200 context_fn: Option<syn::Path>,
202 idempotent: bool,
203 effect: MacroToolEffect,
204 sanitization: MacroOutputSanitization,
205}
206
207#[derive(Copy, Clone, PartialEq, Eq, Debug)]
208pub(crate) enum MacroToolEffect {
209 ReadOnly,
210 Mutating,
211 Destructive,
212}
213
214#[derive(Clone, PartialEq, Eq, Debug, Default)]
215pub(crate) enum MacroOutputSanitization {
216 #[default]
217 None,
218 SanitizeTokens,
219 Quarantine,
220 SanitizeTagged(syn::LitStr),
221 SanitizeWithNotice {
222 tag: syn::LitStr,
223 notice: syn::LitStr,
224 },
225}
226
227fn parse_effect_str(s: &str, span: proc_macro2::Span) -> syn::Result<MacroToolEffect> {
228 match s {
229 "read_only" | "ReadOnly" => Ok(MacroToolEffect::ReadOnly),
230 "mutating" | "Mutating" => Ok(MacroToolEffect::Mutating),
231 "destructive" | "Destructive" => Ok(MacroToolEffect::Destructive),
232 _ => Err(syn::Error::new(
233 span,
234 "expected `read_only`, `mutating`, or `destructive`",
235 )),
236 }
237}
238
239fn parse_sanitization_str(
240 s: &str,
241 span: proc_macro2::Span,
242) -> syn::Result<MacroOutputSanitization> {
243 match s {
244 "none" | "None" => Ok(MacroOutputSanitization::None),
245 "sanitize_tokens" | "sanitize" | "SanitizeTokens" => {
246 Ok(MacroOutputSanitization::SanitizeTokens)
247 }
248 "quarantine" | "Quarantine" => Ok(MacroOutputSanitization::Quarantine),
249 _ => Err(syn::Error::new(
250 span,
251 "expected `none`, `sanitize_tokens`, or `quarantine`",
252 )),
253 }
254}
255
256pub(crate) const MACRO_LLM_TOOL: &str = "llm_tool";
257pub(crate) const MACRO_LLM_PROMPT: &str = "llm_prompt";
258pub(crate) const MACRO_LLM_RESOURCE: &str = "llm_resource";
259
260pub(crate) const ATTR_DESCRIPTION: &str = "description";
261pub(crate) const ATTR_DESCRIPTION_FILE: &str = "description_file";
262pub(crate) const ATTR_RESPONSE_FILE: &str = "response_file";
263pub(crate) const ATTR_RESPONSE: &str = "response";
264pub(crate) const ATTR_PARAMS: &str = "params";
265pub(crate) const ATTR_CONTEXT: &str = "context";
266pub(crate) const ATTR_ENV: &str = "env";
267pub(crate) const ATTR_IDEMPOTENT: &str = "idempotent";
268pub(crate) const ATTR_EFFECT: &str = "effect";
269pub(crate) const ATTR_SANITIZE: &str = "sanitize";
270pub(crate) const ATTR_QUARANTINE: &str = "quarantine";
271pub(crate) const ATTR_SANITIZATION: &str = "sanitization";
272pub(crate) const ATTR_DOC: &str = "doc";
273
274pub(crate) const TYPE_OPTION: &str = "Option";
275pub(crate) const TYPE_TOOL_CONTEXT: &str = "ToolContext";
276pub(crate) const TYPE_STR: &str = "str";
277pub(crate) const TYPE_RESULT: &str = "Result";
278
279#[derive(Copy, Clone, PartialEq, Eq, Debug)]
280pub(crate) enum ToolAttrKey {
281 Description,
282 DescriptionFile,
283 ResponseFile,
284 Response,
285 Params,
286 Env,
287 Context,
288 Idempotent,
289 Effect,
290 Sanitize,
291 Quarantine,
292 Sanitization,
293}
294
295impl ToolAttrKey {
296 pub(crate) const ALL: &'static [Self] = &[
297 Self::Description,
298 Self::DescriptionFile,
299 Self::Response,
300 Self::ResponseFile,
301 Self::Params,
302 Self::Env,
303 Self::Context,
304 Self::Idempotent,
305 Self::Effect,
306 Self::Sanitize,
307 Self::Quarantine,
308 Self::Sanitization,
309 ];
310
311 pub(crate) const fn as_str(self) -> &'static str {
312 match self {
313 Self::Description => ATTR_DESCRIPTION,
314 Self::DescriptionFile => ATTR_DESCRIPTION_FILE,
315 Self::ResponseFile => ATTR_RESPONSE_FILE,
316 Self::Response => ATTR_RESPONSE,
317 Self::Params => ATTR_PARAMS,
318 Self::Env => ATTR_ENV,
319 Self::Context => ATTR_CONTEXT,
320 Self::Idempotent => ATTR_IDEMPOTENT,
321 Self::Effect => ATTR_EFFECT,
322 Self::Sanitize => ATTR_SANITIZE,
323 Self::Quarantine => ATTR_QUARANTINE,
324 Self::Sanitization => ATTR_SANITIZATION,
325 }
326 }
327
328 pub(crate) fn expected_keys_error(span: proc_macro2::Span) -> syn::Error {
329 let mut parts: Vec<String> = Self::ALL
330 .iter()
331 .map(|k| format!("`{}`", k.as_str()))
332 .collect();
333 let last = parts.pop().expect("ToolAttrKey::ALL is non-empty");
334 let formatted = if parts.is_empty() {
335 last
336 } else {
337 format!("{}, or {last}", parts.join(", "))
338 };
339 syn::Error::new(span, format!("expected {formatted}"))
340 }
341}
342
343impl TryFrom<&syn::Ident> for ToolAttrKey {
344 type Error = syn::Error;
345
346 fn try_from(ident: &syn::Ident) -> Result<Self, Self::Error> {
347 let s = ident.to_string();
348 for &variant in Self::ALL {
349 if s == variant.as_str() {
350 return Ok(variant);
351 }
352 }
353 Err(Self::expected_keys_error(ident.span()))
354 }
355}
356
357#[derive(Default)]
358pub(crate) struct ToolAttrBuilder {
359 description_inline: Option<syn::LitStr>,
360 description_file_path: Option<syn::LitStr>,
361 response_file_path: Option<syn::LitStr>,
362 response_inline: Option<syn::LitStr>,
363 idempotent: Option<syn::LitBool>,
364 effect: Option<(MacroToolEffect, proc_macro2::Span)>,
365 sanitization: Option<(MacroOutputSanitization, proc_macro2::Span)>,
366 has_explicit_sanitization: bool,
367 inline_params: Vec<(syn::Ident, syn::LitStr)>,
368 env_vars: Vec<(syn::Ident, syn::Lit)>,
369 context_fn: Option<syn::Path>,
370}
371
372impl ToolAttrBuilder {
373 fn parse_params_attr(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
374 let content;
375 syn::parenthesized!(content in input);
376 while !content.is_empty() {
377 let key: syn::Ident = content.parse()?;
378 let _: syn::Token![=] = content.parse()?;
379 let value: syn::LitStr = content.parse()?;
380 self.inline_params.push((key, value));
381 if !content.is_empty() {
382 let _: syn::Token![,] = content.parse()?;
383 }
384 }
385 Ok(())
386 }
387
388 fn parse_env_attr(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
389 let content;
390 syn::parenthesized!(content in input);
391 while !content.is_empty() {
392 let key: syn::Ident = content.parse()?;
393 let _: syn::Token![=] = content.parse()?;
394 let value: syn::Lit = content.parse()?;
395 match &value {
396 syn::Lit::Str(_) | syn::Lit::Int(_) | syn::Lit::Float(_) | syn::Lit::Bool(_) => {}
397 other => {
398 return Err(syn::Error::new(
399 other.span(),
400 "env values must be string, integer, float, or bool literals",
401 ));
402 }
403 }
404 self.env_vars.push((key, value));
405 if !content.is_empty() {
406 let _: syn::Token![,] = content.parse()?;
407 }
408 }
409 Ok(())
410 }
411
412 fn parse_single(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
413 let ident: syn::Ident = input.parse()?;
414 let key = ToolAttrKey::try_from(&ident)?;
415 self.parse_by_key(&ident, key, input)
416 }
417
418 fn parse_lit_str_attr(
419 field: &mut Option<syn::LitStr>,
420 ident: &syn::Ident,
421 key: ToolAttrKey,
422 input: syn::parse::ParseStream,
423 ) -> syn::Result<()> {
424 let _: syn::Token![=] = input.parse()?;
425 if field.is_some() {
426 return Err(syn::Error::new(
427 ident.span(),
428 format!("duplicate `{}` attribute", key.as_str()),
429 ));
430 }
431 *field = Some(input.parse::<syn::LitStr>()?);
432 Ok(())
433 }
434
435 fn parse_context_attr(
436 &mut self,
437 ident: &syn::Ident,
438 key: ToolAttrKey,
439 input: syn::parse::ParseStream,
440 ) -> syn::Result<()> {
441 let _: syn::Token![=] = input.parse()?;
442 if self.context_fn.is_some() {
443 return Err(syn::Error::new(
444 ident.span(),
445 format!("duplicate `{}` attribute", key.as_str()),
446 ));
447 }
448 self.context_fn = Some(input.parse::<syn::Path>()?);
449 Ok(())
450 }
451
452 fn parse_effect_attr(
453 &mut self,
454 ident: &syn::Ident,
455 key: ToolAttrKey,
456 input: syn::parse::ParseStream,
457 ) -> syn::Result<()> {
458 let _: syn::Token![=] = input.parse()?;
459 if self.effect.is_some() {
460 return Err(syn::Error::new(
461 ident.span(),
462 format!("duplicate `{}` attribute", key.as_str()),
463 ));
464 }
465 let span = input.span();
466 let eff = if input.peek(syn::Ident) {
467 let id: syn::Ident = input.parse()?;
468 parse_effect_str(id.to_string().as_str(), id.span())?
469 } else if input.peek(syn::LitStr) {
470 let lit: syn::LitStr = input.parse()?;
471 parse_effect_str(lit.value().as_str(), lit.span())?
472 } else {
473 return Err(syn::Error::new(
474 span,
475 "expected identifier or string literal (`read_only`, `mutating`, or `destructive`)",
476 ));
477 };
478 self.effect = Some((eff, span));
479 Ok(())
480 }
481
482 fn parse_idempotent_attr(
483 &mut self,
484 ident: &syn::Ident,
485 key: ToolAttrKey,
486 input: syn::parse::ParseStream,
487 ) -> syn::Result<()> {
488 if self.idempotent.is_some() {
489 return Err(syn::Error::new(
490 ident.span(),
491 format!("duplicate `{}` attribute", key.as_str()),
492 ));
493 }
494 if input.peek(syn::Token![=]) {
495 let _: syn::Token![=] = input.parse()?;
496 self.idempotent = Some(input.parse::<syn::LitBool>()?);
497 } else {
498 self.idempotent = Some(syn::LitBool::new(true, ident.span()));
499 }
500 Ok(())
501 }
502
503 fn parse_sanitize_attr(
504 &mut self,
505 ident: &syn::Ident,
506 key: ToolAttrKey,
507 input: syn::parse::ParseStream,
508 ) -> syn::Result<()> {
509 if self.has_explicit_sanitization {
510 return Err(syn::Error::new(
511 ident.span(),
512 "`sanitization` cannot be combined with `sanitize` or `quarantine`",
513 ));
514 }
515 if self.sanitization.is_some() {
516 return Err(syn::Error::new(
517 ident.span(),
518 format!("duplicate `{}` attribute", key.as_str()),
519 ));
520 }
521 if input.peek(syn::Token![=]) {
522 let _: syn::Token![=] = input.parse()?;
523 if input.peek(syn::LitBool) {
524 let lit = input.parse::<syn::LitBool>()?;
525 let mode = if lit.value() {
526 MacroOutputSanitization::SanitizeTokens
527 } else {
528 MacroOutputSanitization::None
529 };
530 self.sanitization = Some((mode, ident.span()));
531 } else if input.peek(syn::LitStr) {
532 let lit = input.parse::<syn::LitStr>()?;
533 let val = lit.value();
534 let mode = match val.as_str() {
535 "none" | "None" => MacroOutputSanitization::None,
536 "sanitize_tokens" | "sanitize" | "SanitizeTokens" => {
537 MacroOutputSanitization::SanitizeTokens
538 }
539 "quarantine" | "Quarantine" => MacroOutputSanitization::Quarantine,
540 _ => MacroOutputSanitization::SanitizeTagged(lit),
541 };
542 self.sanitization = Some((mode, ident.span()));
543 } else if input.peek(syn::Ident) {
544 let id: syn::Ident = input.parse()?;
545 let mode = parse_sanitization_str(&id.to_string(), id.span())?;
546 self.sanitization = Some((mode, ident.span()));
547 } else {
548 return Err(syn::Error::new(
549 ident.span(),
550 "expected boolean literal or tag string (e.g. `sanitize = true` or `sanitize = \"tag\"`)",
551 ));
552 }
553 } else if input.peek(syn::token::Paren) {
554 let content;
555 syn::parenthesized!(content in input);
556 let mut tag: Option<syn::LitStr> = None;
557 let mut notice: Option<syn::LitStr> = None;
558 while !content.is_empty() {
559 let k: syn::Ident = content.parse()?;
560 let _: syn::Token![=] = content.parse()?;
561 match k.to_string().as_str() {
562 "tag" => {
563 if tag.is_some() {
564 return Err(syn::Error::new(k.span(), "duplicate `tag` argument"));
565 }
566 tag = Some(content.parse::<syn::LitStr>()?);
567 }
568 "notice" => {
569 if notice.is_some() {
570 return Err(syn::Error::new(k.span(), "duplicate `notice` argument"));
571 }
572 notice = Some(content.parse::<syn::LitStr>()?);
573 }
574 _ => {
575 return Err(syn::Error::new(
576 k.span(),
577 "expected `tag` or `notice` in `sanitize(...)`",
578 ));
579 }
580 }
581 if !content.is_empty() {
582 let _: syn::Token![,] = content.parse()?;
583 }
584 }
585 let Some(tag_lit) = tag else {
586 return Err(syn::Error::new(
587 ident.span(),
588 "`sanitize(...)` requires a `tag = \"...\"` parameter",
589 ));
590 };
591 let mode = if let Some(notice_lit) = notice {
592 MacroOutputSanitization::SanitizeWithNotice {
593 tag: tag_lit,
594 notice: notice_lit,
595 }
596 } else {
597 MacroOutputSanitization::SanitizeTagged(tag_lit)
598 };
599 self.sanitization = Some((mode, ident.span()));
600 } else {
601 self.sanitization = Some((MacroOutputSanitization::SanitizeTokens, ident.span()));
603 }
604 Ok(())
605 }
606
607 fn parse_quarantine_attr(
608 &mut self,
609 ident: &syn::Ident,
610 key: ToolAttrKey,
611 input: syn::parse::ParseStream,
612 ) -> syn::Result<()> {
613 if self.has_explicit_sanitization {
614 return Err(syn::Error::new(
615 ident.span(),
616 "`sanitization` cannot be combined with `sanitize` or `quarantine`",
617 ));
618 }
619 if self.sanitization.is_some() {
620 return Err(syn::Error::new(
621 ident.span(),
622 format!("duplicate `{}` attribute", key.as_str()),
623 ));
624 }
625 if input.peek(syn::Token![=]) {
626 let _: syn::Token![=] = input.parse()?;
627 if input.peek(syn::LitBool) {
628 let lit = input.parse::<syn::LitBool>()?;
629 let mode = if lit.value() {
630 MacroOutputSanitization::Quarantine
631 } else {
632 MacroOutputSanitization::None
633 };
634 self.sanitization = Some((mode, ident.span()));
635 } else if input.peek(syn::LitStr) {
636 let lit = input.parse::<syn::LitStr>()?;
637 let val = lit.value();
638 let mode = if val == "quarantine" || val == "Quarantine" {
639 MacroOutputSanitization::Quarantine
640 } else {
641 MacroOutputSanitization::SanitizeTagged(lit)
642 };
643 self.sanitization = Some((mode, ident.span()));
644 } else {
645 return Err(syn::Error::new(
646 ident.span(),
647 "expected boolean literal or tag string (e.g. `quarantine = true` or `quarantine = \"tag\"`)",
648 ));
649 }
650 } else {
651 self.sanitization = Some((MacroOutputSanitization::Quarantine, ident.span()));
653 }
654 Ok(())
655 }
656
657 fn parse_sanitization_attr(
658 &mut self,
659 ident: &syn::Ident,
660 key: ToolAttrKey,
661 input: syn::parse::ParseStream,
662 ) -> syn::Result<()> {
663 let _: syn::Token![=] = input.parse()?;
664 if self.has_explicit_sanitization || self.sanitization.is_some() {
665 return Err(syn::Error::new(
666 ident.span(),
667 format!("duplicate `{}` attribute", key.as_str()),
668 ));
669 }
670 let span = input.span();
671 let mode = if input.peek(syn::Ident) {
672 let id: syn::Ident = input.parse()?;
673 parse_sanitization_str(id.to_string().as_str(), id.span())?
674 } else if input.peek(syn::LitStr) {
675 let lit: syn::LitStr = input.parse()?;
676 parse_sanitization_str(lit.value().as_str(), lit.span())?
677 } else {
678 return Err(syn::Error::new(
679 span,
680 "expected identifier or string literal (`none`, `sanitize_tokens`, or `quarantine`)",
681 ));
682 };
683 self.has_explicit_sanitization = true;
684 self.sanitization = Some((mode, span));
685 Ok(())
686 }
687
688 pub(crate) fn parse_by_key(
689 &mut self,
690 ident: &syn::Ident,
691 key: ToolAttrKey,
692 input: syn::parse::ParseStream,
693 ) -> syn::Result<()> {
694 match key {
695 ToolAttrKey::Description => {
696 Self::parse_lit_str_attr(&mut self.description_inline, ident, key, input)?;
697 }
698 ToolAttrKey::DescriptionFile => {
699 Self::parse_lit_str_attr(&mut self.description_file_path, ident, key, input)?;
700 }
701 ToolAttrKey::ResponseFile => {
702 Self::parse_lit_str_attr(&mut self.response_file_path, ident, key, input)?;
703 }
704 ToolAttrKey::Response => {
705 Self::parse_lit_str_attr(&mut self.response_inline, ident, key, input)?;
706 }
707 ToolAttrKey::Params => {
708 self.parse_params_attr(input)?;
709 }
710 ToolAttrKey::Env => {
711 self.parse_env_attr(input)?;
712 }
713 ToolAttrKey::Context => {
714 self.parse_context_attr(ident, key, input)?;
715 }
716 ToolAttrKey::Idempotent => {
717 self.parse_idempotent_attr(ident, key, input)?;
718 }
719 ToolAttrKey::Effect => {
720 self.parse_effect_attr(ident, key, input)?;
721 }
722 ToolAttrKey::Sanitize => {
723 self.parse_sanitize_attr(ident, key, input)?;
724 }
725 ToolAttrKey::Quarantine => {
726 self.parse_quarantine_attr(ident, key, input)?;
727 }
728 ToolAttrKey::Sanitization => {
729 self.parse_sanitization_attr(ident, key, input)?;
730 }
731 }
732 Ok(())
733 }
734
735 pub(crate) fn build(self) -> syn::Result<ToolAttr> {
736 validate_tool_attr(&self)?;
737
738 let effect = match (self.effect, self.idempotent) {
739 (Some((eff, _)), _) => eff,
740 (None, Some(idemp)) => {
741 if idemp.value() {
742 MacroToolEffect::ReadOnly
743 } else {
744 MacroToolEffect::Mutating
745 }
746 }
747 (None, None) => MacroToolEffect::Mutating,
748 };
749 let idempotent = matches!(effect, MacroToolEffect::ReadOnly);
750
751 let sanitization = self
752 .sanitization
753 .map_or(MacroOutputSanitization::None, |(mode, _)| mode);
754
755 Ok(ToolAttr {
756 description_inline: self.description_inline,
757 description_file_path: self.description_file_path,
758 response_file_path: self.response_file_path,
759 response_inline: self.response_inline,
760 inline_params: self.inline_params,
761 env_vars: self.env_vars,
762 context_fn: self.context_fn,
763 idempotent,
764 effect,
765 sanitization,
766 })
767 }
768}
769
770impl syn::parse::Parse for ToolAttr {
771 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
772 let mut builder = ToolAttrBuilder::default();
773
774 while !input.is_empty() {
775 builder.parse_single(input)?;
776 if !input.is_empty() {
777 let _: syn::Token![,] = input.parse()?;
778 }
779 }
780
781 builder.build()
782 }
783}
784
785fn validate_tool_attr(builder: &ToolAttrBuilder) -> syn::Result<()> {
786 if builder.description_inline.is_some() && builder.description_file_path.is_some() {
787 return Err(syn::Error::new(
788 proc_macro2::Span::call_site(),
789 "`description` and `description_file` are mutually exclusive",
790 ));
791 }
792
793 if builder.response_file_path.is_some() && builder.response_inline.is_some() {
794 return Err(syn::Error::new(
795 proc_macro2::Span::call_site(),
796 "`response` and `response_file` are mutually exclusive",
797 ));
798 }
799
800 let has_inline_params = !builder.inline_params.is_empty();
801 let has_context_fn = builder.context_fn.is_some();
802 let has_env = !builder.env_vars.is_empty();
803
804 if has_inline_params && has_context_fn {
805 return Err(syn::Error::new(
806 proc_macro2::Span::call_site(),
807 "`params(...)` and `context = ...` are mutually exclusive; \
808 use `params` for compile-time values or `context` for runtime values",
809 ));
810 }
811
812 if has_inline_params
813 && builder.description_file_path.is_none()
814 && builder.description_inline.is_none()
815 {
816 return Err(syn::Error::new(
817 proc_macro2::Span::call_site(),
818 "`params(...)` requires `description_file = \"...\"` or `description = \"...\"`",
819 ));
820 }
821
822 if has_context_fn
823 && builder.description_file_path.is_none()
824 && builder.description_inline.is_none()
825 {
826 return Err(syn::Error::new(
827 proc_macro2::Span::call_site(),
828 "`context = ...` requires `description_file = \"...\"` or `description = \"...\"`",
829 ));
830 }
831
832 if has_env && builder.description_file_path.is_none() && builder.description_inline.is_none() {
833 return Err(syn::Error::new(
834 proc_macro2::Span::call_site(),
835 "`env(...)` requires `description_file = \"...\"` or `description = \"...\"`",
836 ));
837 }
838
839 if let (Some(idemp), Some((eff, span))) = (&builder.idempotent, &builder.effect) {
840 let idemp_val = idemp.value();
841 let eff_is_read_only = matches!(eff, MacroToolEffect::ReadOnly);
842 if idemp_val != eff_is_read_only {
843 return Err(syn::Error::new(
844 *span,
845 "conflicting `idempotent` and `effect` attributes: `idempotent = true` requires `effect = read_only`",
846 ));
847 }
848 }
849
850 Ok(())
851}
852
853struct ParamInfo {
857 name: syn::Ident,
858 ty: Box<syn::Type>,
859 doc_attrs: Vec<syn::Attribute>,
860 is_context: bool,
861 is_mut: bool,
862}
863
864enum ReturnInfo {
866 ResultType {
868 ok_type: Box<syn::Type>,
869 err_type: Box<syn::Type>,
870 },
871 BareType,
873}
874
875fn validate_params(all_params: &[ParamInfo], sig: &syn::Signature) -> syn::Result<()> {
876 let ctx_count = all_params.iter().filter(|p| p.is_context).count();
877 if ctx_count > 1 {
878 return Err(syn::Error::new_spanned(
879 sig,
880 "#[llm_tool] functions can accept at most one ToolContext parameter",
881 ));
882 }
883 for param in all_params.iter().filter(|p| !p.is_context) {
884 if param.doc_attrs.is_empty() {
885 return Err(syn::Error::new_spanned(
886 ¶m.name,
887 format!(
888 "#[llm_tool] parameter `{}` must have a doc comment \
889 (used as the parameter description in the JSON schema)",
890 param.name
891 ),
892 ));
893 }
894 }
895 Ok(())
896}
897
898fn resolve_effect_and_sanitization_tokens(
899 attr: Option<&ToolAttr>,
900 crate_path: &proc_macro2::TokenStream,
901) -> (
902 proc_macro2::TokenStream,
903 bool,
904 proc_macro2::TokenStream,
905 bool,
906 bool,
907) {
908 let effect_val = attr.map_or(MacroToolEffect::Mutating, |a| a.effect);
909 let effect_tokens = match effect_val {
910 MacroToolEffect::ReadOnly => quote!(#crate_path::ToolEffect::ReadOnly),
911 MacroToolEffect::Destructive => quote!(#crate_path::ToolEffect::Destructive),
912 MacroToolEffect::Mutating => quote!(#crate_path::ToolEffect::Mutating),
913 };
914 let idempotent_bool = attr.is_some_and(|a| a.idempotent);
915
916 let san_val = attr.map_or(MacroOutputSanitization::None, |a| a.sanitization.clone());
917 let san_tokens = match &san_val {
918 MacroOutputSanitization::None => quote!(#crate_path::OutputSanitization::None),
919 MacroOutputSanitization::SanitizeTokens => {
920 quote!(#crate_path::OutputSanitization::SanitizeTokens)
921 }
922 MacroOutputSanitization::Quarantine => quote!(#crate_path::OutputSanitization::Quarantine),
923 MacroOutputSanitization::SanitizeTagged(tag) => {
924 quote!(#crate_path::OutputSanitization::SanitizeTagged(#tag))
925 }
926 MacroOutputSanitization::SanitizeWithNotice { tag, notice } => {
927 quote!(#crate_path::OutputSanitization::SanitizeWithNotice { tag: #tag, notice: #notice })
928 }
929 };
930 let sanitize_bool = matches!(san_val, MacroOutputSanitization::SanitizeTokens);
931 let quarantine_bool = matches!(
932 san_val,
933 MacroOutputSanitization::Quarantine
934 | MacroOutputSanitization::SanitizeTagged(_)
935 | MacroOutputSanitization::SanitizeWithNotice { .. }
936 );
937 (
938 effect_tokens,
939 idempotent_bool,
940 san_tokens,
941 sanitize_bool,
942 quarantine_bool,
943 )
944}
945
946fn tool_impl(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<proc_macro2::TokenStream> {
947 let crate_path = quote! { ::llm_tool };
948 let fn_name = &func.sig.ident;
949 reject_generic_signature(func, MACRO_LLM_TOOL)?;
950 let tool_name_str = fn_name.to_string();
951 let struct_name = format_ident!("{}", tool_name_str.to_case(Case::Pascal));
952 let params_name = format_ident!("{}Params", struct_name);
953
954 let DescriptionInfo {
956 static_description,
957 helper_tokens,
958 description_method,
959 dep_tracking,
960 } = resolve_description(func, attr)?;
961
962 let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
964
965 let all_params = extract_params(func, MACRO_LLM_TOOL)?;
967 validate_params(&all_params, &func.sig)?;
968 let ctx_param = all_params.iter().find(|p| p.is_context);
969 let params: Vec<&ParamInfo> = all_params.iter().filter(|p| !p.is_context).collect();
970
971 let return_info = parse_return_type(func, MACRO_LLM_TOOL)?;
973
974 let param_names: Vec<_> = params.iter().map(|p| &p.name).collect();
975 let param_descriptions: Vec<String> = params
976 .iter()
977 .map(|p| extract_doc_string(&p.doc_attrs))
978 .collect();
979
980 let (param_struct_types, borrow_bindings) = build_param_types_and_borrows(¶ms);
981 let serde_defaults = build_serde_defaults(¶ms);
982 let body_tokens = build_body_tokens(func, &return_info, &crate_path, &response_info);
983
984 let vis = &func.vis;
985
986 let params_doc = format!("Auto-generated parameters for the [`{struct_name}`] tool.");
987 let struct_doc = format!(
988 "Auto-generated tool struct. See the `#[llm_tool]`-annotated function `{fn_name}` for the implementation."
989 );
990
991 let ctx_binding = if let Some(cp) = ctx_param {
994 let ctx_name = &cp.name;
995 quote! { let #ctx_name = _ctx; }
996 } else {
997 quote! {}
998 };
999
1000 let mut_tokens: Vec<proc_macro2::TokenStream> = params
1001 .iter()
1002 .map(|p| {
1003 if p.is_mut {
1004 quote! { mut }
1005 } else {
1006 quote! {}
1007 }
1008 })
1009 .collect();
1010
1011 let response_dep_tracking = &response_info.dep_tracking;
1012 let response_helper_tokens = &response_info.helper_tokens;
1013 let (effect_tokens, idempotent_bool, san_tokens, sanitize_bool, quarantine_bool) =
1014 resolve_effect_and_sanitization_tokens(attr, &crate_path);
1015
1016 Ok(quote! {
1017 #dep_tracking
1018 #response_dep_tracking
1019 #helper_tokens
1020 #response_helper_tokens
1021
1022 #[doc = #params_doc]
1023 #[derive(::serde::Deserialize, ::schemars::JsonSchema)]
1024 #vis struct #params_name {
1025 #(
1026 #[schemars(description = #param_descriptions)]
1027 #serde_defaults
1028 pub #param_names: #param_struct_types,
1029 )*
1030 }
1031
1032 #[doc = #struct_doc]
1033 #vis struct #struct_name;
1034
1035 impl #crate_path::RustTool for #struct_name {
1036 type Params = #params_name;
1037 const NAME: &'static str = #tool_name_str;
1038 const DESCRIPTION: &'static str = #static_description;
1039 const IDEMPOTENT: bool = #idempotent_bool;
1040 const EFFECT: #crate_path::ToolEffect = #effect_tokens;
1041 const SANITIZE_OUTPUT: bool = #sanitize_bool;
1042 const QUARANTINE_OUTPUT: bool = #quarantine_bool;
1043 const OUTPUT_SANITIZATION: #crate_path::OutputSanitization = #san_tokens;
1044
1045 #description_method
1046
1047 async fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
1048
1049 use #crate_path::__private::SerializeFallback as _;
1052 let #params_name { #( #mut_tokens #param_names, )* } = params;
1055 #( #borrow_bindings )*
1057 #ctx_binding
1058 let __raw_res: ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> = {
1059 #body_tokens
1060 };
1061 __raw_res.map(|__out| Self::OUTPUT_SANITIZATION.apply(__out))
1062 }
1063 }
1064 })
1065}
1066
1067struct DescriptionInfo {
1071 static_description: String,
1073 helper_tokens: proc_macro2::TokenStream,
1075 description_method: Option<proc_macro2::TokenStream>,
1077 dep_tracking: proc_macro2::TokenStream,
1079}
1080
1081pub(crate) mod desc;
1082pub(crate) mod helpers;
1083pub(crate) use desc::resolve_description;
1084pub(crate) use helpers::{
1085 build_body_tokens, build_param_types_and_borrows, build_serde_defaults, extract_doc_string,
1086 extract_params, parse_return_type, reject_generic_signature, resolve_response_template,
1087};