feat: prepare agen crates for publication
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "agen-macros"
|
||||
description = "Procedural macros for declaring agen tools"
|
||||
version = "0.2.0"
|
||||
edition.workspace = true
|
||||
rust-version = "1.85"
|
||||
license.workspace = true
|
||||
readme = "README.md"
|
||||
repository = "https://gitea.hareworks.net/Hare/yoi"
|
||||
homepage = "https://gitea.hareworks.net/Hare/yoi"
|
||||
documentation = "https://docs.rs/agen-macros"
|
||||
keywords = ["llm", "agent", "tools", "macros"]
|
||||
categories = ["development-tools::procedural-macro-helpers"]
|
||||
include = ["src/**", "README.md", "LICENSE"]
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full"] }
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
@@ -0,0 +1,7 @@
|
||||
Copyright 2026 Hare
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,32 @@
|
||||
# agen-macros
|
||||
|
||||
Procedural macros used by [`agen`](https://crates.io/crates/agen) to declare LLM tools from Rust methods.
|
||||
|
||||
Applications should normally depend only on `agen` and import its re-exports:
|
||||
|
||||
```rust
|
||||
use agen::tool_registry;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Tools;
|
||||
|
||||
#[tool_registry]
|
||||
impl Tools {
|
||||
/// Returns the supplied text.
|
||||
#[tool]
|
||||
async fn echo(
|
||||
&self,
|
||||
#[description = "Text to return"] text: String,
|
||||
) -> Result<String, std::io::Error> {
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`#[tool_registry]` generates the argument schema, a `Tool` implementation, and an `<method>_definition` constructor. It rejects arguments of its own, duplicate `#[tool]` markers, malformed or duplicate `#[description = "..."]` attributes, and non-identifier argument patterns.
|
||||
|
||||
Generated code targets the canonical `::agen` path and uses implementation dependencies re-exported by `agen`; consumers do not need direct `serde`, `schemars`, `serde_json`, or `async-trait` dependencies. Renaming the `agen` dependency in `Cargo.toml` is not currently supported.
|
||||
|
||||
This companion package is published before the matching `agen` release. Its public contract is the generated API consumed by `agen`, and its minor version compatibility follows the `agen` 0.2 series.
|
||||
|
||||
Licensed under the [MIT License](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/LICENSE).
|
||||
@@ -0,0 +1,482 @@
|
||||
//! Procedural macros for declaring [`agen`](https://docs.rs/agen) tools.
|
||||
//!
|
||||
//! [`tool_registry`] expands methods marked with `#[tool]` into `agen::tool::Tool`
|
||||
//! implementations and tool definitions. Applications normally use the re-exports from
|
||||
//! `agen`; this companion crate exists so those macros can be published and versioned
|
||||
//! independently.
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{
|
||||
Attribute, FnArg, ImplItem, ItemImpl, Lit, Meta, Pat, ReturnType, Type, parse_macro_input,
|
||||
spanned::Spanned,
|
||||
};
|
||||
|
||||
/// Generates tools for methods marked with `#[tool]` in an `impl` block.
|
||||
///
|
||||
/// Method doc comments become the tool description. An argument can use
|
||||
/// `#[description = "..."]` to supply its JSON Schema description.
|
||||
///
|
||||
/// ```ignore
|
||||
/// #[derive(Clone)]
|
||||
/// struct MyApp;
|
||||
///
|
||||
/// #[agen::tool_registry]
|
||||
/// impl MyApp {
|
||||
/// /// Retrieves a user by ID.
|
||||
/// #[tool]
|
||||
/// async fn get_user(
|
||||
/// &self,
|
||||
/// #[description = "The user ID"] user_id: String,
|
||||
/// ) -> Result<String, std::io::Error> {
|
||||
/// todo!()
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// This generates a `ToolGetUser` wrapper, a `GetUserArgs` schema type, and
|
||||
/// `MyApp::get_user_definition()`.
|
||||
#[proc_macro_attribute]
|
||||
pub fn tool_registry(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let attr = proc_macro2::TokenStream::from(attr);
|
||||
let impl_block = parse_macro_input!(item as ItemImpl);
|
||||
|
||||
expand_tool_registry(attr, impl_block)
|
||||
.unwrap_or_else(syn::Error::into_compile_error)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn expand_tool_registry(
|
||||
attr: proc_macro2::TokenStream,
|
||||
mut impl_block: ItemImpl,
|
||||
) -> syn::Result<proc_macro2::TokenStream> {
|
||||
if !attr.is_empty() {
|
||||
return Err(syn::Error::new(
|
||||
attr.span(),
|
||||
"tool_registry does not accept arguments",
|
||||
));
|
||||
}
|
||||
|
||||
let self_ty = impl_block.self_ty.as_ref().clone();
|
||||
let mut generated_items = Vec::new();
|
||||
|
||||
for item in &mut impl_block.items {
|
||||
let ImplItem::Fn(method) = item else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let tool_attrs: Vec<_> = method
|
||||
.attrs
|
||||
.iter()
|
||||
.filter(|attr| attr.path().is_ident("tool"))
|
||||
.collect();
|
||||
if tool_attrs.len() > 1 {
|
||||
return Err(syn::Error::new_spanned(
|
||||
tool_attrs[1],
|
||||
"duplicate #[tool] attribute",
|
||||
));
|
||||
}
|
||||
let Some(tool_attr) = tool_attrs.first() else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(tool_attr.meta, Meta::Path(_)) {
|
||||
return Err(syn::Error::new_spanned(
|
||||
tool_attr,
|
||||
"#[tool] does not accept arguments",
|
||||
));
|
||||
}
|
||||
|
||||
method.attrs.retain(|attr| !attr.path().is_ident("tool"));
|
||||
generated_items.push(generate_tool_impl(&self_ty, method)?);
|
||||
|
||||
for input in &mut method.sig.inputs {
|
||||
if let FnArg::Typed(pat_type) = input {
|
||||
pat_type
|
||||
.attrs
|
||||
.retain(|attr| !attr.path().is_ident("description"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(quote! {
|
||||
#impl_block
|
||||
|
||||
#(#generated_items)*
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_doc_comment(attrs: &[Attribute]) -> String {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for attr in attrs {
|
||||
if attr.path().is_ident("doc")
|
||||
&& let Meta::NameValue(meta) = &attr.meta
|
||||
&& let syn::Expr::Lit(expr_lit) = &meta.value
|
||||
&& let Lit::Str(lit_str) = &expr_lit.lit
|
||||
{
|
||||
let line = lit_str.value();
|
||||
let trimmed = line.strip_prefix(' ').unwrap_or(&line);
|
||||
lines.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn extract_description_attr(attrs: &[Attribute]) -> syn::Result<Option<String>> {
|
||||
let mut description = None;
|
||||
|
||||
for attr in attrs
|
||||
.iter()
|
||||
.filter(|attr| attr.path().is_ident("description"))
|
||||
{
|
||||
let value = match &attr.meta {
|
||||
Meta::NameValue(meta) => match &meta.value {
|
||||
syn::Expr::Lit(expr_lit) => match &expr_lit.lit {
|
||||
Lit::Str(value) => value.value(),
|
||||
_ => {
|
||||
return Err(syn::Error::new_spanned(
|
||||
attr,
|
||||
"description must be a string literal",
|
||||
));
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
return Err(syn::Error::new_spanned(
|
||||
attr,
|
||||
"description must be a string literal",
|
||||
));
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
return Err(syn::Error::new_spanned(
|
||||
attr,
|
||||
"expected #[description = \"...\"]",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if description.replace(value).is_some() {
|
||||
return Err(syn::Error::new_spanned(
|
||||
attr,
|
||||
"duplicate #[description] attribute",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(description)
|
||||
}
|
||||
|
||||
fn argument_ident(pat: &Pat) -> syn::Result<&syn::Ident> {
|
||||
match pat {
|
||||
Pat::Ident(pat_ident) => Ok(&pat_ident.ident),
|
||||
_ => Err(syn::Error::new_spanned(
|
||||
pat,
|
||||
"tool arguments must use simple identifier patterns",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_tool_execution_context_type(ty: &Type) -> bool {
|
||||
let Type::Path(path) = ty else {
|
||||
return false;
|
||||
};
|
||||
path.path
|
||||
.segments
|
||||
.last()
|
||||
.is_some_and(|segment| segment.ident == "ToolExecutionContext")
|
||||
}
|
||||
|
||||
fn generate_tool_impl(
|
||||
self_ty: &Type,
|
||||
method: &syn::ImplItemFn,
|
||||
) -> syn::Result<proc_macro2::TokenStream> {
|
||||
let sig = &method.sig;
|
||||
let method_name = &sig.ident;
|
||||
let tool_name = method_name.to_string();
|
||||
|
||||
let pascal_name = to_pascal_case(&method_name.to_string());
|
||||
let tool_struct_name = format_ident!("Tool{}", pascal_name);
|
||||
let args_struct_name = format_ident!("{}Args", pascal_name);
|
||||
let definition_name = format_ident!("{}_definition", method_name);
|
||||
|
||||
let description = extract_doc_comment(&method.attrs);
|
||||
let description = if description.is_empty() {
|
||||
format!("Tool: {}", tool_name)
|
||||
} else {
|
||||
description
|
||||
};
|
||||
|
||||
let method_args: Vec<_> = sig
|
||||
.inputs
|
||||
.iter()
|
||||
.filter_map(|arg| match arg {
|
||||
FnArg::Typed(pat_type) => Some(pat_type),
|
||||
FnArg::Receiver(_) => None,
|
||||
})
|
||||
.collect();
|
||||
let json_args: Vec<_> = method_args
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|pat_type| !is_tool_execution_context_type(pat_type.ty.as_ref()))
|
||||
.collect();
|
||||
|
||||
let arg_fields: Vec<_> = json_args
|
||||
.iter()
|
||||
.map(|pat_type| {
|
||||
let field_name = argument_ident(pat_type.pat.as_ref())?;
|
||||
let ty = &pat_type.ty;
|
||||
let description = extract_description_attr(&pat_type.attrs)?;
|
||||
|
||||
Ok(if let Some(description) = description {
|
||||
quote! {
|
||||
#[schemars(description = #description)]
|
||||
pub #field_name: #ty
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
pub #field_name: #ty
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect::<syn::Result<_>>()?;
|
||||
|
||||
let call_args: Vec<_> = method_args
|
||||
.iter()
|
||||
.map(|pat_type| {
|
||||
if is_tool_execution_context_type(pat_type.ty.as_ref()) {
|
||||
Ok(quote! { ctx.clone() })
|
||||
} else {
|
||||
let ident = argument_ident(pat_type.pat.as_ref())?;
|
||||
Ok(quote! { args.#ident })
|
||||
}
|
||||
})
|
||||
.collect::<syn::Result<_>>()?;
|
||||
let method_call = if call_args.is_empty() {
|
||||
quote! { self.ctx.#method_name() }
|
||||
} else {
|
||||
quote! { self.ctx.#method_name(#(#call_args),*) }
|
||||
};
|
||||
|
||||
let awaiter = if sig.asyncness.is_some() {
|
||||
quote! { .await }
|
||||
} else {
|
||||
quote! {}
|
||||
};
|
||||
|
||||
let result_handling = if is_result_type(&sig.output) {
|
||||
quote! {
|
||||
match result {
|
||||
Ok(val) => Ok(format!("{:?}", val).into()),
|
||||
Err(error) => Err(::agen::tool::ToolError::ExecutionFailed(format!("{}", error))),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
Ok(format!("{:?}", result).into())
|
||||
}
|
||||
};
|
||||
|
||||
let args_struct_def = quote! {
|
||||
#[derive(
|
||||
::agen::__private::serde::Deserialize,
|
||||
::agen::__private::schemars::JsonSchema,
|
||||
)]
|
||||
#[serde(crate = "::agen::__private::serde")]
|
||||
#[schemars(crate = "::agen::__private::schemars")]
|
||||
struct #args_struct_name {
|
||||
#(#arg_fields),*
|
||||
}
|
||||
};
|
||||
|
||||
let execute_body = if json_args.is_empty() {
|
||||
quote! {
|
||||
let _: #args_struct_name = ::agen::__private::serde_json::from_str(input_json)
|
||||
.unwrap_or(#args_struct_name {});
|
||||
|
||||
let result = #method_call #awaiter;
|
||||
#result_handling
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
let args: #args_struct_name = ::agen::__private::serde_json::from_str(input_json)
|
||||
.map_err(|error| ::agen::tool::ToolError::InvalidArgument(error.to_string()))?;
|
||||
|
||||
let result = #method_call #awaiter;
|
||||
#result_handling
|
||||
}
|
||||
};
|
||||
|
||||
Ok(quote! {
|
||||
#args_struct_def
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct #tool_struct_name {
|
||||
ctx: #self_ty,
|
||||
}
|
||||
|
||||
#[::agen::__private::async_trait::async_trait]
|
||||
impl ::agen::tool::Tool for #tool_struct_name {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
ctx: ::agen::tool::ToolExecutionContext,
|
||||
) -> Result<::agen::tool::ToolOutput, ::agen::tool::ToolError> {
|
||||
let _ = &ctx;
|
||||
#execute_body
|
||||
}
|
||||
}
|
||||
|
||||
impl #self_ty {
|
||||
/// Returns a tool definition for registration with an `agen::Engine`.
|
||||
pub fn #definition_name(&self) -> ::agen::tool::ToolDefinition {
|
||||
let ctx = self.clone();
|
||||
::std::sync::Arc::new(move || {
|
||||
let schema = ::agen::__private::schemars::schema_for!(#args_struct_name);
|
||||
let meta = ::agen::tool::ToolMeta::new(#tool_name)
|
||||
.description(#description)
|
||||
.input_schema(
|
||||
::agen::__private::serde_json::to_value(schema)
|
||||
.unwrap_or_else(|_| ::agen::__private::serde_json::json!({})),
|
||||
);
|
||||
let tool: ::std::sync::Arc<dyn ::agen::tool::Tool> =
|
||||
::std::sync::Arc::new(#tool_struct_name { ctx: ctx.clone() });
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn is_result_type(return_type: &ReturnType) -> bool {
|
||||
match return_type {
|
||||
ReturnType::Default => false,
|
||||
ReturnType::Type(_, ty) => {
|
||||
if let Type::Path(type_path) = ty.as_ref()
|
||||
&& let Some(segment) = type_path.path.segments.last()
|
||||
{
|
||||
return segment.ident == "Result";
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn to_pascal_case(s: &str) -> String {
|
||||
s.split('_')
|
||||
.map(|part| {
|
||||
let mut chars = part.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Marker attribute interpreted by [`tool_registry`].
|
||||
#[proc_macro_attribute]
|
||||
pub fn tool(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
marker_attribute("tool", attr, item)
|
||||
}
|
||||
|
||||
/// Argument description marker interpreted by [`tool_registry`].
|
||||
///
|
||||
/// Use it as `#[description = "The argument description"]` on a tool method argument.
|
||||
#[proc_macro_attribute]
|
||||
pub fn description(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
marker_attribute("description", attr, item)
|
||||
}
|
||||
|
||||
fn marker_attribute(name: &str, attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
if attr.is_empty() {
|
||||
item
|
||||
} else {
|
||||
syn::Error::new(
|
||||
proc_macro2::Span::call_site(),
|
||||
format!("{name} is a marker interpreted by #[tool_registry]"),
|
||||
)
|
||||
.into_compile_error()
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use quote::quote;
|
||||
use syn::parse_quote;
|
||||
|
||||
#[test]
|
||||
fn rejects_tool_registry_arguments() {
|
||||
let implementation: ItemImpl = parse_quote!(impl Registry {});
|
||||
let error = expand_tool_registry(quote!(unexpected), implementation).unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("does not accept arguments"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_tool_markers() {
|
||||
let implementation: ItemImpl = parse_quote! {
|
||||
impl Registry {
|
||||
#[tool]
|
||||
#[tool]
|
||||
fn inspect(&self) {}
|
||||
}
|
||||
};
|
||||
let error = expand_tool_registry(quote!(), implementation).unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("duplicate #[tool]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_description_attributes() {
|
||||
let implementation: ItemImpl = parse_quote! {
|
||||
impl Registry {
|
||||
#[tool]
|
||||
fn inspect(&self, #[description] input: String) {}
|
||||
}
|
||||
};
|
||||
let error = expand_tool_registry(quote!(), implementation).unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("expected #[description"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_description_attributes() {
|
||||
let implementation: ItemImpl = parse_quote! {
|
||||
impl Registry {
|
||||
#[tool]
|
||||
fn inspect(
|
||||
&self,
|
||||
#[description = "first"]
|
||||
#[description = "second"]
|
||||
input: String,
|
||||
) {}
|
||||
}
|
||||
};
|
||||
let error = expand_tool_registry(quote!(), implementation).unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("duplicate #[description]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_code_uses_only_agen_runtime_paths() {
|
||||
let implementation: ItemImpl = parse_quote! {
|
||||
impl Registry {
|
||||
#[tool]
|
||||
fn inspect(&self, input: String) -> Result<String, Error> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
};
|
||||
let expanded = expand_tool_registry(quote!(), implementation)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
assert!(expanded.contains(":: agen :: tool :: Tool"));
|
||||
assert!(expanded.contains(":: agen :: __private :: serde_json"));
|
||||
assert!(expanded.contains(":: agen :: __private :: serde"));
|
||||
assert!(expanded.contains(":: agen :: __private :: schemars"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user