Skip to main content

sgr_agent/
schema_simplifier.rs

1//! SchemaSimplifier — converts JSON Schema to human-readable text.
2//!
3//! Used by FlexibleAgent to describe tool parameters in the system prompt
4//! without overwhelming weak models with raw JSON Schema syntax.
5
6use serde_json::Value;
7
8/// Convert a JSON Schema to human-readable text description.
9///
10/// Input: `{"type": "object", "properties": {"path": {"type": "string", "description": "File path"}}, "required": ["path"]}`
11/// Output: `- path (required, string): File path`
12pub fn simplify(schema: &Value) -> String {
13    let mut lines = Vec::new();
14    simplify_object(schema, &mut lines, 0);
15    lines.join("\n")
16}
17
18fn simplify_object(schema: &Value, lines: &mut Vec<String>, indent: usize) {
19    let required: Vec<&str> = schema
20        .get("required")
21        .and_then(|r| r.as_array())
22        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
23        .unwrap_or_default();
24
25    let properties = match schema.get("properties").and_then(|p| p.as_object()) {
26        Some(p) => p,
27        None => return,
28    };
29
30    let prefix = "  ".repeat(indent);
31    for (name, prop) in properties {
32        let req_label = if required.contains(&name.as_str()) {
33            "required"
34        } else {
35            "optional"
36        };
37
38        let type_str = format_type(prop);
39        let constraints = format_constraints(prop);
40        let desc = prop
41            .get("description")
42            .and_then(|d| d.as_str())
43            .unwrap_or("");
44
45        let mut parts = vec![req_label.to_string(), type_str];
46        if !constraints.is_empty() {
47            parts.push(constraints);
48        }
49        let suffix = if desc.is_empty() {
50            String::new()
51        } else {
52            format!(": {}", desc)
53        };
54
55        lines.push(format!(
56            "{}- {} ({}){}",
57            prefix,
58            name,
59            parts.join(", "),
60            suffix
61        ));
62
63        // Recurse into nested objects
64        if prop.get("type").and_then(|t| t.as_str()) == Some("object") {
65            simplify_object(prop, lines, indent + 1);
66        }
67
68        // Array items
69        if prop.get("type").and_then(|t| t.as_str()) == Some("array")
70            && let Some(items) = prop.get("items")
71            && items.get("type").and_then(|t| t.as_str()) == Some("object")
72        {
73            let item_prefix = "  ".repeat(indent + 1);
74            lines.push(format!("{}  Each item:", item_prefix));
75            simplify_object(items, lines, indent + 2);
76        }
77    }
78}
79
80fn format_type(prop: &Value) -> String {
81    match prop.get("type").and_then(|t| t.as_str()) {
82        Some("array") => {
83            let item_type = prop
84                .get("items")
85                .and_then(|i| i.get("type"))
86                .and_then(|t| t.as_str())
87                .unwrap_or("any");
88            format!("array of {}", item_type)
89        }
90        Some(t) => t.to_string(),
91        None => {
92            // Check for enum
93            if prop.get("enum").is_some() {
94                "enum".to_string()
95            } else if prop.get("oneOf").is_some() || prop.get("anyOf").is_some() {
96                "union".to_string()
97            } else {
98                "any".to_string()
99            }
100        }
101    }
102}
103
104fn format_constraints(prop: &Value) -> String {
105    let mut parts = Vec::new();
106
107    if let Some(Value::Array(variants)) = prop.get("enum") {
108        let vals: Vec<String> = variants
109            .iter()
110            .map(|v| match v {
111                Value::String(s) => format!("\"{}\"", s),
112                _ => v.to_string(),
113            })
114            .collect();
115        parts.push(format!("one of: {}", vals.join(" | ")));
116    }
117
118    if let Some(min) = prop.get("minimum") {
119        parts.push(format!("min: {}", min));
120    }
121    if let Some(max) = prop.get("maximum") {
122        parts.push(format!("max: {}", max));
123    }
124    if let Some(min) = prop.get("minLength") {
125        parts.push(format!("minLength: {}", min));
126    }
127    if let Some(max) = prop.get("maxLength") {
128        parts.push(format!("maxLength: {}", max));
129    }
130    if let Some(pat) = prop.get("pattern").and_then(|p| p.as_str()) {
131        parts.push(format!("pattern: {}", pat));
132    }
133    if let Some(def) = prop.get("default") {
134        parts.push(format!("default: {}", def));
135    }
136
137    parts.join(", ")
138}
139
140/// Simplify an entire tool definition into a human-readable block.
141pub fn simplify_tool(name: &str, description: &str, schema: &Value) -> String {
142    let params = simplify(schema);
143    if params.is_empty() {
144        format!("### {}\n{}\nNo parameters.", name, description)
145    } else {
146        format!("### {}\n{}\nParameters:\n{}", name, description, params)
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use serde_json::json;
154
155    #[test]
156    fn simplify_basic_object() {
157        let schema = json!({
158            "type": "object",
159            "properties": {
160                "path": { "type": "string", "description": "File path to read" },
161                "line": { "type": "integer", "description": "Start line" }
162            },
163            "required": ["path"]
164        });
165        let result = simplify(&schema);
166        assert!(result.contains("- path (required, string): File path to read"));
167        assert!(result.contains("- line (optional, integer): Start line"));
168    }
169
170    #[test]
171    fn simplify_with_enum() {
172        let schema = json!({
173            "type": "object",
174            "properties": {
175                "mode": {
176                    "type": "string",
177                    "enum": ["read", "write", "append"],
178                    "description": "File open mode"
179                }
180            },
181            "required": ["mode"]
182        });
183        let result = simplify(&schema);
184        assert!(result.contains("one of:"));
185        assert!(result.contains("\"read\""));
186    }
187
188    #[test]
189    fn simplify_nested_object() {
190        let schema = json!({
191            "type": "object",
192            "properties": {
193                "config": {
194                    "type": "object",
195                    "properties": {
196                        "timeout": { "type": "integer", "description": "Timeout in ms" }
197                    },
198                    "required": ["timeout"]
199                }
200            }
201        });
202        let result = simplify(&schema);
203        assert!(result.contains("- config (optional, object)"));
204        assert!(result.contains("  - timeout (required, integer): Timeout in ms"));
205    }
206
207    #[test]
208    fn simplify_array_type() {
209        let schema = json!({
210            "type": "object",
211            "properties": {
212                "tags": {
213                    "type": "array",
214                    "items": { "type": "string" },
215                    "description": "List of tags"
216                }
217            }
218        });
219        let result = simplify(&schema);
220        assert!(result.contains("array of string"));
221    }
222
223    #[test]
224    fn simplify_constraints() {
225        let schema = json!({
226            "type": "object",
227            "properties": {
228                "count": {
229                    "type": "integer",
230                    "minimum": 1,
231                    "maximum": 100,
232                    "description": "Item count"
233                }
234            },
235            "required": ["count"]
236        });
237        let result = simplify(&schema);
238        assert!(result.contains("min: 1"));
239        assert!(result.contains("max: 100"));
240    }
241
242    #[test]
243    fn simplify_empty_schema() {
244        let schema = json!({"type": "object"});
245        let result = simplify(&schema);
246        assert!(result.is_empty());
247    }
248
249    #[test]
250    fn simplify_tool_full() {
251        let schema = json!({
252            "type": "object",
253            "properties": {
254                "command": { "type": "string", "description": "Shell command" }
255            },
256            "required": ["command"]
257        });
258        let result = simplify_tool("bash", "Run a shell command", &schema);
259        assert!(result.contains("### bash"));
260        assert!(result.contains("Run a shell command"));
261        assert!(result.contains("- command (required, string): Shell command"));
262    }
263}