Skip to main content

omgbase_surface/
arguments.rs

1//! Argument-shape errors (`spec/surface/README.md` §4 "Arguments", 3.0): every
2//! tool call is checked against its [`ToolSpec`](crate::catalog::ToolSpec)'s
3//! JSON schema ahead of dispatch, and a malformed call is one `filter_invalid`
4//! whose message names what was expected — and, for an unknown argument name,
5//! the nearest valid one. The reference (`packages/core/src/mcp/arguments.ts`)
6//! composes the same messages byte for byte and the surface fixtures pin them
7//! (every argument-shape error carries `data.hint = "arguments"`).
8//!
9//! Message forms (the `(… takes …)` tail lists the tool's arguments in
10//! bytewise order, optional ones marked `?`; a tool without arguments "takes
11//! no arguments"):
12//!
13//! ```text
14//! unknown argument `q`; did you mean `query`? (blocks_search takes limit?, q, repo?)
15//! `doc` is no longer an argument — pass `id` or `path` (docs_read takes …)
16//! `query` is required (query takes …)
17//! `limit` must be an integer (query takes …)
18//! pass id or path (docs_read takes …)                      — a locator tool called with neither
19//! id "d_x" and path "/a.md" name different documents (…)    — both given, disagreeing
20//! ```
21
22use serde_json::{Map, Value as Json, json};
23
24use crate::error::{Result, SurfaceError};
25
26/// The `(tool takes …)` tail: property names in bytewise order, `?` on the
27/// ones not in `required`.
28#[must_use]
29pub fn arguments_hint(tool: &str, schema: &Json) -> String {
30    let mut names: Vec<&str> = properties(schema).keys().map(String::as_str).collect();
31    names.sort_unstable();
32    if names.is_empty() {
33        return format!("{tool} takes no arguments");
34    }
35    let required = required(schema);
36    let list: Vec<String> = names
37        .iter()
38        .map(|n| {
39            if required.contains(n) {
40                (*n).to_owned()
41            } else {
42                format!("{n}?")
43            }
44        })
45        .collect();
46    format!("{tool} takes {}", list.join(", "))
47}
48
49/// A `filter_invalid` for an argument-shape failure: `message` plus the tool's shape.
50#[must_use]
51pub fn argument_error(tool: &str, message: &str, schema: &Json) -> SurfaceError {
52    let full = format!("{message} ({})", arguments_hint(tool, schema));
53    SurfaceError::with_data(
54        "filter_invalid",
55        full.clone(),
56        json!({ "reason": full, "hint": "arguments" }),
57    )
58}
59
60/// Check `args` against the tool's schema: unknown names first (bytewise
61/// order), then each declared argument (bytewise order) for presence and
62/// type. Nested shapes (`ops`, `expect`, `at`) are checked by their handlers.
63pub fn validate_arguments(tool: &str, schema: &Json, args: &Json) -> Result<()> {
64    let Some(obj) = args.as_object() else {
65        if args.is_null() {
66            return Ok(());
67        }
68        return Err(argument_error(tool, "arguments must be an object", schema));
69    };
70    let props = properties(schema);
71    let mut given: Vec<&String> = obj.keys().collect();
72    given.sort_unstable();
73    for key in given {
74        if !props.contains_key(key) {
75            return Err(argument_error(
76                tool,
77                &unknown_argument_message(key, props),
78                schema,
79            ));
80        }
81    }
82    let required = required(schema);
83    let mut declared: Vec<(&String, &Json)> = props.iter().collect();
84    declared.sort_unstable_by(|a, b| a.0.cmp(b.0));
85    for (key, prop) in declared {
86        match obj.get(key) {
87            None => {
88                if required.contains(&key.as_str()) {
89                    return Err(argument_error(
90                        tool,
91                        &format!("`{key}` is required"),
92                        schema,
93                    ));
94                }
95            }
96            Some(value) => {
97                if !matches_schema(prop, value) {
98                    return Err(argument_error(
99                        tool,
100                        &format!("`{key}` must be {}", phrase_for(prop)),
101                        schema,
102                    ));
103                }
104            }
105        }
106    }
107    Ok(())
108}
109
110/// What to say about an argument name the tool does not take: the names 3.0
111/// retired are called out with their replacement (`doc` → `id`/`path`,
112/// `block`/`node` → `id`, `heading` → `section`,
113/// `markdown` → `text`) when the tool has the replacement; otherwise the
114/// nearest valid name (Levenshtein ≤ 2, or one a prefix of the other).
115#[must_use]
116pub fn unknown_argument_message(key: &str, props: &Map<String, Json>) -> String {
117    let has = |n: &str| props.contains_key(n);
118    let retired =
119        |replacement: &str| format!("`{key}` is no longer an argument — pass {replacement}");
120    if key == "doc" && has("id") && has("path") {
121        return retired("`id` or `path`");
122    }
123    if (key == "block" || key == "node") && has("id") {
124        return retired("`id`");
125    }
126    if key == "heading" && has("section") {
127        return retired("`section` (the heading's text), or blocks_append with the heading's `id`");
128    }
129    if key == "markdown" && has("text") {
130        return retired("`text`");
131    }
132    let candidates: Vec<&str> = props.keys().map(String::as_str).collect();
133    match nearest(key, &candidates) {
134        None => format!("unknown argument `{key}`"),
135        Some(n) => format!("unknown argument `{key}`; did you mean `{n}`?"),
136    }
137}
138
139/// The nearest candidate: Levenshtein distance ≤ 2, or a prefix relation
140/// either way; ties → the bytewise-first.
141#[must_use]
142pub fn nearest<'a>(name: &str, candidates: &[&'a str]) -> Option<&'a str> {
143    let mut sorted: Vec<&'a str> = candidates.to_vec();
144    sorted.sort_unstable();
145    let mut best: Option<(&'a str, usize)> = None;
146    for c in sorted {
147        let d = levenshtein(name, c);
148        let prefix =
149            !name.is_empty() && !c.is_empty() && (c.starts_with(name) || name.starts_with(c));
150        if d > 2 && !prefix {
151            continue;
152        }
153        let score = if prefix && d > 2 { 3 } else { d };
154        if best.is_none_or(|(_, s)| score < s) {
155            best = Some((c, score));
156        }
157    }
158    best.map(|(c, _)| c)
159}
160
161#[must_use]
162pub fn levenshtein(a: &str, b: &str) -> usize {
163    let a: Vec<char> = a.chars().collect();
164    let b: Vec<char> = b.chars().collect();
165    if a.is_empty() {
166        return b.len();
167    }
168    if b.is_empty() {
169        return a.len();
170    }
171    let mut prev: Vec<usize> = (0..=b.len()).collect();
172    for (i, ca) in a.iter().enumerate() {
173        let mut cur = vec![i + 1];
174        for (j, cb) in b.iter().enumerate() {
175            let cost = usize::from(ca != cb);
176            cur.push((prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost));
177        }
178        prev = cur;
179    }
180    prev[b.len()]
181}
182
183// ---- JSON-schema introspection ---------------------------------------------------------
184
185fn properties(schema: &Json) -> &Map<String, Json> {
186    static EMPTY: std::sync::LazyLock<Map<String, Json>> = std::sync::LazyLock::new(Map::new);
187    schema
188        .get("properties")
189        .and_then(Json::as_object)
190        .unwrap_or(&EMPTY)
191}
192
193fn required(schema: &Json) -> Vec<&str> {
194    schema
195        .get("required")
196        .and_then(Json::as_array)
197        .map(|a| a.iter().filter_map(Json::as_str).collect())
198        .unwrap_or_default()
199}
200
201/// The schema's `type` (the first non-null entry of a type list).
202fn type_of(schema: &Json) -> Option<&str> {
203    match schema.get("type") {
204        Some(Json::String(t)) => Some(t.as_str()),
205        Some(Json::Array(ts)) => ts.iter().filter_map(Json::as_str).find(|t| *t != "null"),
206        _ => None,
207    }
208}
209
210fn nullable(schema: &Json) -> bool {
211    matches!(schema.get("type"), Some(Json::Array(ts)) if ts.iter().any(|t| t == "null"))
212}
213
214/// Does `value` fit the (shallow) schema — type, enum, `oneOf` for the `at`
215/// spec; array items by their declared type?
216fn matches_schema(schema: &Json, value: &Json) -> bool {
217    if value.is_null() {
218        return nullable(schema);
219    }
220    if let Some(options) = schema.get("enum").and_then(Json::as_array) {
221        return options.contains(value);
222    }
223    if schema.get("oneOf").is_some() {
224        // The `at` spec: "start" | "end" | { before } | { after }.
225        return match value {
226            Json::String(s) => s == "start" || s == "end",
227            Json::Object(o) => {
228                o.get("before").is_some_and(Json::is_string)
229                    || o.get("after").is_some_and(Json::is_string)
230            }
231            _ => false,
232        };
233    }
234    match type_of(schema) {
235        Some("string") => value.is_string(),
236        Some("integer") => value.as_f64().is_some_and(|n| n.fract() == 0.0),
237        Some("boolean") => value.is_boolean(),
238        Some("object") => value.is_object(),
239        Some("array") => value.as_array().is_some_and(|items| {
240            let item_type = schema.get("items").and_then(type_of);
241            items.iter().all(|v| match item_type {
242                Some("string") => v.is_string(),
243                Some("integer") => v.as_f64().is_some_and(|n| n.fract() == 0.0),
244                Some("object") => v.is_object(),
245                _ => true,
246            })
247        }),
248        _ => true,
249    }
250}
251
252/// The expected-type phrase of an argument, as the reference spells it from
253/// its zod schema.
254#[must_use]
255pub fn phrase_for(schema: &Json) -> String {
256    if let Some(options) = schema.get("enum").and_then(Json::as_array) {
257        let names: Vec<&str> = options.iter().filter_map(Json::as_str).collect();
258        return format!("one of {}", names.join(", "));
259    }
260    if schema.get("oneOf").is_some() {
261        return "\"start\", \"end\", {before} or {after}".to_owned();
262    }
263    match type_of(schema) {
264        Some("string") => "a string".to_owned(),
265        Some("integer") => "an integer".to_owned(),
266        Some("boolean") => "a boolean".to_owned(),
267        Some("object") => "an object".to_owned(),
268        Some("array") => {
269            let inner = match schema.get("items").and_then(type_of) {
270                Some("string") => "strings",
271                Some("integer") => "integers",
272                _ => "objects",
273            };
274            format!("an array of {inner}")
275        }
276        _ => "valid".to_owned(),
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn schema() -> Json {
285        json!({ "type": "object", "properties": {
286            "id": { "type": "string" }, "path": { "type": "string" }, "include_ids": { "type": "boolean" }, "repo": { "type": "string" }
287        }, "required": [] })
288    }
289
290    #[test]
291    fn hints_list_the_shape_in_bytewise_order() {
292        assert_eq!(
293            arguments_hint("docs_read", &schema()),
294            "docs_read takes id?, include_ids?, path?, repo?"
295        );
296        assert_eq!(
297            arguments_hint(
298                "repos",
299                &json!({ "type": "object", "properties": {}, "required": [] })
300            ),
301            "repos takes no arguments"
302        );
303    }
304
305    #[test]
306    fn unknown_names_are_corrected() {
307        let s = schema();
308        let e = validate_arguments("docs_read", &s, &json!({ "doc": "a.md" })).unwrap_err();
309        assert_eq!(
310            e.message,
311            "`doc` is no longer an argument — pass `id` or `path` (docs_read takes id?, include_ids?, path?, repo?)"
312        );
313        assert_eq!(e.data.unwrap()["hint"], "arguments");
314        let e = validate_arguments("docs_read", &s, &json!({ "include_id": true })).unwrap_err();
315        assert!(
316            e.message
317                .starts_with("unknown argument `include_id`; did you mean `include_ids`?")
318        );
319        let e = validate_arguments("docs_read", &s, &json!({ "zzz": true })).unwrap_err();
320        assert!(e.message.starts_with("unknown argument `zzz` ("));
321        let q = json!({ "type": "object", "properties": { "q": { "type": "string" }, "limit": { "type": "integer" } }, "required": ["q"] });
322        let e = validate_arguments("blocks_search", &q, &json!({ "query": "x" })).unwrap_err();
323        assert!(
324            e.message
325                .starts_with("unknown argument `query`; did you mean `q`?")
326        );
327        let e = validate_arguments("blocks_search", &q, &json!({})).unwrap_err();
328        assert_eq!(e.message, "`q` is required (blocks_search takes limit?, q)");
329        let e = validate_arguments("blocks_search", &q, &json!({ "q": "x", "limit": "5" }))
330            .unwrap_err();
331        assert_eq!(
332            e.message,
333            "`limit` must be an integer (blocks_search takes limit?, q)"
334        );
335        assert!(validate_arguments("blocks_search", &q, &json!({ "q": "x", "limit": 5 })).is_ok());
336    }
337
338    #[test]
339    fn distance_and_nearest() {
340        assert_eq!(levenshtein("kitten", "sitting"), 3);
341        assert_eq!(levenshtein("", "ab"), 2);
342        assert_eq!(nearest("q", &["query", "limit"]), Some("query"));
343        assert_eq!(nearest("id", &["id", "path"]), Some("id"));
344        assert_eq!(nearest("zzzz", &["id", "path"]), None);
345    }
346}