Skip to main content

modular_agent_macros/
lib.rs

1#![recursion_limit = "256"]
2//! Procedural macros for modular-agent-core.
3//!
4//! Provides an attribute to declare agent metadata alongside the agent type and
5//! 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/// Example:
18/// ```rust,ignore
19/// #[modular_agent(
20///     title = "Add Int",
21///     category = "Utils",
22///     inputs = ["int"],
23///     outputs = ["int"],
24///     integer_config(
25///         name = "n",
26///         default = 1,
27///     )
28/// )]
29/// struct AdderAgent { /* ... */ }
30/// ```
31#[proc_macro_attribute]
32pub fn modular_agent(attr: TokenStream, item: TokenStream) -> TokenStream {
33    let args = parse_macro_input!(attr with Punctuated<Meta, Comma>::parse_terminated);
34    let item_struct = parse_macro_input!(item as ItemStruct);
35
36    match expand_modular_agent(args, item_struct) {
37        Ok(tokens) => tokens.into(),
38        Err(err) => err.into_compile_error().into(),
39    }
40}
41
42struct AgentArgs {
43    kind: Option<Expr>,
44    name: Option<Expr>,
45    title: Option<Expr>,
46    hide_title: bool,
47    description: Option<Expr>,
48    category: Option<Expr>,
49    inputs: Vec<Expr>,
50    outputs: Vec<Expr>,
51    configs: Vec<ConfigSpec>,
52    global_configs: Vec<ConfigSpec>,
53}
54
55#[derive(Default)]
56struct CommonConfig {
57    name: Option<Expr>,
58    default: Option<Expr>,
59    title: Option<Expr>,
60    description: Option<Expr>,
61    hide_title: bool,
62    hidden: bool,
63    readonly: bool,
64}
65
66struct CustomConfig {
67    name: Expr,
68    default: Expr,
69    type_: Expr,
70    title: Option<Expr>,
71    description: Option<Expr>,
72    hide_title: bool,
73    hidden: bool,
74    readonly: bool,
75}
76
77enum ConfigSpec {
78    Unit(CommonConfig),
79    Boolean(CommonConfig),
80    Integer(CommonConfig),
81    Number(CommonConfig),
82    String(CommonConfig),
83    Text(CommonConfig),
84    Array(CommonConfig),
85    Object(CommonConfig),
86    Custom(CustomConfig),
87}
88
89fn expand_modular_agent(
90    args: Punctuated<Meta, Comma>,
91    item: ItemStruct,
92) -> syn::Result<proc_macro2::TokenStream> {
93    let has_data_field = item.fields.iter().any(|f| match (&f.ident, &f.ty) {
94        (Some(ident), Type::Path(tp)) if ident == "data" => tp
95            .path
96            .segments
97            .last()
98            .map(|seg| seg.ident == "AgentData")
99            .unwrap_or(false),
100        _ => false,
101    });
102
103    if !has_data_field {
104        return Err(syn::Error::new(
105            item.span(),
106            "#[modular_agent] expects the struct to have a `data: AgentData` field",
107        ));
108    }
109
110    let mut parsed = AgentArgs {
111        kind: None,
112        name: None,
113        title: None,
114        hide_title: false,
115        description: None,
116        category: None,
117        inputs: Vec::new(),
118        outputs: Vec::new(),
119        configs: Vec::new(),
120        global_configs: Vec::new(),
121    };
122
123    for meta in args {
124        match meta {
125            Meta::NameValue(nv) if nv.path.is_ident("kind") => {
126                parsed.kind = Some(nv.value);
127            }
128            Meta::NameValue(nv) if nv.path.is_ident("name") => {
129                parsed.name = Some(nv.value);
130            }
131            Meta::NameValue(nv) if nv.path.is_ident("title") => {
132                parsed.title = Some(nv.value);
133            }
134            Meta::Path(p) if p.is_ident("hide_title") => {
135                parsed.hide_title = true;
136            }
137            Meta::NameValue(nv) if nv.path.is_ident("description") => {
138                parsed.description = Some(nv.value);
139            }
140            Meta::NameValue(nv) if nv.path.is_ident("category") => {
141                parsed.category = Some(nv.value);
142            }
143            Meta::NameValue(nv) if nv.path.is_ident("inputs") => {
144                parsed.inputs = parse_expr_array(nv.value)?;
145            }
146            Meta::NameValue(nv) if nv.path.is_ident("outputs") => {
147                parsed.outputs = parse_expr_array(nv.value)?;
148            }
149            Meta::List(ml) if ml.path.is_ident("inputs") => {
150                parsed.inputs = collect_exprs(ml)?;
151            }
152            Meta::List(ml) if ml.path.is_ident("outputs") => {
153                parsed.outputs = collect_exprs(ml)?;
154            }
155            Meta::List(ml) if ml.path.is_ident("string_config") => {
156                parsed
157                    .configs
158                    .push(ConfigSpec::String(parse_common_config(ml)?));
159            }
160            Meta::List(ml) if ml.path.is_ident("text_config") => {
161                parsed
162                    .configs
163                    .push(ConfigSpec::Text(parse_common_config(ml)?));
164            }
165            Meta::List(ml) if ml.path.is_ident("array_config") => {
166                parsed
167                    .configs
168                    .push(ConfigSpec::Array(parse_common_config(ml)?));
169            }
170            Meta::List(ml) if ml.path.is_ident("boolean_config") => {
171                parsed
172                    .configs
173                    .push(ConfigSpec::Boolean(parse_common_config(ml)?));
174            }
175            Meta::List(ml) if ml.path.is_ident("integer_config") => {
176                parsed
177                    .configs
178                    .push(ConfigSpec::Integer(parse_common_config(ml)?));
179            }
180            Meta::List(ml) if ml.path.is_ident("number_config") => {
181                parsed
182                    .configs
183                    .push(ConfigSpec::Number(parse_common_config(ml)?));
184            }
185            Meta::List(ml) if ml.path.is_ident("object_config") => {
186                parsed
187                    .configs
188                    .push(ConfigSpec::Object(parse_common_config(ml)?));
189            }
190            Meta::List(ml) if ml.path.is_ident("custom_config") => {
191                parsed
192                    .configs
193                    .push(ConfigSpec::Custom(parse_custom_config(ml)?));
194            }
195            Meta::List(ml) if ml.path.is_ident("unit_config") => {
196                parsed
197                    .configs
198                    .push(ConfigSpec::Unit(parse_common_config(ml)?));
199            }
200            Meta::List(ml) if ml.path.is_ident("string_global_config") => {
201                parsed
202                    .global_configs
203                    .push(ConfigSpec::String(parse_common_config(ml)?));
204            }
205            Meta::List(ml) if ml.path.is_ident("text_global_config") => {
206                parsed
207                    .global_configs
208                    .push(ConfigSpec::Text(parse_common_config(ml)?));
209            }
210            Meta::List(ml) if ml.path.is_ident("boolean_global_config") => {
211                parsed
212                    .global_configs
213                    .push(ConfigSpec::Boolean(parse_common_config(ml)?));
214            }
215            Meta::List(ml) if ml.path.is_ident("array_global_config") => {
216                parsed
217                    .global_configs
218                    .push(ConfigSpec::Array(parse_common_config(ml)?));
219            }
220            Meta::List(ml) if ml.path.is_ident("integer_global_config") => {
221                parsed
222                    .global_configs
223                    .push(ConfigSpec::Integer(parse_common_config(ml)?));
224            }
225            Meta::List(ml) if ml.path.is_ident("number_global_config") => {
226                parsed
227                    .global_configs
228                    .push(ConfigSpec::Number(parse_common_config(ml)?));
229            }
230            Meta::List(ml) if ml.path.is_ident("object_global_config") => {
231                parsed
232                    .global_configs
233                    .push(ConfigSpec::Object(parse_common_config(ml)?));
234            }
235            Meta::List(ml) if ml.path.is_ident("custom_global_config") => {
236                parsed
237                    .global_configs
238                    .push(ConfigSpec::Custom(parse_custom_config(ml)?));
239            }
240            Meta::List(ml) if ml.path.is_ident("unit_global_config") => {
241                parsed
242                    .global_configs
243                    .push(ConfigSpec::Unit(parse_common_config(ml)?));
244            }
245            other => {
246                return Err(syn::Error::new_spanned(
247                    other,
248                    "unsupported modular_agent argument",
249                ));
250            }
251        }
252    }
253
254    let ident = &item.ident;
255    let generics = item.generics.clone();
256    let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
257    let data_impl = quote! {
258        impl #impl_generics ::modular_agent_core::HasAgentData for #ident #ty_generics #where_clause {
259            fn data(&self) -> &::modular_agent_core::AgentData {
260                &self.data
261            }
262
263            fn mut_data(&mut self) -> &mut ::modular_agent_core::AgentData {
264                &mut self.data
265            }
266        }
267    };
268
269    let kind = parsed.kind.unwrap_or_else(|| parse_quote! { "Agent" });
270    let name_tokens = parsed.name.map(|n| quote! { #n }).unwrap_or_else(|| {
271        quote! { concat!(module_path!(), "::", stringify!(#ident)) }
272    });
273
274    let title = parsed
275        .title
276        .ok_or_else(|| syn::Error::new(Span::call_site(), "modular_agent: missing `title`"))?;
277    let category = parsed
278        .category
279        .ok_or_else(|| syn::Error::new(Span::call_site(), "modular_agent: missing `category`"))?;
280    let title = quote! { .title(#title) };
281    let hide_title = if parsed.hide_title {
282        quote! { .hide_title() }
283    } else {
284        quote! {}
285    };
286    let description = parsed.description.map(|d| quote! { .description(#d) });
287    let category = quote! { .category(#category) };
288
289    let inputs = if parsed.inputs.is_empty() {
290        quote! {}
291    } else {
292        let values = parsed.inputs;
293        quote! { .inputs(vec![#(#values),*]) }
294    };
295
296    let outputs = if parsed.outputs.is_empty() {
297        quote! {}
298    } else {
299        let values = parsed.outputs;
300        quote! { .outputs(vec![#(#values),*]) }
301    };
302
303    let config_calls = parsed
304        .configs
305        .into_iter()
306        .map(|cfg| match cfg {
307            ConfigSpec::Unit(c) => {
308                let name = c.name.ok_or_else(|| {
309                    syn::Error::new(Span::call_site(), "unit_config missing `name`")
310                })?;
311                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
312                let description = c
313                    .description
314                    .map(|d| quote! { let entry = entry.description(#d); });
315                let hide_title = if c.hide_title {
316                    quote! { let entry = entry.hide_title(); }
317                } else {
318                    quote! {}
319                };
320                let hidden = if c.hidden {
321                    quote! { let entry = entry.hidden(); }
322                } else {
323                    quote! {}
324                };
325                let readonly = if c.readonly {
326                    quote! { let entry = entry.readonly(); }
327                } else {
328                    quote! {}
329                };
330                Ok(quote! {
331                    .unit_config_with(#name, |entry| {
332                        let entry = entry;
333                        #title
334                        #description
335                        #hide_title
336                        #hidden
337                        #readonly
338                        entry
339                    })
340                })
341            }
342            ConfigSpec::Boolean(c) => {
343                let name = c.name.ok_or_else(|| {
344                    syn::Error::new(Span::call_site(), "boolean_config missing `name`")
345                })?;
346                let default = c.default.unwrap_or_else(|| parse_quote! { false });
347                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
348                let description = c
349                    .description
350                    .map(|d| quote! { let entry = entry.description(#d); });
351                let hide_title = if c.hide_title {
352                    quote! { let entry = entry.hide_title(); }
353                } else {
354                    quote! {}
355                };
356                let hidden = if c.hidden {
357                    quote! { let entry = entry.hidden(); }
358                } else {
359                    quote! {}
360                };
361                let readonly = if c.readonly {
362                    quote! { let entry = entry.readonly(); }
363                } else {
364                    quote! {}
365                };
366                Ok(quote! {
367                    .boolean_config_with(#name, #default, |entry| {
368                        let entry = entry;
369                        #title
370                        #description
371                        #hide_title
372                        #hidden
373                        #readonly
374                        entry
375                    })
376                })
377            }
378            ConfigSpec::Integer(c) => {
379                let name = c.name.ok_or_else(|| {
380                    syn::Error::new(Span::call_site(), "integer_config missing `name`")
381                })?;
382                let default = c.default.unwrap_or_else(|| parse_quote! { 0i64 });
383                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
384                let description = c
385                    .description
386                    .map(|d| quote! { let entry = entry.description(#d); });
387                let hide_title = if c.hide_title {
388                    quote! { let entry = entry.hide_title(); }
389                } else {
390                    quote! {}
391                };
392                let hidden = if c.hidden {
393                    quote! { let entry = entry.hidden(); }
394                } else {
395                    quote! {}
396                };
397                let readonly = if c.readonly {
398                    quote! { let entry = entry.readonly(); }
399                } else {
400                    quote! {}
401                };
402                Ok(quote! {
403                    .integer_config_with(#name, #default, |entry| {
404                        let entry = entry;
405                        #title
406                        #description
407                        #hide_title
408                        #hidden
409                        #readonly
410                        entry
411                    })
412                })
413            }
414            ConfigSpec::Number(c) => {
415                let name = c.name.ok_or_else(|| {
416                    syn::Error::new(Span::call_site(), "number_config missing `name`")
417                })?;
418                let default = c.default.unwrap_or_else(|| parse_quote! { 0.0f64 });
419                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
420                let description = c
421                    .description
422                    .map(|d| quote! { let entry = entry.description(#d); });
423                let hide_title = if c.hide_title {
424                    quote! { let entry = entry.hide_title(); }
425                } else {
426                    quote! {}
427                };
428                let hidden = if c.hidden {
429                    quote! { let entry = entry.hidden(); }
430                } else {
431                    quote! {}
432                };
433                let readonly = if c.readonly {
434                    quote! { let entry = entry.readonly(); }
435                } else {
436                    quote! {}
437                };
438                Ok(quote! {
439                    .number_config_with(#name, #default, |entry| {
440                        let entry = entry;
441                        #title
442                        #description
443                        #hide_title
444                        #hidden
445                        #readonly
446                        entry
447                    })
448                })
449            }
450            ConfigSpec::String(c) => {
451                let name = c.name.ok_or_else(|| {
452                    syn::Error::new(Span::call_site(), "string_config missing `name`")
453                })?;
454                let default = c.default.unwrap_or_else(|| parse_quote! { "" });
455                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
456                let description = c
457                    .description
458                    .map(|d| quote! { let entry = entry.description(#d); });
459                let hide_title = if c.hide_title {
460                    quote! { let entry = entry.hide_title(); }
461                } else {
462                    quote! {}
463                };
464                let hidden = if c.hidden {
465                    quote! { let entry = entry.hidden(); }
466                } else {
467                    quote! {}
468                };
469                let readonly = if c.readonly {
470                    quote! { let entry = entry.readonly(); }
471                } else {
472                    quote! {}
473                };
474                Ok(quote! {
475                    .string_config_with(#name, #default, |entry| {
476                        let entry = entry;
477                        #title
478                        #description
479                        #hide_title
480                        #hidden
481                        #readonly
482                        entry
483                    })
484                })
485            }
486            ConfigSpec::Text(c) => {
487                let name = c.name.ok_or_else(|| {
488                    syn::Error::new(Span::call_site(), "text_config missing `name`")
489                })?;
490                let default = c.default.unwrap_or_else(|| parse_quote! { "" });
491                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
492                let description = c
493                    .description
494                    .map(|d| quote! { let entry = entry.description(#d); });
495                let hide_title = if c.hide_title {
496                    quote! { let entry = entry.hide_title(); }
497                } else {
498                    quote! {}
499                };
500                let hidden = if c.hidden {
501                    quote! { let entry = entry.hidden(); }
502                } else {
503                    quote! {}
504                };
505                let readonly = if c.readonly {
506                    quote! { let entry = entry.readonly(); }
507                } else {
508                    quote! {}
509                };
510                Ok(quote! {
511                    .text_config_with(#name, #default, |entry| {
512                        let entry = entry;
513                        #title
514                        #description
515                        #hide_title
516                        #hidden
517                        #readonly
518                        entry
519                    })
520                })
521            }
522            ConfigSpec::Array(c) => {
523                let name = c.name.ok_or_else(|| {
524                    syn::Error::new(Span::call_site(), "array_config missing `name`")
525                })?;
526                let default = c.default.unwrap_or_else(|| {
527                    parse_quote! { ::modular_agent_core::AgentValue::array_default() }
528                });
529                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
530                let description = c
531                    .description
532                    .map(|d| quote! { let entry = entry.description(#d); });
533                let hide_title = if c.hide_title {
534                    quote! { let entry = entry.hide_title(); }
535                } else {
536                    quote! {}
537                };
538                let hidden = if c.hidden {
539                    quote! { let entry = entry.hidden(); }
540                } else {
541                    quote! {}
542                };
543                let readonly = if c.readonly {
544                    quote! { let entry = entry.readonly(); }
545                } else {
546                    quote! {}
547                };
548                Ok(quote! {
549                    .array_config_with(#name, #default, |entry| {
550                        let entry = entry;
551                        #title
552                        #description
553                        #hide_title
554                        #hidden
555                        #readonly
556                        entry
557                    })
558                })
559            }
560            ConfigSpec::Object(c) => {
561                let name = c.name.ok_or_else(|| {
562                    syn::Error::new(Span::call_site(), "object_config missing `name`")
563                })?;
564                let default = c.default.unwrap_or_else(|| {
565                    parse_quote! { ::modular_agent_core::AgentValue::object_default() }
566                });
567                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
568                let description = c
569                    .description
570                    .map(|d| quote! { let entry = entry.description(#d); });
571                let hide_title = if c.hide_title {
572                    quote! { let entry = entry.hide_title(); }
573                } else {
574                    quote! {}
575                };
576                let hidden = if c.hidden {
577                    quote! { let entry = entry.hidden(); }
578                } else {
579                    quote! {}
580                };
581                let readonly = if c.readonly {
582                    quote! { let entry = entry.readonly(); }
583                } else {
584                    quote! {}
585                };
586                Ok(quote! {
587                    .object_config_with(#name, #default, |entry| {
588                        let entry = entry;
589                        #title
590                        #description
591                        #hide_title
592                        #hidden
593                        #readonly
594                        entry
595                    })
596                })
597            }
598            ConfigSpec::Custom(c) => custom_config_call("custom_config_with", c),
599        })
600        .collect::<syn::Result<Vec<_>>>()?;
601
602    let global_config_calls = parsed
603        .global_configs
604        .into_iter()
605        .map(|cfg| match cfg {
606            ConfigSpec::Unit(c) => {
607                let name = c.name.ok_or_else(|| {
608                    syn::Error::new(Span::call_site(), "unit_global_config missing `name`")
609                })?;
610                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
611                let description = c
612                    .description
613                    .map(|d| quote! { let entry = entry.description(#d); });
614                let hide_title = if c.hide_title {
615                    quote! { let entry = entry.hide_title(); }
616                } else {
617                    quote! {}
618                };
619                let hidden = if c.hidden {
620                    quote! { let entry = entry.hidden(); }
621                } else {
622                    quote! {}
623                };
624                let readonly = if c.readonly {
625                    quote! { let entry = entry.readonly(); }
626                } else {
627                    quote! {}
628                };
629                Ok(quote! {
630                    .unit_global_config_with(#name, |entry| {
631                        let entry = entry;
632                        #title
633                        #description
634                        #hide_title
635                        #hidden
636                        #readonly
637                        entry
638                    })
639                })
640            }
641            ConfigSpec::Boolean(c) => {
642                let name = c.name.ok_or_else(|| {
643                    syn::Error::new(Span::call_site(), "boolean_global_config missing `name`")
644                })?;
645                let default = c.default.unwrap_or_else(|| parse_quote! { false });
646                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
647                let description = c
648                    .description
649                    .map(|d| quote! { let entry = entry.description(#d); });
650                let hide_title = if c.hide_title {
651                    quote! { let entry = entry.hide_title(); }
652                } else {
653                    quote! {}
654                };
655                let hidden = if c.hidden {
656                    quote! { let entry = entry.hidden(); }
657                } else {
658                    quote! {}
659                };
660                let readonly = if c.readonly {
661                    quote! { let entry = entry.readonly(); }
662                } else {
663                    quote! {}
664                };
665                Ok(quote! {
666                    .boolean_global_config_with(#name, #default, |entry| {
667                        let entry = entry;
668                        #title
669                        #description
670                        #hide_title
671                        #hidden
672                        #readonly
673                        entry
674                    })
675                })
676            }
677            ConfigSpec::Integer(c) => {
678                let name = c.name.ok_or_else(|| {
679                    syn::Error::new(Span::call_site(), "integer_global_config missing `name`")
680                })?;
681                let default = c.default.unwrap_or_else(|| parse_quote! { 0i64 });
682                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
683                let description = c
684                    .description
685                    .map(|d| quote! { let entry = entry.description(#d); });
686                let hide_title = if c.hide_title {
687                    quote! { let entry = entry.hide_title(); }
688                } else {
689                    quote! {}
690                };
691                let hidden = if c.hidden {
692                    quote! { let entry = entry.hidden(); }
693                } else {
694                    quote! {}
695                };
696                let readonly = if c.readonly {
697                    quote! { let entry = entry.readonly(); }
698                } else {
699                    quote! {}
700                };
701                Ok(quote! {
702                    .integer_global_config_with(#name, #default, |entry| {
703                        let entry = entry;
704                        #title
705                        #description
706                        #hide_title
707                        #hidden
708                        #readonly
709                        entry
710                    })
711                })
712            }
713            ConfigSpec::Number(c) => {
714                let name = c.name.ok_or_else(|| {
715                    syn::Error::new(Span::call_site(), "number_global_config missing `name`")
716                })?;
717                let default = c.default.unwrap_or_else(|| parse_quote! { 0.0f64 });
718                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
719                let description = c
720                    .description
721                    .map(|d| quote! { let entry = entry.description(#d); });
722                let hide_title = if c.hide_title {
723                    quote! { let entry = entry.hide_title(); }
724                } else {
725                    quote! {}
726                };
727                let hidden = if c.hidden {
728                    quote! { let entry = entry.hidden(); }
729                } else {
730                    quote! {}
731                };
732                let readonly = if c.readonly {
733                    quote! { let entry = entry.readonly(); }
734                } else {
735                    quote! {}
736                };
737                Ok(quote! {
738                    .number_global_config_with(#name, #default, |entry| {
739                        let entry = entry;
740                        #title
741                        #description
742                        #hide_title
743                        #hidden
744                        #readonly
745                        entry
746                    })
747                })
748            }
749            ConfigSpec::String(c) => {
750                let name = c.name.ok_or_else(|| {
751                    syn::Error::new(Span::call_site(), "string_global_config missing `name`")
752                })?;
753                let default = c.default.unwrap_or_else(|| parse_quote! { "" });
754                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
755                let description = c
756                    .description
757                    .map(|d| quote! { let entry = entry.description(#d); });
758                let hide_title = if c.hide_title {
759                    quote! { let entry = entry.hide_title(); }
760                } else {
761                    quote! {}
762                };
763                let hidden = if c.hidden {
764                    quote! { let entry = entry.hidden(); }
765                } else {
766                    quote! {}
767                };
768                let readonly = if c.readonly {
769                    quote! { let entry = entry.readonly(); }
770                } else {
771                    quote! {}
772                };
773                Ok(quote! {
774                    .string_global_config_with(#name, #default, |entry| {
775                        let entry = entry;
776                        #title
777                        #description
778                        #hide_title
779                        #hidden
780                        #readonly
781                        entry
782                    })
783                })
784            }
785            ConfigSpec::Text(c) => {
786                let name = c.name.ok_or_else(|| {
787                    syn::Error::new(Span::call_site(), "text_global_config missing `name`")
788                })?;
789                let default = c.default.unwrap_or_else(|| parse_quote! { "" });
790                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
791                let description = c
792                    .description
793                    .map(|d| quote! { let entry = entry.description(#d); });
794                let hide_title = if c.hide_title {
795                    quote! { let entry = entry.hide_title(); }
796                } else {
797                    quote! {}
798                };
799                let hidden = if c.hidden {
800                    quote! { let entry = entry.hidden(); }
801                } else {
802                    quote! {}
803                };
804                let readonly = if c.readonly {
805                    quote! { let entry = entry.readonly(); }
806                } else {
807                    quote! {}
808                };
809                Ok(quote! {
810                    .text_global_config_with(#name, #default, |entry| {
811                        let entry = entry;
812                        #title
813                        #description
814                        #hide_title
815                        #hidden
816                        #readonly
817                        entry
818                    })
819                })
820            }
821            ConfigSpec::Array(c) => {
822                let name = c.name.ok_or_else(|| {
823                    syn::Error::new(Span::call_site(), "array_global_config missing `name`")
824                })?;
825                let default = c.default.unwrap_or_else(|| {
826                    parse_quote! { ::modular_agent_core::AgentValue::array_default() }
827                });
828                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
829                let description = c
830                    .description
831                    .map(|d| quote! { let entry = entry.description(#d); });
832                let hide_title = if c.hide_title {
833                    quote! { let entry = entry.hide_title(); }
834                } else {
835                    quote! {}
836                };
837                let hidden = if c.hidden {
838                    quote! { let entry = entry.hidden(); }
839                } else {
840                    quote! {}
841                };
842                let readonly = if c.readonly {
843                    quote! { let entry = entry.readonly(); }
844                } else {
845                    quote! {}
846                };
847                Ok(quote! {
848                    .array_global_config_with(#name, #default, |entry| {
849                        let entry = entry;
850                        #title
851                        #description
852                        #hide_title
853                        #hidden
854                        #readonly
855                        entry
856                    })
857                })
858            }
859            ConfigSpec::Object(c) => {
860                let name = c.name.ok_or_else(|| {
861                    syn::Error::new(Span::call_site(), "object_global_config missing `name`")
862                })?;
863                let default = c.default.unwrap_or_else(|| {
864                    parse_quote! { ::modular_agent_core::AgentValue::object_default() }
865                });
866                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
867                let description = c
868                    .description
869                    .map(|d| quote! { let entry = entry.description(#d); });
870                let hide_title = if c.hide_title {
871                    quote! { let entry = entry.hide_title(); }
872                } else {
873                    quote! {}
874                };
875                let hidden = if c.hidden {
876                    quote! { let entry = entry.hidden(); }
877                } else {
878                    quote! {}
879                };
880                let readonly = if c.readonly {
881                    quote! { let entry = entry.readonly(); }
882                } else {
883                    quote! {}
884                };
885                Ok(quote! {
886                    .object_global_config_with(#name, #default, |entry| {
887                        let entry = entry;
888                        #title
889                        #description
890                        #hide_title
891                        #hidden
892                        #readonly
893                        entry
894                    })
895                })
896            }
897            ConfigSpec::Custom(c) => custom_config_call("custom_global_config_with", c),
898        })
899        .collect::<syn::Result<Vec<_>>>()?;
900
901    let definition_builder = quote! {
902        ::modular_agent_core::AgentDefinition::new(
903            #kind,
904            #name_tokens,
905            Some(::modular_agent_core::new_agent_boxed::<#ident>),
906        )
907        #title
908        #hide_title
909        #description
910        #category
911        #inputs
912        #outputs
913        #(#config_calls)*
914        #(#global_config_calls)*
915    };
916
917    let expanded = quote! {
918        #item
919
920        #data_impl
921
922        impl #impl_generics #ident #ty_generics #where_clause {
923            pub const DEF_NAME: &'static str = #name_tokens;
924
925            pub fn def_name() -> &'static str { Self::DEF_NAME }
926
927            pub fn agent_definition() -> ::modular_agent_core::AgentDefinition {
928                #definition_builder
929            }
930
931            pub fn register(ma: &::modular_agent_core::ModularAgent) {
932                ma.register_agent_definiton(Self::agent_definition());
933            }
934        }
935
936        ::modular_agent_core::inventory::submit! {
937            ::modular_agent_core::AgentRegistration {
938                build: || #definition_builder,
939            }
940        }
941    };
942
943    Ok(expanded)
944}
945
946fn parse_name_type_title_description(
947    meta: &Meta,
948    name: &mut Option<Expr>,
949    type_: &mut Option<Expr>,
950    title: &mut Option<Expr>,
951    description: &mut Option<Expr>,
952) -> bool {
953    match meta {
954        Meta::NameValue(nv) if nv.path.is_ident("name") => {
955            *name = Some(nv.value.clone());
956            true
957        }
958        Meta::NameValue(nv) if nv.path.is_ident("type") => {
959            *type_ = Some(nv.value.clone());
960            true
961        }
962        Meta::NameValue(nv) if nv.path.is_ident("type_") => {
963            *type_ = Some(nv.value.clone());
964            true
965        }
966        Meta::NameValue(nv) if nv.path.is_ident("title") => {
967            *title = Some(nv.value.clone());
968            true
969        }
970        Meta::NameValue(nv) if nv.path.is_ident("description") => {
971            *description = Some(nv.value.clone());
972            true
973        }
974        _ => false,
975    }
976}
977
978fn parse_custom_config(list: MetaList) -> syn::Result<CustomConfig> {
979    let mut name = None;
980    let mut default = None;
981    let mut type_ = None;
982    let mut title = None;
983    let mut description = None;
984    let mut hide_title = false;
985    let mut hidden = false;
986    let mut readonly = false;
987    let nested = list.parse_args_with(Punctuated::<Meta, Comma>::parse_terminated)?;
988
989    for meta in nested {
990        if parse_name_type_title_description(
991            &meta,
992            &mut name,
993            &mut type_,
994            &mut title,
995            &mut description,
996        ) {
997            continue;
998        }
999
1000        match meta {
1001            Meta::NameValue(nv) if nv.path.is_ident("default") => {
1002                default = Some(nv.value.clone());
1003            }
1004            Meta::Path(p) if p.is_ident("hide_title") => {
1005                hide_title = true;
1006            }
1007            Meta::Path(p) if p.is_ident("hidden") => {
1008                hidden = true;
1009            }
1010            Meta::Path(p) if p.is_ident("readonly") => {
1011                readonly = true;
1012            }
1013            other => {
1014                return Err(syn::Error::new_spanned(
1015                    other,
1016                    "custom_config supports name, default, type/type_, title, description, hide_title, hidden, readonly",
1017                ));
1018            }
1019        }
1020    }
1021
1022    let name = name.ok_or_else(|| syn::Error::new(list.span(), "config missing `name`"))?;
1023    let default =
1024        default.ok_or_else(|| syn::Error::new(list.span(), "config missing `default`"))?;
1025    let type_ = type_.ok_or_else(|| syn::Error::new(list.span(), "config missing `type`"))?;
1026
1027    Ok(CustomConfig {
1028        name,
1029        default,
1030        type_,
1031        title,
1032        description,
1033        hide_title,
1034        hidden,
1035        readonly,
1036    })
1037}
1038
1039fn collect_exprs(list: MetaList) -> syn::Result<Vec<Expr>> {
1040    let values = list.parse_args_with(Punctuated::<Expr, Comma>::parse_terminated)?;
1041    Ok(values.into_iter().collect())
1042}
1043
1044fn parse_expr_array(expr: Expr) -> syn::Result<Vec<Expr>> {
1045    if let Expr::Array(arr) = expr {
1046        Ok(arr.elems.into_iter().collect())
1047    } else {
1048        Err(syn::Error::new_spanned(
1049            expr,
1050            "inputs/outputs expect array expressions",
1051        ))
1052    }
1053}
1054
1055fn parse_common_config(list: MetaList) -> syn::Result<CommonConfig> {
1056    let mut cfg = CommonConfig::default();
1057    let nested = list.parse_args_with(Punctuated::<Meta, Comma>::parse_terminated)?;
1058
1059    for meta in nested {
1060        match meta {
1061            Meta::NameValue(nv) if nv.path.is_ident("name") => {
1062                cfg.name = Some(nv.value.clone());
1063            }
1064            Meta::NameValue(nv) if nv.path.is_ident("default") => {
1065                cfg.default = Some(nv.value.clone());
1066            }
1067            Meta::NameValue(nv) if nv.path.is_ident("title") => {
1068                cfg.title = Some(nv.value.clone());
1069            }
1070            Meta::NameValue(nv) if nv.path.is_ident("description") => {
1071                cfg.description = Some(nv.value.clone());
1072            }
1073            Meta::Path(p) if p.is_ident("hide_title") => {
1074                cfg.hide_title = true;
1075            }
1076            Meta::Path(p) if p.is_ident("hidden") => {
1077                cfg.hidden = true;
1078            }
1079            Meta::Path(p) if p.is_ident("readonly") => {
1080                cfg.readonly = true;
1081            }
1082            other => {
1083                return Err(syn::Error::new_spanned(
1084                    other,
1085                    "config supports name, default, title, description, hide_title, hidden, readonly",
1086                ));
1087            }
1088        }
1089    }
1090
1091    if cfg.name.is_none() {
1092        return Err(syn::Error::new(list.span(), "config missing `name`"));
1093    }
1094    Ok(cfg)
1095}
1096
1097fn custom_config_call(method: &str, cfg: CustomConfig) -> syn::Result<proc_macro2::TokenStream> {
1098    let CustomConfig {
1099        name,
1100        default,
1101        type_,
1102        title,
1103        description,
1104        hide_title,
1105        hidden,
1106        readonly,
1107    } = cfg;
1108    let title = title.map(|t| quote! { let entry = entry.title(#t); });
1109    let description = description.map(|d| quote! { let entry = entry.description(#d); });
1110    let hide_title = if hide_title {
1111        quote! { let entry = entry.hide_title(); }
1112    } else {
1113        quote! {}
1114    };
1115    let hidden = if hidden {
1116        quote! { let entry = entry.hidden(); }
1117    } else {
1118        quote! {}
1119    };
1120    let readonly = if readonly {
1121        quote! { let entry = entry.readonly(); }
1122    } else {
1123        quote! {}
1124    };
1125    let method_ident = format_ident!("{}", method);
1126
1127    Ok(quote! {
1128        .#method_ident(#name, #default, #type_, |entry| {
1129            let entry = entry;
1130            #title
1131            #description
1132            #hide_title
1133            #hidden
1134            #readonly
1135            entry
1136        })
1137    })
1138}