Skip to main content

klieo_macros/
lib.rs

1#![deny(missing_docs)]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! Procedural macros for the klieo agent framework.
4//!
5//! See the docs on [`tool`] for the `#[tool]` attribute macro and on
6//! [`derive_klieo_response`] for the `#[derive(KlieoResponse)]` macro.
7
8extern crate proc_macro;
9
10mod codegen;
11mod parse;
12
13use parse::{ToolAttr, ToolDecl, ToolFn};
14use proc_macro::TokenStream;
15use quote::quote;
16use syn::{parse_macro_input, spanned::Spanned, DeriveInput, FnArg, ItemFn, Pat, Type};
17
18/// Turn an `async fn` into a `klieo_core::Tool` implementation.
19///
20/// # Example
21///
22/// ```ignore
23/// use klieo_core::tool::ToolCtx;
24/// use klieo_core::error::ToolError;
25///
26/// #[klieo_macros::tool(description = "Greet a person")]
27/// async fn greet(_ctx: &ToolCtx, name: String) -> Result<String, ToolError> {
28///     Ok(format!("Hello, {name}"))
29/// }
30/// ```
31///
32/// Generates a `GreetTool` zero-sized struct that implements
33/// `klieo_core::Tool`. The expansion routes serde / schemars / serde_json
34/// through `klieo::__private`, so the user crate needs only `klieo` (feature
35/// `macros`) plus `async-trait` as direct dependencies — no direct serde,
36/// serde_json, or schemars. (The tool's *return type* must still implement
37/// `serde::Serialize`, which is satisfied transitively through klieo's
38/// re-exported serde for primitives; a custom return struct only needs the
39/// derive, reachable via `klieo::__private::serde`.)
40#[proc_macro_attribute]
41pub fn tool(attr: TokenStream, item: TokenStream) -> TokenStream {
42    let attr = parse_macro_input!(attr as ToolAttr);
43    let item_fn = parse_macro_input!(item as ItemFn);
44    let func = match ToolFn::parse(item_fn) {
45        Ok(f) => f,
46        Err(e) => return e.to_compile_error().into(),
47    };
48    let decl = ToolDecl { attr, func };
49    codegen::expand(decl).into()
50}
51
52/// Mark an `async fn` test as a klieo test: emits a sync
53/// `#[test] fn` that builds a `current_thread` tokio runtime, optionally
54/// constructs a `klieo_core::test_utils::TestContext`, and `block_on`s
55/// the body.
56///
57/// # Accepted signatures
58///
59/// ```ignore
60/// #[klieo::test]
61/// async fn name() { /* … */ }
62///
63/// #[klieo::test]
64/// async fn name(ctx: TestContext) { /* … */ }
65/// ```
66///
67/// In the second form the macro builds `TestContext::default()` and
68/// passes it in by value, so the test body can chain builder calls
69/// (`ctx.with_canned_llm_responses(...)`) without touching `Arc<dyn …>`
70/// plumbing.
71///
72/// The emitted code uses absolute `::tokio` / `::klieo_core` paths,
73/// so callers do not need either crate in scope.
74#[proc_macro_attribute]
75pub fn test(_attr: TokenStream, item: TokenStream) -> TokenStream {
76    let item_fn = parse_macro_input!(item as ItemFn);
77
78    if item_fn.sig.asyncness.is_none() {
79        return syn::Error::new_spanned(
80            item_fn.sig.fn_token,
81            "#[klieo::test] requires an async fn",
82        )
83        .to_compile_error()
84        .into();
85    }
86
87    let fn_name = &item_fn.sig.ident;
88    let body = &item_fn.block;
89    let attrs = &item_fn.attrs;
90    let vis = &item_fn.vis;
91    let inputs = &item_fn.sig.inputs;
92
93    let injects_ctx = match inputs.len() {
94        0 => false,
95        1 => {
96            // Must be a typed pattern (no self), an ident pattern, and
97            // the type tail must be `TestContext`. We accept any module
98            // prefix because absolute paths (`::klieo_core::…`) and
99            // unqualified imports (`TestContext`) are both common in
100            // tests.
101            let arg = inputs.first().unwrap();
102            let pat_ty = match arg {
103                FnArg::Typed(pt) => pt,
104                FnArg::Receiver(_) => {
105                    return syn::Error::new_spanned(arg, "#[klieo::test] cannot decorate methods")
106                        .to_compile_error()
107                        .into();
108                }
109            };
110            if !matches!(*pat_ty.pat, Pat::Ident(_)) {
111                return syn::Error::new_spanned(
112                    &pat_ty.pat,
113                    "#[klieo::test] requires the argument to be `name: TestContext`",
114                )
115                .to_compile_error()
116                .into();
117            }
118            if !is_test_context_type(&pat_ty.ty) {
119                return syn::Error::new_spanned(
120                    &pat_ty.ty,
121                    "#[klieo::test] argument must be `TestContext` (the type-name tail must be `TestContext`)",
122                )
123                .to_compile_error()
124                .into();
125            }
126            true
127        }
128        _ => {
129            return syn::Error::new(
130                inputs.span(),
131                "#[klieo::test] accepts at most one argument: `ctx: TestContext`",
132            )
133            .to_compile_error()
134            .into();
135        }
136    };
137
138    let runtime_build = quote! {
139        ::tokio::runtime::Builder::new_current_thread()
140            .enable_all()
141            .build()
142            .expect("tokio current-thread runtime builds")
143    };
144
145    let expanded = if injects_ctx {
146        // Re-emit the argument name (`ctx` in the canonical shape) so
147        // the body's references to it resolve. We bind it to a fresh
148        // `TestContext::default()`. We preserve the user-written type
149        // annotation (`TestContext` or `klieo_core::…::TestContext`)
150        // so the import the user added at the call site stays
151        // referenced — otherwise `#[allow(unused_imports)]` would be
152        // required for every test fn.
153        let arg = inputs.first().unwrap();
154        let pat_ty = match arg {
155            FnArg::Typed(pt) => pt,
156            _ => unreachable!("guarded above"),
157        };
158        let arg_pat = &pat_ty.pat;
159        let arg_ty = &pat_ty.ty;
160        quote! {
161            #( #attrs )*
162            #[::core::prelude::v1::test]
163            #vis fn #fn_name() {
164                #runtime_build .block_on(async move {
165                    #[allow(unused_mut)]
166                    let mut #arg_pat: #arg_ty =
167                        ::klieo::__private::klieo_core::test_utils::TestContext::default();
168                    #body
169                });
170            }
171        }
172    } else {
173        quote! {
174            #( #attrs )*
175            #[::core::prelude::v1::test]
176            #vis fn #fn_name() {
177                #runtime_build .block_on(async move {
178                    #body
179                });
180            }
181        }
182    };
183
184    expanded.into()
185}
186
187/// `true` if `ty`'s final path segment is `TestContext`. Permissive on
188/// purpose: callers may use the absolute path
189/// `klieo_core::test_utils::TestContext`, a use-imported `TestContext`,
190/// or an alias — we only assert the tail.
191fn is_test_context_type(ty: &Type) -> bool {
192    let Type::Path(tp) = ty else {
193        return false;
194    };
195    tp.path
196        .segments
197        .last()
198        .map(|s| s.ident == "TestContext")
199        .unwrap_or(false)
200}
201
202/// Derive `klieo_core::KlieoResponse` for a struct so it can be the
203/// typed payload of a structured LLM response.
204///
205/// The derive only emits the `KlieoResponse` impl itself (its
206/// `json_schema()` body routes through `klieo::__private`). It does NOT
207/// emit the `JsonSchema` / `Deserialize` derives, so the caller MUST still
208/// derive `schemars::JsonSchema` and `serde::Deserialize` on the same type.
209/// Keeping those two derives at the call site makes the schema/deserialize
210/// surface visible rather than hidden inside this macro's expansion.
211///
212/// # Required derives
213///
214/// A tool author who already lists `serde` + `schemars` directly writes the
215/// derives plainly:
216///
217/// ```ignore
218/// use klieo::macros::KlieoResponse;
219/// use schemars::JsonSchema;
220/// use serde::Deserialize;
221///
222/// #[derive(Deserialize, JsonSchema, KlieoResponse)]
223/// struct Greeting {
224///     greeting: String,
225/// }
226/// ```
227///
228/// To avoid direct serde/schemars dependencies entirely, reach both derives
229/// through the `macros`-gated `klieo::__private` re-exports and pin the
230/// derive crate paths:
231///
232/// ```ignore
233/// #[derive(
234///     klieo::__private::serde::Deserialize,
235///     klieo::__private::schemars::JsonSchema,
236///     klieo::macros::KlieoResponse,
237/// )]
238/// #[serde(crate = "::klieo::__private::serde")]
239/// #[schemars(crate = "::klieo::__private::schemars")]
240/// struct Greeting {
241///     greeting: String,
242/// }
243/// ```
244///
245/// Either way the user crate needs only `klieo` (feature `macros`) — and,
246/// in the first form, `serde` + `schemars` as direct deps if preferred.
247#[proc_macro_derive(KlieoResponse)]
248pub fn derive_klieo_response(input: TokenStream) -> TokenStream {
249    let input = parse_macro_input!(input as DeriveInput);
250    let name = &input.ident;
251    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
252
253    let expanded = quote! {
254        impl #impl_generics ::klieo::__private::klieo_core::response::KlieoResponse for #name #ty_generics
255        #where_clause
256        {
257            fn json_schema() -> ::klieo::__private::serde_json::Value {
258                let schema = ::klieo::__private::schemars::schema_for!(#name);
259                ::klieo::__private::serde_json::to_value(&schema)
260                    .expect("schemars produces a valid serde_json::Value")
261            }
262        }
263    };
264    expanded.into()
265}