Skip to main content

llm_tool_macros/
lib.rs

1//! Proc-macro crate for `llm-tool`.
2//!
3//! Provides the `#[llm_tool]` attribute macro that transforms a plain function
4//! into a strongly-typed [`RustTool`](https://docs.rs/llm-tool/latest/llm_tool/trait.RustTool.html)
5//! implementation.
6//!
7//! Tool descriptions can be loaded from `.tmpl.md` template files via
8//! `description_file = "..."`, and tool responses can be auto-rendered through
9//! templates via `response = "..."` or `response_file = "..."`.
10mod 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/// Transforms a function into a `RustTool` implementation.
20///
21/// The macro generates:
22/// - A `{FnName}Params` struct deriving `Deserialize` and `JsonSchema`
23/// - A `{FnName}` unit struct (`PascalCase`) implementing `RustTool`
24///
25/// The tool **name** is the function name (`snake_case`).
26/// The tool **description** comes from one of the sources below.
27/// Parameter names and types come from the function signature.
28/// Doc comments on parameters become schema descriptions.
29///
30/// # Description sources (in priority order)
31///
32/// | Syntax | Cost |
33/// |--------|------|
34/// | `#[llm_tool]` + doc comment | Zero (static `&str`) |
35/// | `#[llm_tool(description = "inline text")]` | Zero (static `&str`) |
36/// | `#[llm_tool(response_file = "...")]` | Runtime render |
37/// | `#[llm_tool(description_file = "tools/x.tmpl.md")]` | Zero (compiled) |
38/// | `#[llm_tool(description_file = "...", params(k = "v"))]` | Zero (compiled) |
39/// | `#[llm_tool(description_file = "...", env(K = "v"))]` | Zero (compiled) |
40/// | `#[llm_tool(description_file = "...", context = fn)]` | Runtime `Cow::Owned` |
41///
42/// ## Inline description
43///
44/// Override or replace the doc comment with an inline string:
45///
46/// ```text
47/// #[llm_tool(description = "Get the current weather for a city.")]
48/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
49/// ```
50///
51/// ## Template descriptions
52///
53/// Load the description from a `.tmpl.md` file:
54///
55/// ```text
56/// #[llm_tool(description_file = "tools/weather.tmpl.md")]
57/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
58/// ```
59///
60/// For templates with variables, provide **compile-time** key-value pairs:
61///
62/// ```text
63/// #[llm_tool(description_file = "tools/weather.tmpl.md", params(api = "v3", env = "prod"))]
64/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
65/// ```
66///
67/// The macro reads the template, validates all declared variables are
68/// provided, renders the description, and embeds the result as a static
69/// string — **zero runtime cost**.
70///
71/// For **runtime** context (e.g. values from config), provide a context function:
72///
73/// ```text
74/// #[llm_tool(description_file = "tools/weather.tmpl.md", context = build_ctx)]
75/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
76/// ```
77///
78/// The context function signature is `fn(&ToolStruct) -> Context`.
79/// Templates are parsed once at startup via `LazyLock`.
80///
81/// ## Environment variables
82///
83/// Templates can declare `env:` variables in their frontmatter. These are
84/// separate from `params:` — they represent build-time configuration
85/// (deployment environment, API version, etc.) rather than template parameters.
86///
87/// In the template:
88/// ```text
89/// ---
90/// env:
91///   - API_VERSION = str
92///   - MAX_RETRIES = int := 3
93/// ---
94/// Uses API {{ API_VERSION }} with {{ MAX_RETRIES }} retries.
95/// ```
96///
97/// Supply values via the `env(...)` attribute:
98/// ```text
99/// #[llm_tool(description_file = "tools/api.tmpl.md", env(API_VERSION = "v5"))]
100/// fn query_api(/* … */) -> Result<String, ToolError> { /* … */ }
101/// ```
102///
103/// Env values are resolved at compile time, producing a zero-cost static
104/// description. They can be combined with `params(...)` or `context = fn`.
105///
106/// # Typed parameters
107///
108/// Parameters may use `&str` — the generated params struct stores an owned
109/// `String` and the macro auto-borrows it before passing to your function body.
110///
111/// # Response templates
112///
113/// When `response_file = "path/to/response.tmpl.md"` is provided, the
114/// tool's return value (`T: Serialize`) is used to build a template context
115/// via `Context::from_serialize`, rendered through the template, and returned
116/// as `ToolOutput`. The struct is also attached as metadata.
117///
118/// # Return types
119///
120/// The return type can be `Result<T, E>` or just `T` (infallible):
121///
122/// - **`T`**: `String` (wrapped as-is), `ToolOutput` (passed through), any
123///   `T: Serialize` (auto-serialized to JSON), or any `T: Into<ToolOutput>`
124/// - **`E`**: any `E: Into<ToolError>` — built-in for `String`, `ToolError`,
125///   `std::io::Error`, `serde_json::Error`
126#[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/// Transforms a function into a `RustPrompt` implementation.
144#[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/// Transforms a function into a `RustResource` implementation.
162#[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
175// ── Attribute Parsing ───────────────────────────────────────────────────────
176
177/// Parsed `#[llm_tool(...)]` attribute.
178///
179/// Supports:
180/// - `description = "inline text"` — static or inline template description
181/// - `description_file = "path.tmpl.md"` — template file
182/// - `params(key = "value", ...)` — compile-time template variables
183/// - `env(KEY = "value", ...)` — compile-time environment variables for `env:` frontmatter
184/// - `context = path::to::fn` — runtime template context function
185/// - `response_file = "path.tmpl.md"` — response rendering template
186struct ToolAttr {
187    /// Inline description string (mutually exclusive with `description_file_path`).
188    description_inline: Option<LitStr>,
189    /// Path to a `.tmpl.md` file (mutually exclusive with `description_inline`).
190    description_file_path: Option<LitStr>,
191    /// Path to a response `.tmpl.md` file for auto-rendering tool output.
192    response_file_path: Option<LitStr>,
193    /// Inline response template string (mutually exclusive with `response_file_path`).
194    response_inline: Option<LitStr>,
195    /// Compile-time key-value pairs for template rendering.
196    /// Mutually exclusive with `context_fn`.
197    inline_params: Vec<(Ident, LitStr)>,
198    /// Compile-time environment variables for `env:` frontmatter declarations.
199    env_vars: Vec<(Ident, syn::Lit)>,
200    /// Runtime context function (mutually exclusive with `inline_params`).
201    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            // Bare `#[llm_tool(sanitize)]`
602            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            // Bare `#[llm_tool(quarantine)]`
652            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
853// ── Implementation ──────────────────────────────────────────────────────────
854
855/// Parsed information about a single function parameter.
856struct 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
864/// Information about the function's return type.
865enum ReturnInfo {
866    /// `Result<T, E>` — fallible tool.
867    ResultType {
868        ok_type: Box<syn::Type>,
869        err_type: Box<syn::Type>,
870    },
871    /// Bare `T` — infallible tool.
872    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                &param.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 build_tool_call_method(
947    is_async: bool,
948    crate_path: &proc_macro2::TokenStream,
949    inner: &proc_macro2::TokenStream,
950) -> proc_macro2::TokenStream {
951    if is_async {
952        quote! {
953            async fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
954                #inner
955                __raw_res.map(|__out| Self::OUTPUT_SANITIZATION.apply(__out))
956            }
957        }
958    } else {
959        quote! {
960            fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::future::Ready<::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError>> {
961                ::core::future::ready((|| -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
962                    #inner
963                    __raw_res.map(|__out| Self::OUTPUT_SANITIZATION.apply(__out))
964                })())
965            }
966        }
967    }
968}
969
970fn tool_impl(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<proc_macro2::TokenStream> {
971    let crate_path = quote! { ::llm_tool };
972    let fn_name = &func.sig.ident;
973    reject_generic_signature(func, MACRO_LLM_TOOL)?;
974    let tool_name_str = fn_name.to_string();
975    let struct_name = format_ident!("{}", tool_name_str.to_case(Case::Pascal));
976    let params_name = format_ident!("{}Params", struct_name);
977
978    let DescriptionInfo {
979        static_description,
980        helper_tokens,
981        description_method,
982        dep_tracking,
983    } = resolve_description(func, attr)?;
984
985    let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
986    let all_params = extract_params(func, MACRO_LLM_TOOL)?;
987    validate_params(&all_params, &func.sig)?;
988    let ctx_param = all_params.iter().find(|p| p.is_context);
989    let params: Vec<&ParamInfo> = all_params.iter().filter(|p| !p.is_context).collect();
990    let return_info = parse_return_type(func, MACRO_LLM_TOOL)?;
991
992    let param_names: Vec<_> = params.iter().map(|p| &p.name).collect();
993    let param_descriptions: Vec<String> = params
994        .iter()
995        .map(|p| extract_doc_string(&p.doc_attrs))
996        .collect();
997
998    let (param_struct_types, borrow_bindings) = build_param_types_and_borrows(&params);
999    let serde_defaults = build_serde_defaults(&params);
1000    let body_tokens = build_body_tokens(func, &return_info, &crate_path, &response_info);
1001
1002    let vis = &func.vis;
1003    let params_doc = format!("Auto-generated parameters for the [`{struct_name}`] tool.");
1004    let struct_doc = format!(
1005        "Auto-generated tool struct. See the `#[llm_tool]`-annotated function `{fn_name}` for the implementation."
1006    );
1007
1008    let ctx_binding = if let Some(cp) = ctx_param {
1009        let ctx_name = &cp.name;
1010        quote! { let #ctx_name = _ctx; }
1011    } else {
1012        quote! {}
1013    };
1014
1015    let mut_tokens: Vec<proc_macro2::TokenStream> = params
1016        .iter()
1017        .map(|p| {
1018            if p.is_mut {
1019                quote! { mut }
1020            } else {
1021                quote! {}
1022            }
1023        })
1024        .collect();
1025
1026    let response_dep_tracking = &response_info.dep_tracking;
1027    let response_helper_tokens = &response_info.helper_tokens;
1028    let (effect_tokens, idempotent_bool, san_tokens, sanitize_bool, quarantine_bool) =
1029        resolve_effect_and_sanitization_tokens(attr, &crate_path);
1030
1031    let call_inner = quote! {
1032        use #crate_path::__private::SerializeFallback as _;
1033        let #params_name { #( #mut_tokens #param_names, )* } = params;
1034        #( #borrow_bindings )*
1035        #ctx_binding
1036        let __raw_res: ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> = {
1037            #body_tokens
1038        };
1039    };
1040    let call_method =
1041        build_tool_call_method(func.sig.asyncness.is_some(), &crate_path, &call_inner);
1042
1043    Ok(quote! {
1044        #dep_tracking
1045        #response_dep_tracking
1046        #helper_tokens
1047        #response_helper_tokens
1048
1049        #[doc = #params_doc]
1050        #[derive(::serde::Deserialize, ::schemars::JsonSchema)]
1051        #vis struct #params_name {
1052            #(
1053                #[schemars(description = #param_descriptions)]
1054                #serde_defaults
1055                pub #param_names: #param_struct_types,
1056            )*
1057        }
1058
1059        #[doc = #struct_doc]
1060        #vis struct #struct_name;
1061
1062        impl #crate_path::RustTool for #struct_name {
1063            type Params = #params_name;
1064            const NAME: &'static str = #tool_name_str;
1065            const DESCRIPTION: &'static str = #static_description;
1066            const IDEMPOTENT: bool = #idempotent_bool;
1067            const EFFECT: #crate_path::ToolEffect = #effect_tokens;
1068            const SANITIZE_OUTPUT: bool = #sanitize_bool;
1069            const QUARANTINE_OUTPUT: bool = #quarantine_bool;
1070            const OUTPUT_SANITIZATION: #crate_path::OutputSanitization = #san_tokens;
1071
1072            #description_method
1073
1074            #call_method
1075        }
1076    })
1077}
1078
1079// ── Description Resolution ──────────────────────────────────────────────────
1080
1081/// Structured output from description resolution.
1082struct DescriptionInfo {
1083    /// Value for `const DESCRIPTION`. For dynamic descriptions, this contains the raw template body.
1084    static_description: String,
1085    /// Helper tokens to emit in the crate scope (e.g. `static TEMPLATE`).
1086    helper_tokens: proc_macro2::TokenStream,
1087    /// Implementation of the `description(&self)` method if dynamic.
1088    description_method: Option<proc_macro2::TokenStream>,
1089    /// Cargo dependency-tracking tokens.
1090    dep_tracking: proc_macro2::TokenStream,
1091}
1092
1093pub(crate) mod desc;
1094pub(crate) mod helpers;
1095pub(crate) use desc::resolve_description;
1096pub(crate) use helpers::{
1097    build_body_tokens, build_param_types_and_borrows, build_serde_defaults, extract_doc_string,
1098    extract_params, parse_return_type, reject_generic_signature, resolve_response_template,
1099};