Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Macros — `#[tool]` no longer silently maps non-scalar parameter types to `"string"` in the generated JSON schema. `Vec<T>` and fixed-size arrays now emit `{"type": "array", "items": ...}`, maps and structs emit `{"type": "object"}`, and unsupported types (references, tuples, slices) are rejected at compile time. ([#235](https://github.com/Fullstop000/ignis/pull/235))

## [0.45.1] - 2026-07-01

### Fixed
Expand Down
112 changes: 91 additions & 21 deletions ignis-macros/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use proc_macro::TokenStream;
use proc_macro2::Span;
use proc_macro2::{Span, TokenStream as TokenStream2};
use quote::quote;
use syn::{
parse::{Parse, ParseStream},
Expand Down Expand Up @@ -60,29 +60,98 @@ fn get_inner_type_from_option(ty: &Type) -> Option<&Type> {
None
}

fn map_type_to_json_schema(ty: &Type) -> &'static str {
let check_ty = get_inner_type_from_option(ty).unwrap_or(ty);
if let Type::Path(type_path) = check_ty {
if let Some(segment) = type_path.path.segments.last() {
let ident_str = segment.ident.to_string();
match ident_str.as_str() {
"String" | "str" | "char" => "string",
"i8" | "i16" | "i32" | "i64" | "isize" | "u8" | "u16" | "u32" | "u64" | "usize" => {
"integer"
/// Maps a Rust parameter type to a JSON-Schema expression.
/// Returns a `TokenStream2` that evaluates to a `serde_json::Value`.
fn map_type_to_json_schema(ty: &Type) -> syn::Result<TokenStream2> {
// Option<T> is represented by the schema of T; the field is marked optional
// via the `required` array, not via the schema type.
if let Some(inner) = get_inner_type_from_option(ty) {
return map_type_to_json_schema(inner);
}

match ty {
Type::Array(type_array) => {
let inner = map_type_to_json_schema(&type_array.elem)?;
Ok(quote! { serde_json::json!({ "type": "array", "items": #inner }) })
}
Type::Slice(type_slice) => {
let inner = map_type_to_json_schema(&type_slice.elem)?;
Ok(quote! { serde_json::json!({ "type": "array", "items": #inner }) })
}
Type::Reference(type_ref) => {
// &[T] is a slice reference, which cannot be deserialized from a
// `serde_json::Value` (it needs a borrowed lifetime). Point users to
// Vec<T> instead.
if type_ref.mutability.is_none() {
if let Type::Slice(_) = &*type_ref.elem {
return Err(syn::Error::new_spanned(
ty,
"slice references (&[T]) are not supported by #[tool]; use Vec<T> instead",
));
}
"f32" | "f64" => "number",
"bool" => "boolean",
_ => "string", // Fallback to string
}
} else {
"string"
Err(syn::Error::new_spanned(
ty,
"references are not supported by #[tool]; use owned types like String or Vec<T>",
))
}
} else {
"string"
Type::Path(type_path) => {
if let Some(segment) = type_path.path.segments.last() {
let ident_str = segment.ident.to_string();
match ident_str.as_str() {
"Vec" => {
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() {
let inner = map_type_to_json_schema(inner_ty)?;
return Ok(quote! {
serde_json::json!({ "type": "array", "items": #inner })
});
}
}
Err(syn::Error::new_spanned(ty, "Vec requires a type argument"))
}
"HashMap" | "BTreeMap" => {
Ok(quote! { serde_json::json!({ "type": "object" }) })
}
"String" | "str" | "char" => {
Ok(quote! { serde_json::json!({ "type": "string" }) })
}
"i8" | "i16" | "i32" | "i64" | "isize" | "u8" | "u16" | "u32" | "u64"
| "usize" => Ok(quote! { serde_json::json!({ "type": "integer" }) }),
"f32" | "f64" => Ok(quote! { serde_json::json!({ "type": "number" }) }),
"bool" => Ok(quote! { serde_json::json!({ "type": "boolean" }) }),
// Other path types (structs, enums, type aliases) are treated as objects.
_ => Ok(quote! { serde_json::json!({ "type": "object" }) }),
}
} else {
Ok(quote! { serde_json::json!({ "type": "object" }) })
}
}
_ => Err(syn::Error::new_spanned(
ty,
"unsupported parameter type for #[tool]",
)),
}
}

#[proc_macro_attribute]
/// The `#[tool]` attribute turns an async function into an `ignis::AgentTool`.
///
/// Supported parameter types map to the corresponding JSON Schema type:
/// - scalars (`String`, integers, floats, `bool`) → `string`, `integer`, `number`, `boolean`
/// - `Vec<T>` and fixed-size arrays `[T; N]` → `array` with `items` schemas
/// - maps (`HashMap`, `BTreeMap`) and structs/enums → `object`
/// - `Option<T>` → schema of `T`, and the field is omitted from `required`
///
/// Unsupported types (references, tuples, slices, etc.) are rejected at compile
/// time rather than silently mapped to `"string"`.
///
/// ```compile_fail
/// use ignis_macros::tool;
///
/// #[tool(name = "bad", description = "bad")]
/// async fn bad_tool(_x: (String, String)) -> Result<String, String> { Ok(String::new()) }
/// ```
pub fn tool(args: TokenStream, input: TokenStream) -> TokenStream {
let tool_args = parse_macro_input!(args as ToolArgs);
let func = parse_macro_input!(input as ItemFn);
Expand Down Expand Up @@ -123,7 +192,10 @@ pub fn tool(args: TokenStream, input: TokenStream) -> TokenStream {

let ty = &pat_type.ty;
let is_optional = get_inner_type_from_option(ty).is_some();
let json_type = map_type_to_json_schema(ty);
let schema_expr = match map_type_to_json_schema(ty) {
Ok(expr) => expr,
Err(e) => return e.to_compile_error().into(),
};

arg_names.push(syn::Ident::new(&arg_name, Span::call_site()));
arg_types.push(ty.clone());
Expand All @@ -133,9 +205,7 @@ pub fn tool(args: TokenStream, input: TokenStream) -> TokenStream {
}

properties_code.push(quote! {
properties.insert(#arg_name.to_string(), serde_json::json!({
"type": #json_type
}));
properties.insert(#arg_name.to_string(), #schema_expr);
});
}
}
Expand Down
52 changes: 52 additions & 0 deletions ignis/tests/tool_macro.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
use ignis::AgentTool;

#[ignis::tool(name = "concat_items", description = "Concatenate a list of strings")]
async fn concat_items(items: Vec<String>) -> Result<String, String> {
Ok(items.join(", "))
}

#[ignis::tool(name = "maybe_concat", description = "Concatenate optional strings")]
async fn maybe_concat(items: Option<Vec<String>>) -> Result<String, String> {
Ok(items.unwrap_or_default().join(" | "))
}

#[tokio::test]
async fn vec_string_param_emits_array_schema() {
let tool = ConcatItemsTool;
let schema = tool.parameters();

assert_eq!(schema["type"], "object");
assert_eq!(schema["properties"]["items"]["type"], "array");
assert_eq!(schema["properties"]["items"]["items"]["type"], "string");
assert_eq!(schema["required"], serde_json::json!(["items"]));
}

#[tokio::test]
async fn vec_string_param_round_trips() {
let tool = ConcatItemsTool;
let result = tool
.call(serde_json::json!({ "items": ["a", "b", "c"] }))
.await;

assert!(!result.is_error);
assert_eq!(result.content, "a, b, c");
}

#[tokio::test]
async fn optional_vec_param_is_not_required() {
let tool = MaybeConcatTool;
let schema = tool.parameters();

assert_eq!(schema["properties"]["items"]["type"], "array");
assert_eq!(schema["properties"]["items"]["items"]["type"], "string");
assert!(schema["required"].as_array().unwrap().is_empty());
}

#[tokio::test]
async fn optional_vec_param_omits_field() {
let tool = MaybeConcatTool;
let result = tool.call(serde_json::json!({})).await;

assert!(!result.is_error);
assert_eq!(result.content, "");
}
Loading