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