Skip to main content

modular_agent_macros/
lib.rs

1#![recursion_limit = "256"]
2//! Procedural macros for modular-agent-core.
3//!
4//! Provides the [`#[modular_agent]`](modular_agent) attribute macro to declare agent metadata
5//! alongside the agent type and generate the registration boilerplate.
6
7use proc_macro::TokenStream;
8use proc_macro2::Span;
9use quote::{format_ident, quote};
10use syn::{
11    Expr, ItemStruct, Meta, MetaList, Type, parse_macro_input, parse_quote, punctuated::Punctuated,
12    spanned::Spanned, token::Comma,
13};
14
15/// Declare agent metadata and generate `agent_definition` / `register` helpers.
16///
17/// This macro transforms a struct into a modular agent by:
18/// - Implementing `HasAgentData` trait
19/// - Generating `agent_definition()` and `register()` methods
20/// - Registering the agent with the inventory for automatic discovery
21///
22/// # Requirements
23///
24/// The struct must have a `data: AgentData` field.
25///
26/// # Attributes
27///
28/// ## Required
29///
30/// - `title = "..."` - Display title shown in the UI
31/// - `category = "..."` - Category for organization (e.g., "Utils", "LLM/Chat")
32///
33/// ## Optional Metadata
34///
35/// - `name = "..."` - Override the definition name (default: `module::path::StructName`)
36/// - `description = "..."` - Description text
37/// - `kind = "..."` - Agent kind (default: "Agent")
38/// - `hide_title` - Hide the title in the UI
39/// - `hint(key = value, ...)` - UI hints (e.g., `hint(color = 3, width = 2)`)
40///
41/// ## Ports
42///
43/// - `inputs = ["port1", "port2", ...]` - Input port names
44/// - `outputs = ["port1", "port2", ...]` - Output port names
45///
46/// ## Configuration
47///
48/// Add configuration fields using `*_config(...)` attributes. Each config type accepts:
49/// - `name = "..."` (required) - Config key name
50/// - `default = ...` - Default value
51/// - `title = "..."` - Display title
52/// - `description = "..."` - Description
53/// - `hide_title` - Hide title in UI
54/// - `hidden` - Hide from UI entirely
55/// - `readonly` - Make read-only in UI
56/// - `detail` - Show only in detail view
57///
58/// ### Config Types
59///
60/// | Type | Macro | Default | Description |
61/// |------|-------|---------|-------------|
62/// | Boolean | `boolean_config(...)` | `false` | True/false toggle |
63/// | Integer | `integer_config(...)` | `0` | 64-bit signed integer |
64/// | Number | `number_config(...)` | `0.0` | 64-bit float |
65/// | String | `string_config(...)` | `""` | Single-line text |
66/// | Text | `text_config(...)` | `""` | Multi-line text |
67/// | Array | `array_config(...)` | `[]` | JSON array |
68/// | Object | `object_config(...)` | `{}` | JSON object |
69/// | Unit | `unit_config(...)` | - | Action button |
70/// | Custom | `custom_config(...)` | - | Custom type with `type_ = "..."` |
71///
72/// ## Global Configuration
73///
74/// Use `*_global_config(...)` variants for configs shared across all instances
75/// of this agent type (e.g., API keys).
76///
77/// # Example
78///
79/// ```rust,ignore
80/// use modular_agent_core::{
81///     ModularAgent, AgentContext, AgentData, AgentError, AgentSpec, AgentValue, AsAgent,
82///     modular_agent, async_trait,
83/// };
84///
85/// const PORT_INPUT: &str = "input";
86/// const PORT_OUTPUT: &str = "output";
87///
88/// #[modular_agent(
89///     title = "Add Integer",
90///     category = "Math/Arithmetic",
91///     description = "Adds a constant to the input value",
92///     inputs = [PORT_INPUT],
93///     outputs = [PORT_OUTPUT],
94///     integer_config(name = "n", default = 1, title = "Add Value"),
95/// )]
96/// struct AddIntAgent {
97///     data: AgentData,
98///     n: i64,
99/// }
100///
101/// #[async_trait]
102/// impl AsAgent for AddIntAgent {
103///     fn new(ma: ModularAgent, id: String, spec: AgentSpec) -> Result<Self, AgentError> {
104///         let n = spec.configs.as_ref()
105///             .map(|c| c.get_integer_or_default("n"))
106///             .unwrap_or(1);
107///         Ok(Self {
108///             data: AgentData::new(ma, id, spec),
109///             n,
110///         })
111///     }
112///
113///     async fn process(&mut self, ctx: AgentContext, port: String, value: AgentValue)
114///         -> Result<(), AgentError>
115///     {
116///         if port == PORT_INPUT {
117///             let result = value.as_integer().unwrap_or(0) + self.n;
118///             self.output(ctx, PORT_OUTPUT.into(), AgentValue::integer(result)).await?;
119///         }
120///         Ok(())
121///     }
122/// }
123/// ```
124///
125/// # Generated Code
126///
127/// The macro generates:
128/// - `impl HasAgentData for StructName` - Access to agent data
129/// - `StructName::DEF_NAME` - The definition name constant
130/// - `StructName::def_name()` - Returns the definition name
131/// - `StructName::agent_definition()` - Returns the [`AgentDefinition`]
132/// - `StructName::register(ma)` - Registers with a [`ModularAgent`]
133/// - Inventory submission for automatic registration
134#[proc_macro_attribute]
135pub fn modular_agent(attr: TokenStream, item: TokenStream) -> TokenStream {
136    let args = parse_macro_input!(attr with Punctuated<Meta, Comma>::parse_terminated);
137    let item_struct = parse_macro_input!(item as ItemStruct);
138
139    match expand_modular_agent(args, item_struct) {
140        Ok(tokens) => tokens.into(),
141        Err(err) => err.into_compile_error().into(),
142    }
143}
144
145struct AgentArgs {
146    kind: Option<Expr>,
147    name: Option<Expr>,
148    title: Option<Expr>,
149    hide_title: bool,
150    description: Option<Expr>,
151    category: Option<Expr>,
152    inputs: Vec<Expr>,
153    outputs: Vec<Expr>,
154    configs: Vec<ConfigSpec>,
155    global_configs: Vec<ConfigSpec>,
156    hints: Vec<(Expr, Expr)>,
157}
158
159#[derive(Default)]
160struct CommonConfig {
161    name: Option<Expr>,
162    default: Option<Expr>,
163    title: Option<Expr>,
164    description: Option<Expr>,
165    hide_title: bool,
166    hidden: bool,
167    readonly: bool,
168    detail: bool,
169}
170
171struct CustomConfig {
172    name: Expr,
173    default: Expr,
174    type_: Expr,
175    title: Option<Expr>,
176    description: Option<Expr>,
177    hide_title: bool,
178    hidden: bool,
179    readonly: bool,
180    detail: bool,
181}
182
183enum ConfigSpec {
184    Unit(CommonConfig),
185    Boolean(CommonConfig),
186    Integer(CommonConfig),
187    Number(CommonConfig),
188    String(CommonConfig),
189    Text(CommonConfig),
190    Array(CommonConfig),
191    Object(CommonConfig),
192    Custom(CustomConfig),
193}
194
195fn expand_modular_agent(
196    args: Punctuated<Meta, Comma>,
197    item: ItemStruct,
198) -> syn::Result<proc_macro2::TokenStream> {
199    let has_data_field = item.fields.iter().any(|f| match (&f.ident, &f.ty) {
200        (Some(ident), Type::Path(tp)) if ident == "data" => tp
201            .path
202            .segments
203            .last()
204            .map(|seg| seg.ident == "AgentData")
205            .unwrap_or(false),
206        _ => false,
207    });
208
209    if !has_data_field {
210        return Err(syn::Error::new(
211            item.span(),
212            "#[modular_agent] expects the struct to have a `data: AgentData` field",
213        ));
214    }
215
216    let mut parsed = AgentArgs {
217        kind: None,
218        name: None,
219        title: None,
220        hide_title: false,
221        description: None,
222        category: None,
223        inputs: Vec::new(),
224        outputs: Vec::new(),
225        configs: Vec::new(),
226        global_configs: Vec::new(),
227        hints: Vec::new(),
228    };
229
230    for meta in args {
231        match meta {
232            Meta::NameValue(nv) if nv.path.is_ident("kind") => {
233                parsed.kind = Some(nv.value);
234            }
235            Meta::NameValue(nv) if nv.path.is_ident("name") => {
236                parsed.name = Some(nv.value);
237            }
238            Meta::NameValue(nv) if nv.path.is_ident("title") => {
239                parsed.title = Some(nv.value);
240            }
241            Meta::Path(p) if p.is_ident("hide_title") => {
242                parsed.hide_title = true;
243            }
244            Meta::NameValue(nv) if nv.path.is_ident("description") => {
245                parsed.description = Some(nv.value);
246            }
247            Meta::NameValue(nv) if nv.path.is_ident("category") => {
248                parsed.category = Some(nv.value);
249            }
250            Meta::NameValue(nv) if nv.path.is_ident("inputs") => {
251                parsed.inputs = parse_expr_array(nv.value)?;
252            }
253            Meta::NameValue(nv) if nv.path.is_ident("outputs") => {
254                parsed.outputs = parse_expr_array(nv.value)?;
255            }
256            Meta::List(ml) if ml.path.is_ident("inputs") => {
257                parsed.inputs = collect_exprs(ml)?;
258            }
259            Meta::List(ml) if ml.path.is_ident("outputs") => {
260                parsed.outputs = collect_exprs(ml)?;
261            }
262            Meta::List(ml) if ml.path.is_ident("string_config") => {
263                parsed
264                    .configs
265                    .push(ConfigSpec::String(parse_common_config(ml)?));
266            }
267            Meta::List(ml) if ml.path.is_ident("text_config") => {
268                parsed
269                    .configs
270                    .push(ConfigSpec::Text(parse_common_config(ml)?));
271            }
272            Meta::List(ml) if ml.path.is_ident("array_config") => {
273                parsed
274                    .configs
275                    .push(ConfigSpec::Array(parse_common_config(ml)?));
276            }
277            Meta::List(ml) if ml.path.is_ident("boolean_config") => {
278                parsed
279                    .configs
280                    .push(ConfigSpec::Boolean(parse_common_config(ml)?));
281            }
282            Meta::List(ml) if ml.path.is_ident("integer_config") => {
283                parsed
284                    .configs
285                    .push(ConfigSpec::Integer(parse_common_config(ml)?));
286            }
287            Meta::List(ml) if ml.path.is_ident("number_config") => {
288                parsed
289                    .configs
290                    .push(ConfigSpec::Number(parse_common_config(ml)?));
291            }
292            Meta::List(ml) if ml.path.is_ident("object_config") => {
293                parsed
294                    .configs
295                    .push(ConfigSpec::Object(parse_common_config(ml)?));
296            }
297            Meta::List(ml) if ml.path.is_ident("custom_config") => {
298                parsed
299                    .configs
300                    .push(ConfigSpec::Custom(parse_custom_config(ml)?));
301            }
302            Meta::List(ml) if ml.path.is_ident("unit_config") => {
303                parsed
304                    .configs
305                    .push(ConfigSpec::Unit(parse_common_config(ml)?));
306            }
307            Meta::List(ml) if ml.path.is_ident("string_global_config") => {
308                parsed
309                    .global_configs
310                    .push(ConfigSpec::String(parse_common_config(ml)?));
311            }
312            Meta::List(ml) if ml.path.is_ident("text_global_config") => {
313                parsed
314                    .global_configs
315                    .push(ConfigSpec::Text(parse_common_config(ml)?));
316            }
317            Meta::List(ml) if ml.path.is_ident("boolean_global_config") => {
318                parsed
319                    .global_configs
320                    .push(ConfigSpec::Boolean(parse_common_config(ml)?));
321            }
322            Meta::List(ml) if ml.path.is_ident("array_global_config") => {
323                parsed
324                    .global_configs
325                    .push(ConfigSpec::Array(parse_common_config(ml)?));
326            }
327            Meta::List(ml) if ml.path.is_ident("integer_global_config") => {
328                parsed
329                    .global_configs
330                    .push(ConfigSpec::Integer(parse_common_config(ml)?));
331            }
332            Meta::List(ml) if ml.path.is_ident("number_global_config") => {
333                parsed
334                    .global_configs
335                    .push(ConfigSpec::Number(parse_common_config(ml)?));
336            }
337            Meta::List(ml) if ml.path.is_ident("object_global_config") => {
338                parsed
339                    .global_configs
340                    .push(ConfigSpec::Object(parse_common_config(ml)?));
341            }
342            Meta::List(ml) if ml.path.is_ident("custom_global_config") => {
343                parsed
344                    .global_configs
345                    .push(ConfigSpec::Custom(parse_custom_config(ml)?));
346            }
347            Meta::List(ml) if ml.path.is_ident("unit_global_config") => {
348                parsed
349                    .global_configs
350                    .push(ConfigSpec::Unit(parse_common_config(ml)?));
351            }
352            Meta::List(ml) if ml.path.is_ident("hint") => {
353                parsed.hints.extend(parse_hint_pairs(ml)?);
354            }
355            other => {
356                return Err(syn::Error::new_spanned(
357                    other,
358                    "unsupported modular_agent argument",
359                ));
360            }
361        }
362    }
363
364    // Fall back to doc comments when no explicit `description` is provided.
365    if parsed.description.is_none() {
366        if let Some(doc) = extract_doc_comment(&item.attrs) {
367            let lit = syn::LitStr::new(&doc, Span::call_site());
368            parsed.description = Some(parse_quote! { #lit });
369        }
370    }
371
372    let ident = &item.ident;
373    let generics = item.generics.clone();
374    let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
375    let data_impl = quote! {
376        impl #impl_generics ::modular_agent_core::HasAgentData for #ident #ty_generics #where_clause {
377            fn data(&self) -> &::modular_agent_core::AgentData {
378                &self.data
379            }
380
381            fn mut_data(&mut self) -> &mut ::modular_agent_core::AgentData {
382                &mut self.data
383            }
384        }
385    };
386
387    let kind = parsed.kind.unwrap_or_else(|| parse_quote! { "Agent" });
388    let name_tokens = parsed.name.map(|n| quote! { #n }).unwrap_or_else(|| {
389        quote! { concat!(module_path!(), "::", stringify!(#ident)) }
390    });
391
392    let title = parsed
393        .title
394        .ok_or_else(|| syn::Error::new(Span::call_site(), "modular_agent: missing `title`"))?;
395    let category = parsed
396        .category
397        .ok_or_else(|| syn::Error::new(Span::call_site(), "modular_agent: missing `category`"))?;
398    let title = quote! { .title(#title) };
399    let hide_title = if parsed.hide_title {
400        quote! { .hide_title() }
401    } else {
402        quote! {}
403    };
404    let description = parsed.description.map(|d| quote! { .description(#d) });
405    let category = quote! { .category(#category) };
406
407    let inputs = if parsed.inputs.is_empty() {
408        quote! {}
409    } else {
410        let values = parsed.inputs;
411        quote! { .inputs(vec![#(#values),*]) }
412    };
413
414    let outputs = if parsed.outputs.is_empty() {
415        quote! {}
416    } else {
417        let values = parsed.outputs;
418        quote! { .outputs(vec![#(#values),*]) }
419    };
420
421    let config_calls = parsed
422        .configs
423        .into_iter()
424        .map(|cfg| match cfg {
425            ConfigSpec::Unit(c) => {
426                let name = c.name.ok_or_else(|| {
427                    syn::Error::new(Span::call_site(), "unit_config missing `name`")
428                })?;
429                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
430                let description = c
431                    .description
432                    .map(|d| quote! { let entry = entry.description(#d); });
433                let hide_title = if c.hide_title {
434                    quote! { let entry = entry.hide_title(); }
435                } else {
436                    quote! {}
437                };
438                let hidden = if c.hidden {
439                    quote! { let entry = entry.hidden(); }
440                } else {
441                    quote! {}
442                };
443                let readonly = if c.readonly {
444                    quote! { let entry = entry.readonly(); }
445                } else {
446                    quote! {}
447                };
448                let detail = if c.detail {
449                    quote! { let entry = entry.detail(); }
450                } else {
451                    quote! {}
452                };
453                Ok(quote! {
454                    .unit_config_with(#name, |entry| {
455                        let entry = entry;
456                        #title
457                        #description
458                        #hide_title
459                        #hidden
460                        #readonly
461                        #detail
462                        entry
463                    })
464                })
465            }
466            ConfigSpec::Boolean(c) => {
467                let name = c.name.ok_or_else(|| {
468                    syn::Error::new(Span::call_site(), "boolean_config missing `name`")
469                })?;
470                let default = c.default.unwrap_or_else(|| parse_quote! { false });
471                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
472                let description = c
473                    .description
474                    .map(|d| quote! { let entry = entry.description(#d); });
475                let hide_title = if c.hide_title {
476                    quote! { let entry = entry.hide_title(); }
477                } else {
478                    quote! {}
479                };
480                let hidden = if c.hidden {
481                    quote! { let entry = entry.hidden(); }
482                } else {
483                    quote! {}
484                };
485                let readonly = if c.readonly {
486                    quote! { let entry = entry.readonly(); }
487                } else {
488                    quote! {}
489                };
490                let detail = if c.detail {
491                    quote! { let entry = entry.detail(); }
492                } else {
493                    quote! {}
494                };
495                Ok(quote! {
496                    .boolean_config_with(#name, #default, |entry| {
497                        let entry = entry;
498                        #title
499                        #description
500                        #hide_title
501                        #hidden
502                        #readonly
503                        #detail
504                        entry
505                    })
506                })
507            }
508            ConfigSpec::Integer(c) => {
509                let name = c.name.ok_or_else(|| {
510                    syn::Error::new(Span::call_site(), "integer_config missing `name`")
511                })?;
512                let default = c.default.unwrap_or_else(|| parse_quote! { 0i64 });
513                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
514                let description = c
515                    .description
516                    .map(|d| quote! { let entry = entry.description(#d); });
517                let hide_title = if c.hide_title {
518                    quote! { let entry = entry.hide_title(); }
519                } else {
520                    quote! {}
521                };
522                let hidden = if c.hidden {
523                    quote! { let entry = entry.hidden(); }
524                } else {
525                    quote! {}
526                };
527                let readonly = if c.readonly {
528                    quote! { let entry = entry.readonly(); }
529                } else {
530                    quote! {}
531                };
532                let detail = if c.detail {
533                    quote! { let entry = entry.detail(); }
534                } else {
535                    quote! {}
536                };
537                Ok(quote! {
538                    .integer_config_with(#name, #default, |entry| {
539                        let entry = entry;
540                        #title
541                        #description
542                        #hide_title
543                        #hidden
544                        #readonly
545                        #detail
546                        entry
547                    })
548                })
549            }
550            ConfigSpec::Number(c) => {
551                let name = c.name.ok_or_else(|| {
552                    syn::Error::new(Span::call_site(), "number_config missing `name`")
553                })?;
554                let default = c.default.unwrap_or_else(|| parse_quote! { 0.0f64 });
555                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
556                let description = c
557                    .description
558                    .map(|d| quote! { let entry = entry.description(#d); });
559                let hide_title = if c.hide_title {
560                    quote! { let entry = entry.hide_title(); }
561                } else {
562                    quote! {}
563                };
564                let hidden = if c.hidden {
565                    quote! { let entry = entry.hidden(); }
566                } else {
567                    quote! {}
568                };
569                let readonly = if c.readonly {
570                    quote! { let entry = entry.readonly(); }
571                } else {
572                    quote! {}
573                };
574                let detail = if c.detail {
575                    quote! { let entry = entry.detail(); }
576                } else {
577                    quote! {}
578                };
579                Ok(quote! {
580                    .number_config_with(#name, #default, |entry| {
581                        let entry = entry;
582                        #title
583                        #description
584                        #hide_title
585                        #hidden
586                        #readonly
587                        #detail
588                        entry
589                    })
590                })
591            }
592            ConfigSpec::String(c) => {
593                let name = c.name.ok_or_else(|| {
594                    syn::Error::new(Span::call_site(), "string_config missing `name`")
595                })?;
596                let default = c.default.unwrap_or_else(|| parse_quote! { "" });
597                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
598                let description = c
599                    .description
600                    .map(|d| quote! { let entry = entry.description(#d); });
601                let hide_title = if c.hide_title {
602                    quote! { let entry = entry.hide_title(); }
603                } else {
604                    quote! {}
605                };
606                let hidden = if c.hidden {
607                    quote! { let entry = entry.hidden(); }
608                } else {
609                    quote! {}
610                };
611                let readonly = if c.readonly {
612                    quote! { let entry = entry.readonly(); }
613                } else {
614                    quote! {}
615                };
616                let detail = if c.detail {
617                    quote! { let entry = entry.detail(); }
618                } else {
619                    quote! {}
620                };
621                Ok(quote! {
622                    .string_config_with(#name, #default, |entry| {
623                        let entry = entry;
624                        #title
625                        #description
626                        #hide_title
627                        #hidden
628                        #readonly
629                        #detail
630                        entry
631                    })
632                })
633            }
634            ConfigSpec::Text(c) => {
635                let name = c.name.ok_or_else(|| {
636                    syn::Error::new(Span::call_site(), "text_config missing `name`")
637                })?;
638                let default = c.default.unwrap_or_else(|| parse_quote! { "" });
639                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
640                let description = c
641                    .description
642                    .map(|d| quote! { let entry = entry.description(#d); });
643                let hide_title = if c.hide_title {
644                    quote! { let entry = entry.hide_title(); }
645                } else {
646                    quote! {}
647                };
648                let hidden = if c.hidden {
649                    quote! { let entry = entry.hidden(); }
650                } else {
651                    quote! {}
652                };
653                let readonly = if c.readonly {
654                    quote! { let entry = entry.readonly(); }
655                } else {
656                    quote! {}
657                };
658                let detail = if c.detail {
659                    quote! { let entry = entry.detail(); }
660                } else {
661                    quote! {}
662                };
663                Ok(quote! {
664                    .text_config_with(#name, #default, |entry| {
665                        let entry = entry;
666                        #title
667                        #description
668                        #hide_title
669                        #hidden
670                        #readonly
671                        #detail
672                        entry
673                    })
674                })
675            }
676            ConfigSpec::Array(c) => {
677                let name = c.name.ok_or_else(|| {
678                    syn::Error::new(Span::call_site(), "array_config missing `name`")
679                })?;
680                let default = c.default.unwrap_or_else(|| {
681                    parse_quote! { ::modular_agent_core::AgentValue::array_default() }
682                });
683                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
684                let description = c
685                    .description
686                    .map(|d| quote! { let entry = entry.description(#d); });
687                let hide_title = if c.hide_title {
688                    quote! { let entry = entry.hide_title(); }
689                } else {
690                    quote! {}
691                };
692                let hidden = if c.hidden {
693                    quote! { let entry = entry.hidden(); }
694                } else {
695                    quote! {}
696                };
697                let readonly = if c.readonly {
698                    quote! { let entry = entry.readonly(); }
699                } else {
700                    quote! {}
701                };
702                let detail = if c.detail {
703                    quote! { let entry = entry.detail(); }
704                } else {
705                    quote! {}
706                };
707                Ok(quote! {
708                    .array_config_with(#name, #default, |entry| {
709                        let entry = entry;
710                        #title
711                        #description
712                        #hide_title
713                        #hidden
714                        #readonly
715                        #detail
716                        entry
717                    })
718                })
719            }
720            ConfigSpec::Object(c) => {
721                let name = c.name.ok_or_else(|| {
722                    syn::Error::new(Span::call_site(), "object_config missing `name`")
723                })?;
724                let default = c.default.unwrap_or_else(|| {
725                    parse_quote! { ::modular_agent_core::AgentValue::object_default() }
726                });
727                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
728                let description = c
729                    .description
730                    .map(|d| quote! { let entry = entry.description(#d); });
731                let hide_title = if c.hide_title {
732                    quote! { let entry = entry.hide_title(); }
733                } else {
734                    quote! {}
735                };
736                let hidden = if c.hidden {
737                    quote! { let entry = entry.hidden(); }
738                } else {
739                    quote! {}
740                };
741                let readonly = if c.readonly {
742                    quote! { let entry = entry.readonly(); }
743                } else {
744                    quote! {}
745                };
746                let detail = if c.detail {
747                    quote! { let entry = entry.detail(); }
748                } else {
749                    quote! {}
750                };
751                Ok(quote! {
752                    .object_config_with(#name, #default, |entry| {
753                        let entry = entry;
754                        #title
755                        #description
756                        #hide_title
757                        #hidden
758                        #readonly
759                        #detail
760                        entry
761                    })
762                })
763            }
764            ConfigSpec::Custom(c) => custom_config_call("custom_config_with", c),
765        })
766        .collect::<syn::Result<Vec<_>>>()?;
767
768    let global_config_calls = parsed
769        .global_configs
770        .into_iter()
771        .map(|cfg| match cfg {
772            ConfigSpec::Unit(c) => {
773                let name = c.name.ok_or_else(|| {
774                    syn::Error::new(Span::call_site(), "unit_global_config missing `name`")
775                })?;
776                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
777                let description = c
778                    .description
779                    .map(|d| quote! { let entry = entry.description(#d); });
780                let hide_title = if c.hide_title {
781                    quote! { let entry = entry.hide_title(); }
782                } else {
783                    quote! {}
784                };
785                let hidden = if c.hidden {
786                    quote! { let entry = entry.hidden(); }
787                } else {
788                    quote! {}
789                };
790                let readonly = if c.readonly {
791                    quote! { let entry = entry.readonly(); }
792                } else {
793                    quote! {}
794                };
795                let detail = if c.detail {
796                    quote! { let entry = entry.detail(); }
797                } else {
798                    quote! {}
799                };
800                Ok(quote! {
801                    .unit_global_config_with(#name, |entry| {
802                        let entry = entry;
803                        #title
804                        #description
805                        #hide_title
806                        #hidden
807                        #readonly
808                        #detail
809                        entry
810                    })
811                })
812            }
813            ConfigSpec::Boolean(c) => {
814                let name = c.name.ok_or_else(|| {
815                    syn::Error::new(Span::call_site(), "boolean_global_config missing `name`")
816                })?;
817                let default = c.default.unwrap_or_else(|| parse_quote! { false });
818                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
819                let description = c
820                    .description
821                    .map(|d| quote! { let entry = entry.description(#d); });
822                let hide_title = if c.hide_title {
823                    quote! { let entry = entry.hide_title(); }
824                } else {
825                    quote! {}
826                };
827                let hidden = if c.hidden {
828                    quote! { let entry = entry.hidden(); }
829                } else {
830                    quote! {}
831                };
832                let readonly = if c.readonly {
833                    quote! { let entry = entry.readonly(); }
834                } else {
835                    quote! {}
836                };
837                let detail = if c.detail {
838                    quote! { let entry = entry.detail(); }
839                } else {
840                    quote! {}
841                };
842                Ok(quote! {
843                    .boolean_global_config_with(#name, #default, |entry| {
844                        let entry = entry;
845                        #title
846                        #description
847                        #hide_title
848                        #hidden
849                        #readonly
850                        #detail
851                        entry
852                    })
853                })
854            }
855            ConfigSpec::Integer(c) => {
856                let name = c.name.ok_or_else(|| {
857                    syn::Error::new(Span::call_site(), "integer_global_config missing `name`")
858                })?;
859                let default = c.default.unwrap_or_else(|| parse_quote! { 0i64 });
860                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
861                let description = c
862                    .description
863                    .map(|d| quote! { let entry = entry.description(#d); });
864                let hide_title = if c.hide_title {
865                    quote! { let entry = entry.hide_title(); }
866                } else {
867                    quote! {}
868                };
869                let hidden = if c.hidden {
870                    quote! { let entry = entry.hidden(); }
871                } else {
872                    quote! {}
873                };
874                let readonly = if c.readonly {
875                    quote! { let entry = entry.readonly(); }
876                } else {
877                    quote! {}
878                };
879                let detail = if c.detail {
880                    quote! { let entry = entry.detail(); }
881                } else {
882                    quote! {}
883                };
884                Ok(quote! {
885                    .integer_global_config_with(#name, #default, |entry| {
886                        let entry = entry;
887                        #title
888                        #description
889                        #hide_title
890                        #hidden
891                        #readonly
892                        #detail
893                        entry
894                    })
895                })
896            }
897            ConfigSpec::Number(c) => {
898                let name = c.name.ok_or_else(|| {
899                    syn::Error::new(Span::call_site(), "number_global_config missing `name`")
900                })?;
901                let default = c.default.unwrap_or_else(|| parse_quote! { 0.0f64 });
902                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
903                let description = c
904                    .description
905                    .map(|d| quote! { let entry = entry.description(#d); });
906                let hide_title = if c.hide_title {
907                    quote! { let entry = entry.hide_title(); }
908                } else {
909                    quote! {}
910                };
911                let hidden = if c.hidden {
912                    quote! { let entry = entry.hidden(); }
913                } else {
914                    quote! {}
915                };
916                let readonly = if c.readonly {
917                    quote! { let entry = entry.readonly(); }
918                } else {
919                    quote! {}
920                };
921                let detail = if c.detail {
922                    quote! { let entry = entry.detail(); }
923                } else {
924                    quote! {}
925                };
926                Ok(quote! {
927                    .number_global_config_with(#name, #default, |entry| {
928                        let entry = entry;
929                        #title
930                        #description
931                        #hide_title
932                        #hidden
933                        #readonly
934                        #detail
935                        entry
936                    })
937                })
938            }
939            ConfigSpec::String(c) => {
940                let name = c.name.ok_or_else(|| {
941                    syn::Error::new(Span::call_site(), "string_global_config missing `name`")
942                })?;
943                let default = c.default.unwrap_or_else(|| parse_quote! { "" });
944                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
945                let description = c
946                    .description
947                    .map(|d| quote! { let entry = entry.description(#d); });
948                let hide_title = if c.hide_title {
949                    quote! { let entry = entry.hide_title(); }
950                } else {
951                    quote! {}
952                };
953                let hidden = if c.hidden {
954                    quote! { let entry = entry.hidden(); }
955                } else {
956                    quote! {}
957                };
958                let readonly = if c.readonly {
959                    quote! { let entry = entry.readonly(); }
960                } else {
961                    quote! {}
962                };
963                let detail = if c.detail {
964                    quote! { let entry = entry.detail(); }
965                } else {
966                    quote! {}
967                };
968                Ok(quote! {
969                    .string_global_config_with(#name, #default, |entry| {
970                        let entry = entry;
971                        #title
972                        #description
973                        #hide_title
974                        #hidden
975                        #readonly
976                        #detail
977                        entry
978                    })
979                })
980            }
981            ConfigSpec::Text(c) => {
982                let name = c.name.ok_or_else(|| {
983                    syn::Error::new(Span::call_site(), "text_global_config missing `name`")
984                })?;
985                let default = c.default.unwrap_or_else(|| parse_quote! { "" });
986                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
987                let description = c
988                    .description
989                    .map(|d| quote! { let entry = entry.description(#d); });
990                let hide_title = if c.hide_title {
991                    quote! { let entry = entry.hide_title(); }
992                } else {
993                    quote! {}
994                };
995                let hidden = if c.hidden {
996                    quote! { let entry = entry.hidden(); }
997                } else {
998                    quote! {}
999                };
1000                let readonly = if c.readonly {
1001                    quote! { let entry = entry.readonly(); }
1002                } else {
1003                    quote! {}
1004                };
1005                let detail = if c.detail {
1006                    quote! { let entry = entry.detail(); }
1007                } else {
1008                    quote! {}
1009                };
1010                Ok(quote! {
1011                    .text_global_config_with(#name, #default, |entry| {
1012                        let entry = entry;
1013                        #title
1014                        #description
1015                        #hide_title
1016                        #hidden
1017                        #readonly
1018                        #detail
1019                        entry
1020                    })
1021                })
1022            }
1023            ConfigSpec::Array(c) => {
1024                let name = c.name.ok_or_else(|| {
1025                    syn::Error::new(Span::call_site(), "array_global_config missing `name`")
1026                })?;
1027                let default = c.default.unwrap_or_else(|| {
1028                    parse_quote! { ::modular_agent_core::AgentValue::array_default() }
1029                });
1030                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
1031                let description = c
1032                    .description
1033                    .map(|d| quote! { let entry = entry.description(#d); });
1034                let hide_title = if c.hide_title {
1035                    quote! { let entry = entry.hide_title(); }
1036                } else {
1037                    quote! {}
1038                };
1039                let hidden = if c.hidden {
1040                    quote! { let entry = entry.hidden(); }
1041                } else {
1042                    quote! {}
1043                };
1044                let readonly = if c.readonly {
1045                    quote! { let entry = entry.readonly(); }
1046                } else {
1047                    quote! {}
1048                };
1049                let detail = if c.detail {
1050                    quote! { let entry = entry.detail(); }
1051                } else {
1052                    quote! {}
1053                };
1054                Ok(quote! {
1055                    .array_global_config_with(#name, #default, |entry| {
1056                        let entry = entry;
1057                        #title
1058                        #description
1059                        #hide_title
1060                        #hidden
1061                        #readonly
1062                        #detail
1063                        entry
1064                    })
1065                })
1066            }
1067            ConfigSpec::Object(c) => {
1068                let name = c.name.ok_or_else(|| {
1069                    syn::Error::new(Span::call_site(), "object_global_config missing `name`")
1070                })?;
1071                let default = c.default.unwrap_or_else(|| {
1072                    parse_quote! { ::modular_agent_core::AgentValue::object_default() }
1073                });
1074                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
1075                let description = c
1076                    .description
1077                    .map(|d| quote! { let entry = entry.description(#d); });
1078                let hide_title = if c.hide_title {
1079                    quote! { let entry = entry.hide_title(); }
1080                } else {
1081                    quote! {}
1082                };
1083                let hidden = if c.hidden {
1084                    quote! { let entry = entry.hidden(); }
1085                } else {
1086                    quote! {}
1087                };
1088                let readonly = if c.readonly {
1089                    quote! { let entry = entry.readonly(); }
1090                } else {
1091                    quote! {}
1092                };
1093                let detail = if c.detail {
1094                    quote! { let entry = entry.detail(); }
1095                } else {
1096                    quote! {}
1097                };
1098                Ok(quote! {
1099                    .object_global_config_with(#name, #default, |entry| {
1100                        let entry = entry;
1101                        #title
1102                        #description
1103                        #hide_title
1104                        #hidden
1105                        #readonly
1106                        #detail
1107                        entry
1108                    })
1109                })
1110            }
1111            ConfigSpec::Custom(c) => custom_config_call("custom_global_config_with", c),
1112        })
1113        .collect::<syn::Result<Vec<_>>>()?;
1114
1115    let hint_calls: Vec<_> = parsed
1116        .hints
1117        .iter()
1118        .map(|(key, value)| {
1119            quote! { .hint(#key, #value) }
1120        })
1121        .collect();
1122
1123    let definition_builder = quote! {
1124        ::modular_agent_core::AgentDefinition::new(
1125            #kind,
1126            #name_tokens,
1127            Some(::modular_agent_core::new_agent_boxed::<#ident>),
1128        )
1129        #title
1130        #hide_title
1131        #description
1132        #category
1133        #inputs
1134        #outputs
1135        #(#config_calls)*
1136        #(#global_config_calls)*
1137        #(#hint_calls)*
1138    };
1139
1140    let expanded = quote! {
1141        #item
1142
1143        #data_impl
1144
1145        impl #impl_generics #ident #ty_generics #where_clause {
1146            pub const DEF_NAME: &'static str = #name_tokens;
1147
1148            pub fn def_name() -> &'static str { Self::DEF_NAME }
1149
1150            pub fn agent_definition() -> ::modular_agent_core::AgentDefinition {
1151                #definition_builder
1152            }
1153
1154            pub fn register(ma: &::modular_agent_core::ModularAgent) {
1155                ma.register_agent_definiton(Self::agent_definition());
1156            }
1157        }
1158
1159        ::modular_agent_core::inventory::submit! {
1160            ::modular_agent_core::AgentRegistration {
1161                build: || #definition_builder,
1162            }
1163        }
1164    };
1165
1166    Ok(expanded)
1167}
1168
1169fn parse_name_type_title_description(
1170    meta: &Meta,
1171    name: &mut Option<Expr>,
1172    type_: &mut Option<Expr>,
1173    title: &mut Option<Expr>,
1174    description: &mut Option<Expr>,
1175) -> bool {
1176    match meta {
1177        Meta::NameValue(nv) if nv.path.is_ident("name") => {
1178            *name = Some(nv.value.clone());
1179            true
1180        }
1181        Meta::NameValue(nv) if nv.path.is_ident("type") => {
1182            *type_ = Some(nv.value.clone());
1183            true
1184        }
1185        Meta::NameValue(nv) if nv.path.is_ident("type_") => {
1186            *type_ = Some(nv.value.clone());
1187            true
1188        }
1189        Meta::NameValue(nv) if nv.path.is_ident("title") => {
1190            *title = Some(nv.value.clone());
1191            true
1192        }
1193        Meta::NameValue(nv) if nv.path.is_ident("description") => {
1194            *description = Some(nv.value.clone());
1195            true
1196        }
1197        _ => false,
1198    }
1199}
1200
1201fn parse_custom_config(list: MetaList) -> syn::Result<CustomConfig> {
1202    let mut name = None;
1203    let mut default = None;
1204    let mut type_ = None;
1205    let mut title = None;
1206    let mut description = None;
1207    let mut hide_title = false;
1208    let mut hidden = false;
1209    let mut readonly = false;
1210    let mut detail = false;
1211    let nested = list.parse_args_with(Punctuated::<Meta, Comma>::parse_terminated)?;
1212
1213    for meta in nested {
1214        if parse_name_type_title_description(
1215            &meta,
1216            &mut name,
1217            &mut type_,
1218            &mut title,
1219            &mut description,
1220        ) {
1221            continue;
1222        }
1223
1224        match meta {
1225            Meta::NameValue(nv) if nv.path.is_ident("default") => {
1226                default = Some(nv.value.clone());
1227            }
1228            Meta::Path(p) if p.is_ident("hide_title") => {
1229                hide_title = true;
1230            }
1231            Meta::Path(p) if p.is_ident("hidden") => {
1232                hidden = true;
1233            }
1234            Meta::Path(p) if p.is_ident("readonly") => {
1235                readonly = true;
1236            }
1237            Meta::Path(p) if p.is_ident("detail") => {
1238                detail = true;
1239            }
1240            other => {
1241                return Err(syn::Error::new_spanned(
1242                    other,
1243                    "custom_config supports name, default, type/type_, title, description, hide_title, hidden, readonly, detail",
1244                ));
1245            }
1246        }
1247    }
1248
1249    let name = name.ok_or_else(|| syn::Error::new(list.span(), "config missing `name`"))?;
1250    // Config names starting with `_` are reserved for stale key migration
1251    if let Expr::Lit(ref lit) = name
1252        && let syn::Lit::Str(ref s) = lit.lit
1253        && s.value().starts_with('_')
1254    {
1255        return Err(syn::Error::new_spanned(
1256            &name,
1257            "config name must not start with `_` (reserved for stale key migration)",
1258        ));
1259    }
1260    let default =
1261        default.ok_or_else(|| syn::Error::new(list.span(), "config missing `default`"))?;
1262    let type_ = type_.ok_or_else(|| syn::Error::new(list.span(), "config missing `type`"))?;
1263
1264    Ok(CustomConfig {
1265        name,
1266        default,
1267        type_,
1268        title,
1269        description,
1270        hide_title,
1271        hidden,
1272        readonly,
1273        detail,
1274    })
1275}
1276
1277fn collect_exprs(list: MetaList) -> syn::Result<Vec<Expr>> {
1278    let values = list.parse_args_with(Punctuated::<Expr, Comma>::parse_terminated)?;
1279    Ok(values.into_iter().collect())
1280}
1281
1282fn parse_expr_array(expr: Expr) -> syn::Result<Vec<Expr>> {
1283    if let Expr::Array(arr) = expr {
1284        Ok(arr.elems.into_iter().collect())
1285    } else {
1286        Err(syn::Error::new_spanned(
1287            expr,
1288            "inputs/outputs expect array expressions",
1289        ))
1290    }
1291}
1292
1293fn parse_common_config(list: MetaList) -> syn::Result<CommonConfig> {
1294    let mut cfg = CommonConfig::default();
1295    let nested = list.parse_args_with(Punctuated::<Meta, Comma>::parse_terminated)?;
1296
1297    for meta in nested {
1298        match meta {
1299            Meta::NameValue(nv) if nv.path.is_ident("name") => {
1300                // Config names starting with `_` are reserved for stale key migration
1301                if let Expr::Lit(ref lit) = nv.value
1302                    && let syn::Lit::Str(ref s) = lit.lit
1303                    && s.value().starts_with('_')
1304                {
1305                    return Err(syn::Error::new_spanned(
1306                        &nv.value,
1307                        "config name must not start with `_` (reserved for stale key migration)",
1308                    ));
1309                }
1310                cfg.name = Some(nv.value.clone());
1311            }
1312            Meta::NameValue(nv) if nv.path.is_ident("default") => {
1313                cfg.default = Some(nv.value.clone());
1314            }
1315            Meta::NameValue(nv) if nv.path.is_ident("title") => {
1316                cfg.title = Some(nv.value.clone());
1317            }
1318            Meta::NameValue(nv) if nv.path.is_ident("description") => {
1319                cfg.description = Some(nv.value.clone());
1320            }
1321            Meta::Path(p) if p.is_ident("hide_title") => {
1322                cfg.hide_title = true;
1323            }
1324            Meta::Path(p) if p.is_ident("hidden") => {
1325                cfg.hidden = true;
1326            }
1327            Meta::Path(p) if p.is_ident("readonly") => {
1328                cfg.readonly = true;
1329            }
1330            Meta::Path(p) if p.is_ident("detail") => {
1331                cfg.detail = true;
1332            }
1333            other => {
1334                return Err(syn::Error::new_spanned(
1335                    other,
1336                    "config supports name, default, title, description, hide_title, hidden, readonly, detail",
1337                ));
1338            }
1339        }
1340    }
1341
1342    if cfg.name.is_none() {
1343        return Err(syn::Error::new(list.span(), "config missing `name`"));
1344    }
1345    Ok(cfg)
1346}
1347
1348fn parse_hint_pairs(list: MetaList) -> syn::Result<Vec<(Expr, Expr)>> {
1349    let nested = list.parse_args_with(Punctuated::<Meta, Comma>::parse_terminated)?;
1350    let mut pairs = Vec::new();
1351    for meta in nested {
1352        match meta {
1353            Meta::NameValue(nv) => {
1354                let key = nv
1355                    .path
1356                    .get_ident()
1357                    .ok_or_else(|| {
1358                        syn::Error::new_spanned(
1359                            &nv.path,
1360                            "hint key must be a simple identifier",
1361                        )
1362                    })?;
1363                let key_str = key.to_string();
1364                let key_lit = syn::LitStr::new(&key_str, key.span());
1365                pairs.push((parse_quote!(#key_lit), nv.value));
1366            }
1367            other => {
1368                return Err(syn::Error::new_spanned(
1369                    other,
1370                    "hint expects `key = value` pairs (e.g., `hint(color = 3)`)",
1371                ));
1372            }
1373        }
1374    }
1375    Ok(pairs)
1376}
1377
1378fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option<String> {
1379    let lines: Vec<String> = attrs
1380        .iter()
1381        .filter_map(|attr| {
1382            if !attr.path().is_ident("doc") {
1383                return None;
1384            }
1385            if let Meta::NameValue(nv) = &attr.meta {
1386                if let Expr::Lit(lit) = &nv.value {
1387                    if let syn::Lit::Str(s) = &lit.lit {
1388                        return Some(s.value());
1389                    }
1390                }
1391            }
1392            None
1393        })
1394        .collect();
1395    if lines.is_empty() {
1396        return None;
1397    }
1398    // rustc converts `/// text` to `#[doc = " text"]`. Strip the leading space.
1399    let text = lines
1400        .iter()
1401        .map(|l| l.strip_prefix(' ').unwrap_or(l))
1402        .collect::<Vec<_>>()
1403        .join("\n")
1404        .trim()
1405        .to_string();
1406    if text.is_empty() { None } else { Some(text) }
1407}
1408
1409fn custom_config_call(method: &str, cfg: CustomConfig) -> syn::Result<proc_macro2::TokenStream> {
1410    let CustomConfig {
1411        name,
1412        default,
1413        type_,
1414        title,
1415        description,
1416        hide_title,
1417        hidden,
1418        readonly,
1419        detail,
1420    } = cfg;
1421    let title = title.map(|t| quote! { let entry = entry.title(#t); });
1422    let description = description.map(|d| quote! { let entry = entry.description(#d); });
1423    let hide_title = if hide_title {
1424        quote! { let entry = entry.hide_title(); }
1425    } else {
1426        quote! {}
1427    };
1428    let hidden = if hidden {
1429        quote! { let entry = entry.hidden(); }
1430    } else {
1431        quote! {}
1432    };
1433    let readonly = if readonly {
1434        quote! { let entry = entry.readonly(); }
1435    } else {
1436        quote! {}
1437    };
1438    let detail = if detail {
1439        quote! { let entry = entry.detail(); }
1440    } else {
1441        quote! {}
1442    };
1443    let method_ident = format_ident!("{}", method);
1444
1445    Ok(quote! {
1446        .#method_ident(#name, #default, #type_, |entry| {
1447            let entry = entry;
1448            #title
1449            #description
1450            #hide_title
1451            #hidden
1452            #readonly
1453            #detail
1454            entry
1455        })
1456    })
1457}