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}