Skip to main content

areev_core/format/tool_schema/
mod.rs

1//! Tool-catalog renderer — Phase 2 of action-grain unification (2026-04-20).
2//!
3//! Renders `Tool` grains with `kind = Definition` into the wire format
4//! of a target LLM provider (OpenAI, Anthropic, Gemini, MCP, Hermes,
5//! Llama 3.1, Markdown, SML). Every adapter accepts a Phase-1-validated
6//! definition and produces output that is legal for its target provider
7//! by construction.
8//!
9//! Adapter outputs are **deterministic** — same input, same bytes — so
10//! CAL template rendering is stable across replicas.
11//!
12//! See `docs/facts/tool-formats.md` for output shape examples.
13
14use serde_json::Value;
15
16use crate::error::{AreevError, Result};
17use crate::types::Tool;
18
19pub mod anthropic;
20pub mod escape;
21pub mod gemini;
22pub mod hermes;
23pub mod llama31;
24pub mod markdown;
25pub mod mcp;
26pub mod openai;
27pub mod parse;
28pub mod sml;
29
30pub use parse::{parse, ParseError, ParsedToolCall};
31
32/// Target provider for tool-catalog rendering.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub enum ProviderKind {
35    OpenAiTools,
36    OpenAiResponses,
37    AnthropicTools,
38    GeminiTools,
39    McpTools,
40    Hermes,
41    Llama31,
42    MarkdownTools,
43    SmlTools,
44}
45
46impl ProviderKind {
47    /// Parse from a lowercase-hyphen wire name.
48    pub fn parse(s: &str) -> Option<Self> {
49        match s {
50            "openai-tools" => Some(Self::OpenAiTools),
51            "openai-responses" => Some(Self::OpenAiResponses),
52            "anthropic-tools" => Some(Self::AnthropicTools),
53            "gemini-tools" => Some(Self::GeminiTools),
54            "mcp-tools" => Some(Self::McpTools),
55            "hermes" => Some(Self::Hermes),
56            "llama31" => Some(Self::Llama31),
57            "markdown-tools" => Some(Self::MarkdownTools),
58            "sml-tools" => Some(Self::SmlTools),
59            _ => None,
60        }
61    }
62
63    pub fn as_str(&self) -> &'static str {
64        match self {
65            Self::OpenAiTools => "openai-tools",
66            Self::OpenAiResponses => "openai-responses",
67            Self::AnthropicTools => "anthropic-tools",
68            Self::GeminiTools => "gemini-tools",
69            Self::McpTools => "mcp-tools",
70            Self::Hermes => "hermes",
71            Self::Llama31 => "llama31",
72            Self::MarkdownTools => "markdown-tools",
73            Self::SmlTools => "sml-tools",
74        }
75    }
76
77    /// True for text-shaped providers (Hermes, Llama31, Markdown, SML).
78    pub fn is_text(&self) -> bool {
79        matches!(
80            self,
81            Self::Hermes | Self::Llama31 | Self::MarkdownTools | Self::SmlTools
82        )
83    }
84
85    /// Known lowercase-hyphen names, in stable order.
86    pub const ALL: &'static [&'static str] = &[
87        "openai-tools",
88        "openai-responses",
89        "anthropic-tools",
90        "gemini-tools",
91        "mcp-tools",
92        "hermes",
93        "llama31",
94        "markdown-tools",
95        "sml-tools",
96    ];
97}
98
99/// Render an Tool into provider-native JSON.
100///
101/// Returns `AreevError::ToolRenderUnsupported` (MEM-E107) when the action
102/// lacks fields the provider requires (e.g. Gemini needs `input_schema`
103/// with `type: "object"`) or when the provider is text-shaped.
104pub fn render_json(action: &Tool, provider: ProviderKind) -> Result<Value> {
105    if provider.is_text() {
106        return Err(AreevError::ToolRenderUnsupported(format!(
107            "provider {} is text-shaped; use render_text",
108            provider.as_str()
109        )));
110    }
111    match provider {
112        ProviderKind::OpenAiTools => openai::render_tools(action),
113        ProviderKind::OpenAiResponses => openai::render_responses(action),
114        ProviderKind::AnthropicTools => anthropic::render(action),
115        ProviderKind::GeminiTools => gemini::render(action),
116        ProviderKind::McpTools => mcp::render(action),
117        _ => unreachable!("is_text covered above"),
118    }
119}
120
121/// Render an Tool into provider-native text (Hermes, Llama31, Markdown, SML).
122///
123/// Returns `AreevError::ToolRenderUnsupported` (MEM-E107) when the
124/// provider is JSON-shaped.
125pub fn render_text(action: &Tool, provider: ProviderKind) -> Result<String> {
126    if !provider.is_text() {
127        return Err(AreevError::ToolRenderUnsupported(format!(
128            "provider {} is JSON-shaped; use render_json",
129            provider.as_str()
130        )));
131    }
132    match provider {
133        ProviderKind::Hermes => hermes::render(action),
134        ProviderKind::Llama31 => llama31::render(action),
135        ProviderKind::MarkdownTools => markdown::render(action),
136        ProviderKind::SmlTools => sml::render(action),
137        _ => unreachable!("!is_text covered above"),
138    }
139}
140
141/// Convenience wrapper — returns `Value::String` for text providers,
142/// native JSON for JSON providers. Useful for CAL format dispatch.
143pub fn render_any(action: &Tool, provider: ProviderKind) -> Result<Value> {
144    if provider.is_text() {
145        Ok(Value::String(render_text(action, provider)?))
146    } else {
147        render_json(action, provider)
148    }
149}
150
151/// Normalize `tool_name` for provider-facing use: replace `.` with `_`
152/// to satisfy name-regex constraints (Anthropic/OpenAI forbid dots).
153/// Identity preserved via the grain's `tool_name` field; the invoker
154/// reverse-maps on the return path.
155///
156/// **Public because the reverse map needs the same spelling.** A model that
157/// was offered `receipt_prepare` calls `receipt_prepare`, and whoever reads
158/// that call back has to recognise it as the Definition `receipt.prepare` —
159/// the runtime's abstract nodes (`areev_run_core`) and the tool-calling
160/// adapters (`areev_llm`) both do exactly that. A second, private copy of
161/// this one-liner in either crate is how the two halves drift apart.
162pub fn normalize_tool_name(name: &str) -> String {
163    name.replace('.', "_")
164}
165
166/// Fetch the shared `(name, description, input_schema)` triple that
167/// every JSON adapter uses. Returns MEM-E107 if the action is not a
168/// definition or lacks `input_schema`.
169pub(crate) fn definition_parts(action: &Tool) -> Result<(String, String, Value)> {
170    if action.kind != crate::types::ToolKind::Definition {
171        return Err(AreevError::ToolRenderUnsupported(format!(
172            "action {} is not kind=definition",
173            action.tool_name
174        )));
175    }
176    let description = action
177        .tool_description
178        .clone()
179        .or_else(|| action.content.clone())
180        .unwrap_or_default();
181    let schema = action.input_schema.clone().ok_or_else(|| {
182        AreevError::ToolRenderUnsupported(format!(
183            "action {} missing input_schema",
184            action.tool_name
185        ))
186    })?;
187    Ok((normalize_tool_name(&action.tool_name), description, schema))
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::types::{Tool, ToolKind};
194    use serde_json::json;
195
196    pub(crate) fn sample_def() -> Tool {
197        let mut a = Tool::new("slack.post_message").kind(ToolKind::Definition);
198        a.tool_description = Some("Post a message to a Slack channel".to_string());
199        a.input_schema = Some(json!({
200            "type": "object",
201            "properties": {
202                "channel": {"type": "string"},
203                "text": {"type": "string"}
204            },
205            "required": ["channel", "text"]
206        }));
207        a
208    }
209
210    #[test]
211    fn provider_kind_round_trip() {
212        for name in ProviderKind::ALL {
213            let p = ProviderKind::parse(name).unwrap();
214            assert_eq!(p.as_str(), *name);
215        }
216    }
217
218    #[test]
219    fn render_json_rejects_text_provider() {
220        let a = sample_def();
221        let err = render_json(&a, ProviderKind::Hermes).unwrap_err();
222        assert!(matches!(err, AreevError::ToolRenderUnsupported(_)));
223    }
224
225    #[test]
226    fn render_text_rejects_json_provider() {
227        let a = sample_def();
228        let err = render_text(&a, ProviderKind::AnthropicTools).unwrap_err();
229        assert!(matches!(err, AreevError::ToolRenderUnsupported(_)));
230    }
231
232    #[test]
233    fn render_any_text_yields_string() {
234        let a = sample_def();
235        let v = render_any(&a, ProviderKind::MarkdownTools).unwrap();
236        assert!(v.is_string());
237    }
238
239    #[test]
240    fn normalize_replaces_dots() {
241        assert_eq!(
242            normalize_tool_name("slack.post_message"),
243            "slack_post_message"
244        );
245        assert_eq!(normalize_tool_name("gmail.send"), "gmail_send");
246    }
247
248    #[test]
249    fn definition_parts_rejects_execution_kind() {
250        let a = Tool::new("x"); // Default Execution
251        let err = definition_parts(&a).unwrap_err();
252        assert!(matches!(err, AreevError::ToolRenderUnsupported(_)));
253    }
254}