Skip to main content

dejadb_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::{DejaDbError, 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 `DejaDbError::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(DejaDbError::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 `DejaDbError::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(DejaDbError::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.
155pub(crate) fn normalize_tool_name(name: &str) -> String {
156    name.replace('.', "_")
157}
158
159/// Fetch the shared `(name, description, input_schema)` triple that
160/// every JSON adapter uses. Returns MEM-E107 if the action is not a
161/// definition or lacks `input_schema`.
162pub(crate) fn definition_parts(action: &Tool) -> Result<(String, String, Value)> {
163    if action.kind != crate::types::ToolKind::Definition {
164        return Err(DejaDbError::ToolRenderUnsupported(format!(
165            "action {} is not kind=definition",
166            action.tool_name
167        )));
168    }
169    let description = action
170        .tool_description
171        .clone()
172        .or_else(|| action.content.clone())
173        .unwrap_or_default();
174    let schema = action.input_schema.clone().ok_or_else(|| {
175        DejaDbError::ToolRenderUnsupported(format!(
176            "action {} missing input_schema",
177            action.tool_name
178        ))
179    })?;
180    Ok((normalize_tool_name(&action.tool_name), description, schema))
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::types::{Tool, ToolKind};
187    use serde_json::json;
188
189    pub(crate) fn sample_def() -> Tool {
190        let mut a = Tool::new("slack.post_message").kind(ToolKind::Definition);
191        a.tool_description = Some("Post a message to a Slack channel".to_string());
192        a.input_schema = Some(json!({
193            "type": "object",
194            "properties": {
195                "channel": {"type": "string"},
196                "text": {"type": "string"}
197            },
198            "required": ["channel", "text"]
199        }));
200        a
201    }
202
203    #[test]
204    fn provider_kind_round_trip() {
205        for name in ProviderKind::ALL {
206            let p = ProviderKind::parse(name).unwrap();
207            assert_eq!(p.as_str(), *name);
208        }
209    }
210
211    #[test]
212    fn render_json_rejects_text_provider() {
213        let a = sample_def();
214        let err = render_json(&a, ProviderKind::Hermes).unwrap_err();
215        assert!(matches!(err, DejaDbError::ToolRenderUnsupported(_)));
216    }
217
218    #[test]
219    fn render_text_rejects_json_provider() {
220        let a = sample_def();
221        let err = render_text(&a, ProviderKind::AnthropicTools).unwrap_err();
222        assert!(matches!(err, DejaDbError::ToolRenderUnsupported(_)));
223    }
224
225    #[test]
226    fn render_any_text_yields_string() {
227        let a = sample_def();
228        let v = render_any(&a, ProviderKind::MarkdownTools).unwrap();
229        assert!(v.is_string());
230    }
231
232    #[test]
233    fn normalize_replaces_dots() {
234        assert_eq!(
235            normalize_tool_name("slack.post_message"),
236            "slack_post_message"
237        );
238        assert_eq!(normalize_tool_name("gmail.send"), "gmail_send");
239    }
240
241    #[test]
242    fn definition_parts_rejects_execution_kind() {
243        let a = Tool::new("x"); // Default Execution
244        let err = definition_parts(&a).unwrap_err();
245        assert!(matches!(err, DejaDbError::ToolRenderUnsupported(_)));
246    }
247}