One schema-driven validation pass over the *final* arguments object, shared by the `--args` and per-key paths. Turns the silent divergences — unknown keys forwarded and ignored, invalid enum values accepted, wrong JSON types reaching handlers — into corrective errors that state the fix. Schemas without `properties` are treated as opaque (no validation) so dynamic or profile-scoped tools cannot be
(def: &ToolDefinition, args: &Map<String, Value>)
| 235 | /// Schemas without `properties` are treated as opaque (no validation) so |
| 236 | /// dynamic or profile-scoped tools cannot be bricked by a stale walker. |
| 237 | fn validate_tool_args(def: &ToolDefinition, args: &Map<String, Value>) -> Result<()> { |
| 238 | let Some(props) = def |
| 239 | .input_schema |
| 240 | .get("properties") |
| 241 | .and_then(Value::as_object) |
| 242 | .filter(|props| !props.is_empty()) |
| 243 | else { |
| 244 | return Ok(()); |
| 245 | }; |
| 246 | let short = short_tool_name(&def.name); |
| 247 | |
| 248 | let required = schema_required_keys(def); |
| 249 | |
| 250 | for (key, value) in args { |
| 251 | let Some(schema) = props.get(key) else { |
| 252 | if DISPATCH_ROUTING_KEYS.contains(&key.as_str()) { |
| 253 | continue; |
| 254 | } |
| 255 | return Err(unknown_key_error(key, short, props, &required)); |
| 256 | }; |
| 257 | |
| 258 | if value.is_null() && !required.contains(key) { |
| 259 | continue; |
| 260 | } |
| 261 | |
| 262 | if let Some(allowed) = schema.get("enum").and_then(Value::as_array) { |
| 263 | if !allowed.iter().any(|candidate| candidate == value) { |
| 264 | let allowed: Vec<String> = allowed |
| 265 | .iter() |
| 266 | .map(|v| match v { |
| 267 | Value::String(s) => s.clone(), |
| 268 | other => other.to_string(), |
| 269 | }) |
| 270 | .collect(); |
| 271 | let displayed = value |
| 272 | .as_str() |
| 273 | .map(str::to_string) |
| 274 | .unwrap_or_else(|| value.to_string()); |
| 275 | return Err(TraceDecayError::Config { |
| 276 | message: format!( |
| 277 | "--{}: `{displayed}` is not one of: {}", |
| 278 | key.replace('_', "-"), |
| 279 | allowed.join(", ") |
| 280 | ), |
| 281 | }); |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | if let Some(expected) = schema.get("type").and_then(Value::as_str) { |
| 286 | if !value_matches_type(value, expected) { |
| 287 | let flag = key.replace('_', "-"); |
| 288 | let hint = if matches!(expected, "array" | "object") { |
| 289 | format!( |
| 290 | " Pass JSON (e.g. --{flag} '<json>'), {}", |
| 291 | heredoc_hint(short) |
| 292 | ) |
| 293 | } else { |
| 294 | String::new() |
no test coverage detected