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    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            // Bare `#[llm_tool(sanitize)]`
608            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            // Bare `#[llm_tool(quarantine)]`
658            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
893// ── Implementation ──────────────────────────────────────────────────────────
894
895/// Parsed information about a single function parameter.
896struct 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
904/// Information about the function's return type.
905enum ReturnInfo {
906    /// `Result<T, E>` — fallible tool.
907    ResultType {
908        ok_type: Box<syn::Type>,
909        err_type: Box<syn::Type>,
910    },
911    /// Bare `T` — infallible tool.
912    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                &param.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(&params);
1039    let serde_defaults = build_serde_defaults(&params);
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
1125// ── Description Resolution ──────────────────────────────────────────────────
1126
1127/// Structured output from description resolution.
1128struct DescriptionInfo {
1129    /// Value for `const DESCRIPTION`. For dynamic descriptions, this contains the raw template body.
1130    static_description: String,
1131    /// Helper tokens to emit in the crate scope (e.g. `static TEMPLATE`).
1132    helper_tokens: proc_macro2::TokenStream,
1133    /// Implementation of the `description(&self)` method if dynamic.
1134    description_method: Option<proc_macro2::TokenStream>,
1135    /// Cargo dependency-tracking tokens.
1136    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};