Skip to main content

harness_rs_macros/
lib.rs

1//! Procedural macros for the harness framework.
2//!
3//! - `#[skill]` — function-shaped skill (agentskills.io-compliant)
4//! - `#[tool]`  — function-shaped tool, name + risk + schema
5//! - `#[guide]` — function-shaped guide, scope-based feedforward
6//! - `#[sensor]`— function-shaped sensor, stage-based feedback
7//! - `#[hook]`  — synchronous hook on a lifecycle event
8
9use proc_macro::TokenStream;
10use proc_macro2::TokenStream as TokenStream2;
11use quote::{format_ident, quote};
12use syn::{Expr, ExprLit, ItemFn, Lit, Meta, Token, parse_macro_input, punctuated::Punctuated};
13
14// ============================================================
15// #[skill]
16// ============================================================
17
18#[proc_macro_attribute]
19pub fn skill(attr: TokenStream, item: TokenStream) -> TokenStream {
20    let item_fn = parse_macro_input!(item as ItemFn);
21    let args = parse_macro_input!(attr with Punctuated<Meta, Token![,]>::parse_terminated);
22    match expand_skill(args, item_fn) {
23        Ok(ts) => ts.into(),
24        Err(e) => e.to_compile_error().into(),
25    }
26}
27
28fn expand_skill(args: Punctuated<Meta, Token![,]>, item_fn: ItemFn) -> syn::Result<TokenStream2> {
29    let fn_ident = item_fn.sig.ident.clone();
30
31    let mut name: Option<String> = None;
32    let mut description: Option<String> = None;
33    let mut license: Option<String> = None;
34    let mut compatibility: Option<String> = None;
35    let mut allowed_tools: Option<String> = None;
36    let mut harness_kind: Option<String> = None;
37    let mut harness_risk: Option<String> = None;
38
39    for meta in &args {
40        match meta {
41            Meta::NameValue(nv) => {
42                let key = nv
43                    .path
44                    .get_ident()
45                    .ok_or_else(|| syn::Error::new_spanned(&nv.path, "expected ident"))?
46                    .to_string();
47                let value = lit_str(&nv.value)?;
48                match key.as_str() {
49                    "name" => name = Some(value),
50                    "description" => description = Some(value),
51                    "license" => license = Some(value),
52                    "compatibility" => compatibility = Some(value),
53                    "allowed_tools" => allowed_tools = Some(value),
54                    other => return err(nv, format!("unknown attribute `{other}`")),
55                }
56            }
57            Meta::List(ml) if ml.path.is_ident("harness") => {
58                let nested: Punctuated<Meta, Token![,]> =
59                    ml.parse_args_with(Punctuated::parse_terminated)?;
60                for m in &nested {
61                    if let Meta::NameValue(nv) = m {
62                        let k = nv
63                            .path
64                            .get_ident()
65                            .ok_or_else(|| syn::Error::new_spanned(&nv.path, "expected ident"))?
66                            .to_string();
67                        let v = lit_str(&nv.value)?;
68                        match k.as_str() {
69                            "kind" => harness_kind = Some(v),
70                            "risk" => harness_risk = Some(v),
71                            other => return err(nv, format!("unknown harness(...) key `{other}`")),
72                        }
73                    } else {
74                        return err(m, "expected key = \"value\"");
75                    }
76                }
77            }
78            other => return err(other, "expected `key = \"value\"` or `harness(...)`"),
79        }
80    }
81
82    let name = name.ok_or_else(|| syn::Error::new_spanned(&fn_ident, "missing required `name`"))?;
83    validate_skill_name(&name).map_err(|r| syn::Error::new_spanned(&fn_ident, r))?;
84    let description = description
85        .or_else(|| extract_doc_comments(&item_fn.attrs))
86        .ok_or_else(|| {
87            syn::Error::new_spanned(&fn_ident, "missing `description` (or `///` doc-comment)")
88        })?;
89    if description.is_empty() {
90        return err(&fn_ident, "description must not be empty");
91    }
92    if description.len() > 1024 {
93        return err(
94            &fn_ident,
95            format!("description exceeds 1024 chars (got {})", description.len()),
96        );
97    }
98
99    let marker = format_ident!("__Harness_Skill_{}", to_pascal_case(&name));
100    let has_harness_meta = harness_kind.is_some() || harness_risk.is_some();
101    let metadata_tok = if has_harness_meta {
102        let mut json = String::from("{");
103        let mut comma = false;
104        if let Some(k) = &harness_kind {
105            json.push_str(&format!("\"kind\":\"{k}\""));
106            comma = true;
107        }
108        if let Some(r) = &harness_risk {
109            if comma {
110                json.push(',');
111            }
112            json.push_str(&format!("\"risk\":\"{r}\""));
113        }
114        json.push('}');
115        quote! {{
116            let mut m = ::std::collections::BTreeMap::new();
117            let v: ::harness_core::__export::serde_json::Value =
118                ::harness_core::__export::serde_json::from_str(#json).unwrap();
119            m.insert("harness".to_string(), v);
120            m
121        }}
122    } else {
123        quote! { ::std::collections::BTreeMap::new() }
124    };
125
126    let lic_tok = opt_string(license.as_deref());
127    let compat_tok = opt_string(compatibility.as_deref());
128    let allowed_tok = opt_string(allowed_tools.as_deref());
129
130    Ok(quote! {
131        #item_fn
132
133        #[doc(hidden)]
134        #[allow(non_camel_case_types)]
135        pub struct #marker;
136
137        impl ::harness_core::Skill for #marker {
138            fn manifest(&self) -> &::harness_core::SkillManifest {
139                static M: ::std::sync::OnceLock<::harness_core::SkillManifest> = ::std::sync::OnceLock::new();
140                M.get_or_init(|| ::harness_core::SkillManifest {
141                    name:          #name.to_string(),
142                    description:   #description.to_string(),
143                    license:       #lic_tok,
144                    compatibility: #compat_tok,
145                    metadata:      #metadata_tok,
146                    allowed_tools: #allowed_tok,
147                })
148            }
149            fn body(&self) -> ::std::borrow::Cow<'_, str> {
150                ::std::borrow::Cow::Borrowed(#description)
151            }
152            fn handler(&self) -> ::std::option::Option<::harness_core::SkillHandler> {
153                ::std::option::Option::Some(::std::sync::Arc::new(|ctx, world| {
154                    ::std::boxed::Box::pin(#fn_ident(ctx, world))
155                }))
156            }
157        }
158
159        ::harness_core::__export::inventory::submit! {
160            ::harness_core::SkillEntry {
161                factory: || ::std::sync::Arc::new(#marker)
162                    as ::std::sync::Arc<dyn ::harness_core::Skill>,
163            }
164        }
165    })
166}
167
168// ============================================================
169// #[tool]
170// ============================================================
171
172#[proc_macro_attribute]
173pub fn tool(attr: TokenStream, item: TokenStream) -> TokenStream {
174    let item_fn = parse_macro_input!(item as ItemFn);
175    let args = parse_macro_input!(attr with Punctuated<Meta, Token![,]>::parse_terminated);
176    match expand_tool(args, item_fn) {
177        Ok(ts) => ts.into(),
178        Err(e) => e.to_compile_error().into(),
179    }
180}
181
182fn expand_tool(args: Punctuated<Meta, Token![,]>, item_fn: ItemFn) -> syn::Result<TokenStream2> {
183    let fn_ident = item_fn.sig.ident.clone();
184    let mut name: Option<String> = None;
185    let mut description: Option<String> = None;
186    let mut risk: String = "read-only".into();
187    let mut schema: Option<String> = None;
188
189    for meta in &args {
190        if let Meta::NameValue(nv) = meta {
191            let key = nv
192                .path
193                .get_ident()
194                .map(|i| i.to_string())
195                .unwrap_or_default();
196            let value = lit_str(&nv.value)?;
197            match key.as_str() {
198                "name" => name = Some(value),
199                "description" => description = Some(value),
200                "risk" => risk = value,
201                "schema" => schema = Some(value),
202                other => return err(nv, format!("unknown attribute `{other}`")),
203            }
204        } else {
205            return err(meta, "expected `key = \"value\"`");
206        }
207    }
208
209    let name = name.ok_or_else(|| syn::Error::new_spanned(&fn_ident, "missing required `name`"))?;
210    let description = description
211        .or_else(|| extract_doc_comments(&item_fn.attrs))
212        .ok_or_else(|| {
213            syn::Error::new_spanned(&fn_ident, "missing `description` (or `///` doc-comment)")
214        })?;
215    let schema = schema.unwrap_or_else(|| r#"{"type":"object"}"#.to_string());
216    // Validate schema parses.
217    if let Err(e) = serde_json::from_str::<serde_json::Value>(&schema) {
218        return err(&fn_ident, format!("schema is not valid JSON: {e}"));
219    }
220    let risk_variant = match risk.as_str() {
221        "read-only" => quote!(::harness_core::ToolRisk::ReadOnly),
222        "idempotent" => quote!(::harness_core::ToolRisk::Idempotent),
223        "destructive" => quote!(::harness_core::ToolRisk::Destructive),
224        "network" => quote!(::harness_core::ToolRisk::Network),
225        other => {
226            return err(
227                &fn_ident,
228                format!(
229                    "risk must be one of read-only|idempotent|destructive|network, got `{other}`"
230                ),
231            );
232        }
233    };
234    let marker = format_ident!("__Harness_Tool_{}", to_pascal_case(&name));
235
236    Ok(quote! {
237        #item_fn
238
239        #[doc(hidden)]
240        #[allow(non_camel_case_types)]
241        pub struct #marker;
242
243        #[::harness_core::__export::async_trait]
244        impl ::harness_core::Tool for #marker {
245            fn name(&self) -> &str { #name }
246            fn schema(&self) -> &::harness_core::ToolSchema {
247                static S: ::std::sync::OnceLock<::harness_core::ToolSchema> = ::std::sync::OnceLock::new();
248                S.get_or_init(|| ::harness_core::ToolSchema {
249                    name:        #name.to_string(),
250                    description: #description.to_string(),
251                    input:       ::harness_core::__export::serde_json::from_str(#schema).unwrap(),
252                })
253            }
254            fn risk(&self) -> ::harness_core::ToolRisk { #risk_variant }
255            async fn invoke(
256                &self,
257                args: ::harness_core::__export::serde_json::Value,
258                world: &mut ::harness_core::World,
259            ) -> ::std::result::Result<::harness_core::ToolResult, ::harness_core::ToolError> {
260                #fn_ident(args, world).await
261            }
262        }
263
264        ::harness_core::__export::inventory::submit! {
265            ::harness_core::ToolEntry {
266                factory: || ::std::sync::Arc::new(#marker)
267                    as ::std::sync::Arc<dyn ::harness_core::Tool>,
268            }
269        }
270    })
271}
272
273// ============================================================
274// #[guide]
275// ============================================================
276
277#[proc_macro_attribute]
278pub fn guide(attr: TokenStream, item: TokenStream) -> TokenStream {
279    let item_fn = parse_macro_input!(item as ItemFn);
280    let args = parse_macro_input!(attr with Punctuated<Meta, Token![,]>::parse_terminated);
281    match expand_guide(args, item_fn) {
282        Ok(ts) => ts.into(),
283        Err(e) => e.to_compile_error().into(),
284    }
285}
286
287fn expand_guide(args: Punctuated<Meta, Token![,]>, item_fn: ItemFn) -> syn::Result<TokenStream2> {
288    let fn_ident = item_fn.sig.ident.clone();
289    let mut id: Option<String> = None;
290    let mut scope: String = "always".into();
291    let mut kind: String = "inferential".into();
292    let mut task_matches: Vec<String> = Vec::new();
293
294    for meta in &args {
295        if let Meta::NameValue(nv) = meta {
296            let key = nv
297                .path
298                .get_ident()
299                .map(|i| i.to_string())
300                .unwrap_or_default();
301            let value = lit_str(&nv.value)?;
302            match key.as_str() {
303                "id" => id = Some(value),
304                "scope" => scope = value,
305                "kind" => kind = value,
306                "task_matches" => {
307                    task_matches = value.split(',').map(|s| s.trim().to_string()).collect()
308                }
309                other => return err(nv, format!("unknown attribute `{other}`")),
310            }
311        } else {
312            return err(meta, "expected `key = \"value\"`");
313        }
314    }
315    let id = id.unwrap_or_else(|| fn_ident.to_string());
316    let kind_variant = match kind.as_str() {
317        "computational" => quote!(::harness_core::Execution::Computational),
318        "inferential" => quote!(::harness_core::Execution::Inferential),
319        other => {
320            return err(
321                &fn_ident,
322                format!("kind must be computational|inferential, got `{other}`"),
323            );
324        }
325    };
326    let scope_expr = match scope.as_str() {
327        "always" => quote!(::harness_core::GuideScope::Always),
328        "task-matches" if !task_matches.is_empty() => {
329            let items = task_matches.iter().map(|s| quote!(#s.to_string()));
330            quote!(::harness_core::GuideScope::TaskMatches(vec![#(#items),*]))
331        }
332        other => {
333            return err(
334                &fn_ident,
335                format!(
336                    "unsupported scope `{other}`; use \"always\" or \"task-matches\" + task_matches=..."
337                ),
338            );
339        }
340    };
341    let marker = format_ident!("__Harness_Guide_{}", to_pascal_case(&id));
342
343    Ok(quote! {
344        #item_fn
345
346        #[doc(hidden)]
347        #[allow(non_camel_case_types)]
348        pub struct #marker;
349
350        #[::harness_core::__export::async_trait]
351        impl ::harness_core::Guide for #marker {
352            fn id(&self) -> &::harness_core::GuideId {
353                static I: ::std::sync::OnceLock<::harness_core::GuideId> = ::std::sync::OnceLock::new();
354                I.get_or_init(|| #id.to_string())
355            }
356            fn kind(&self) -> ::harness_core::Execution { #kind_variant }
357            fn scope(&self) -> &::harness_core::GuideScope {
358                static S: ::std::sync::OnceLock<::harness_core::GuideScope> = ::std::sync::OnceLock::new();
359                S.get_or_init(|| #scope_expr)
360            }
361            async fn apply(
362                &self,
363                ctx: &mut ::harness_core::Context,
364                world: &::harness_core::World,
365            ) -> ::std::result::Result<(), ::harness_core::GuideError> {
366                #fn_ident(ctx, world).await
367            }
368        }
369
370        ::harness_core::__export::inventory::submit! {
371            ::harness_core::GuideEntry {
372                factory: || ::std::sync::Arc::new(#marker)
373                    as ::std::sync::Arc<dyn ::harness_core::Guide>,
374            }
375        }
376    })
377}
378
379// ============================================================
380// #[sensor]
381// ============================================================
382
383#[proc_macro_attribute]
384pub fn sensor(attr: TokenStream, item: TokenStream) -> TokenStream {
385    let item_fn = parse_macro_input!(item as ItemFn);
386    let args = parse_macro_input!(attr with Punctuated<Meta, Token![,]>::parse_terminated);
387    match expand_sensor(args, item_fn) {
388        Ok(ts) => ts.into(),
389        Err(e) => e.to_compile_error().into(),
390    }
391}
392
393fn expand_sensor(args: Punctuated<Meta, Token![,]>, item_fn: ItemFn) -> syn::Result<TokenStream2> {
394    let fn_ident = item_fn.sig.ident.clone();
395    let mut id: Option<String> = None;
396    let mut stage: String = "self-correct".into();
397    let mut kind: String = "computational".into();
398
399    for meta in &args {
400        if let Meta::NameValue(nv) = meta {
401            let key = nv
402                .path
403                .get_ident()
404                .map(|i| i.to_string())
405                .unwrap_or_default();
406            let value = lit_str(&nv.value)?;
407            match key.as_str() {
408                "id" => id = Some(value),
409                "stage" => stage = value,
410                "kind" => kind = value,
411                other => return err(nv, format!("unknown attribute `{other}`")),
412            }
413        } else {
414            return err(meta, "expected `key = \"value\"`");
415        }
416    }
417    let id = id.unwrap_or_else(|| fn_ident.to_string());
418    let kind_variant = match kind.as_str() {
419        "computational" => quote!(::harness_core::Execution::Computational),
420        "inferential" => quote!(::harness_core::Execution::Inferential),
421        other => {
422            return err(
423                &fn_ident,
424                format!("kind must be computational|inferential, got `{other}`"),
425            );
426        }
427    };
428    let stage_variant = match stage.as_str() {
429        "pre-action" => quote!(::harness_core::Stage::PreAction),
430        "self-correct" => quote!(::harness_core::Stage::SelfCorrect),
431        "pre-commit" => quote!(::harness_core::Stage::PreCommit),
432        "post-integrate" => quote!(::harness_core::Stage::PostIntegrate),
433        "continuous" => quote!(::harness_core::Stage::Continuous),
434        other => return err(&fn_ident, format!("unknown stage `{other}`")),
435    };
436    let marker = format_ident!("__Harness_Sensor_{}", to_pascal_case(&id));
437
438    Ok(quote! {
439        #item_fn
440
441        #[doc(hidden)]
442        #[allow(non_camel_case_types)]
443        pub struct #marker;
444
445        #[::harness_core::__export::async_trait]
446        impl ::harness_core::Sensor for #marker {
447            fn id(&self) -> &::harness_core::SensorId {
448                static I: ::std::sync::OnceLock<::harness_core::SensorId> = ::std::sync::OnceLock::new();
449                I.get_or_init(|| #id.to_string())
450            }
451            fn kind(&self) -> ::harness_core::Execution { #kind_variant }
452            fn stage(&self) -> ::harness_core::Stage { #stage_variant }
453            async fn observe(
454                &self,
455                action: &::harness_core::Action,
456                world: &::harness_core::World,
457            ) -> ::std::result::Result<::std::vec::Vec<::harness_core::Signal>, ::harness_core::SensorError> {
458                #fn_ident(action, world).await
459            }
460        }
461
462        ::harness_core::__export::inventory::submit! {
463            ::harness_core::SensorEntry {
464                factory: || ::std::sync::Arc::new(#marker)
465                    as ::std::sync::Arc<dyn ::harness_core::Sensor>,
466            }
467        }
468    })
469}
470
471// ============================================================
472// #[hook]
473// ============================================================
474
475#[proc_macro_attribute]
476pub fn hook(attr: TokenStream, item: TokenStream) -> TokenStream {
477    let item_fn = parse_macro_input!(item as ItemFn);
478    let args = parse_macro_input!(attr with Punctuated<Meta, Token![,]>::parse_terminated);
479    match expand_hook(args, item_fn) {
480        Ok(ts) => ts.into(),
481        Err(e) => e.to_compile_error().into(),
482    }
483}
484
485fn expand_hook(args: Punctuated<Meta, Token![,]>, item_fn: ItemFn) -> syn::Result<TokenStream2> {
486    let fn_ident = item_fn.sig.ident.clone();
487    let mut name: Option<String> = None;
488    let mut event: Option<String> = None;
489
490    for meta in &args {
491        if let Meta::NameValue(nv) = meta {
492            let key = nv
493                .path
494                .get_ident()
495                .map(|i| i.to_string())
496                .unwrap_or_default();
497            let value = lit_str(&nv.value)?;
498            match key.as_str() {
499                "name" => name = Some(value),
500                "event" => event = Some(value),
501                other => return err(nv, format!("unknown attribute `{other}`")),
502            }
503        } else {
504            return err(meta, "expected `key = \"value\"`");
505        }
506    }
507    let event =
508        event.ok_or_else(|| syn::Error::new_spanned(&fn_ident, "missing required `event`"))?;
509    let name = name.unwrap_or_else(|| fn_ident.to_string());
510    let marker = format_ident!("__Harness_Hook_{}", to_pascal_case(&name));
511
512    Ok(quote! {
513        #item_fn
514
515        #[doc(hidden)]
516        #[allow(non_camel_case_types)]
517        pub struct #marker;
518
519        impl ::harness_core::Hook for #marker {
520            fn name(&self) -> &str { #name }
521            fn matches(&self, ev: &::harness_core::Event<'_>) -> bool {
522                ev.name() == #event
523            }
524            fn fire(
525                &self,
526                ev: &::harness_core::Event<'_>,
527                world: &mut ::harness_core::World,
528            ) -> ::harness_core::HookOutcome {
529                #fn_ident(ev, world)
530            }
531        }
532
533        ::harness_core::__export::inventory::submit! {
534            ::harness_core::HookEntry {
535                factory: || ::std::sync::Arc::new(#marker)
536                    as ::std::sync::Arc<dyn ::harness_core::Hook>,
537            }
538        }
539    })
540}
541
542// ============================================================
543// Shared helpers
544// ============================================================
545
546fn lit_str(expr: &Expr) -> syn::Result<String> {
547    if let Expr::Lit(ExprLit {
548        lit: Lit::Str(s), ..
549    }) = expr
550    {
551        Ok(s.value())
552    } else {
553        Err(syn::Error::new_spanned(expr, "expected string literal"))
554    }
555}
556
557fn opt_string(v: Option<&str>) -> TokenStream2 {
558    match v {
559        Some(s) => quote! { ::std::option::Option::Some(#s.to_string()) },
560        None => quote! { ::std::option::Option::None },
561    }
562}
563
564fn validate_skill_name(name: &str) -> Result<(), String> {
565    if name.is_empty() {
566        return Err("name must not be empty".into());
567    }
568    if name.len() > 64 {
569        return Err(format!("name length {} > 64", name.len()));
570    }
571    if name.starts_with('-') || name.ends_with('-') {
572        return Err("name must not start or end with `-`".into());
573    }
574    if name.contains("--") {
575        return Err("name must not contain `--`".into());
576    }
577    for (i, c) in name.char_indices() {
578        if !(c.is_ascii_digit() || c.is_ascii_lowercase() || c == '-') {
579            return Err(format!("name contains invalid char `{c}` at byte {i}"));
580        }
581    }
582    Ok(())
583}
584
585fn extract_doc_comments(attrs: &[syn::Attribute]) -> Option<String> {
586    let mut lines: Vec<String> = Vec::new();
587    for attr in attrs {
588        if !attr.path().is_ident("doc") {
589            continue;
590        }
591        if let Meta::NameValue(nv) = &attr.meta
592            && let Expr::Lit(ExprLit {
593                lit: Lit::Str(s), ..
594            }) = &nv.value
595        {
596            lines.push(s.value().trim().to_string());
597        }
598    }
599    if lines.is_empty() {
600        None
601    } else {
602        Some(lines.join(" ").trim().to_string())
603    }
604}
605
606fn to_pascal_case(s: &str) -> String {
607    let mut out = String::new();
608    let mut upper = true;
609    for c in s.chars() {
610        if c == '-' || c == '_' {
611            upper = true;
612        } else if upper {
613            out.push(c.to_ascii_uppercase());
614            upper = false;
615        } else {
616            out.push(c);
617        }
618    }
619    out
620}
621
622fn err<T: quote::ToTokens, R>(tokens: T, msg: impl Into<String>) -> syn::Result<R> {
623    Err(syn::Error::new_spanned(tokens, msg.into()))
624}