From b2307b665f826296fea8e53c9ab92a87633d41a2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 20:32:30 +0400 Subject: [PATCH 01/11] feat(harness): normalize recoverable tool arguments --- src/harness/agent_loop/test.rs | 86 +++++++++++++++++++++++++++++++++ src/harness/agent_loop/tools.rs | 60 ++++++++++++++++++++++- src/harness/runtime/types.rs | 6 +++ 3 files changed, 150 insertions(+), 2 deletions(-) diff --git a/src/harness/agent_loop/test.rs b/src/harness/agent_loop/test.rs index eab6fdd51..a8f21a598 100644 --- a/src/harness/agent_loop/test.rs +++ b/src/harness/agent_loop/test.rs @@ -964,6 +964,92 @@ async fn invalid_tool_arguments_return_tool_error_recovers() { ); } +#[tokio::test] +async fn normalized_json_string_arguments_execute_registered_tool() { + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.register_model( + "mock", + Arc::new(MockModel::with_responses(vec![ + tool_call_response( + "call-1", + "strict_lookup", + json!("```json\n{\"query\":\"rust\"}\n```"), + ), + text_response("done", 1, 1), + ])), + ); + let calls = Arc::new(Mutex::new(0)); + harness.register_tool(Arc::new(StrictLookupTool { + calls: Arc::clone(&calls), + })); + harness.with_policy(RunPolicy { + invalid_args: InvalidArgsPolicy::NormalizeThenReturnToolError, + ..RunPolicy::default() + }); + + let run = harness + .invoke_default(&(), vec![Message::user("lookup")]) + .await + .expect("a fenced JSON object should be normalized before validation"); + + assert_eq!(run.final_response.unwrap().text(), "done"); + assert_eq!(*calls.lock().unwrap(), 1); +} + +#[tokio::test] +async fn normalized_non_object_executes_tool_without_required_fields() { + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.register_model( + "mock", + Arc::new(MockModel::with_responses(vec![ + tool_call_response("call-1", "permissive", json!(null)), + text_response("done", 1, 1), + ])), + ); + let tool = Arc::new(FakeTool::new("permissive", "ok")); + harness.register_tool(tool.clone()); + harness.with_policy(RunPolicy { + invalid_args: InvalidArgsPolicy::NormalizeThenReturnToolError, + ..RunPolicy::default() + }); + + let run = harness + .invoke_default(&(), vec![Message::user("run")]) + .await + .expect("a non-object should become an empty object for a permissive schema"); + + assert_eq!(run.final_response.unwrap().text(), "done"); + assert_eq!(*tool.calls.lock().unwrap(), 1); +} + +#[tokio::test] +async fn normalization_preserves_required_field_validation_errors() { + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.register_model( + "mock", + Arc::new(MockModel::with_responses(vec![ + tool_call_response("call-1", "strict_lookup", json!(null)), + text_response("recovered", 1, 1), + ])), + ); + let calls = Arc::new(Mutex::new(0)); + harness.register_tool(Arc::new(StrictLookupTool { + calls: Arc::clone(&calls), + })); + harness.with_policy(RunPolicy { + invalid_args: InvalidArgsPolicy::NormalizeThenReturnToolError, + ..RunPolicy::default() + }); + + let run = harness + .invoke_default(&(), vec![Message::user("lookup")]) + .await + .expect("required-field validation should remain recoverable"); + + assert_eq!(run.final_response.unwrap().text(), "recovered"); + assert_eq!(*calls.lock().unwrap(), 0); +} + #[tokio::test] async fn malformed_tool_arguments_recover_as_error_tool_result() { // A small local model emitted arguments the provider could not parse, so the diff --git a/src/harness/agent_loop/tools.rs b/src/harness/agent_loop/tools.rs index 0f4b41924..c5794c767 100644 --- a/src/harness/agent_loop/tools.rs +++ b/src/harness/agent_loop/tools.rs @@ -224,7 +224,14 @@ impl AgentHarness { } } }; - if let Err(err) = tool.schema().validate_call(call) { + let schema = tool.schema(); + if matches!( + self.policy.invalid_args, + InvalidArgsPolicy::NormalizeThenReturnToolError + ) { + normalize_tool_arguments(call, &schema.parameters); + } + if let Err(err) = schema.validate_call(call) { // The model called a registered tool with schema-invalid arguments. // Apply the run's `InvalidArgsPolicy` instead of unconditionally // aborting the turn (mirrors the unknown-tool recovery above). @@ -237,7 +244,7 @@ impl AgentHarness { // tool-call budget slot above, bounding the loop. let call_id = CallId::new(call.id.clone()); let detail = err.to_string(); - let schema_repr = serde_json::to_string(&tool.schema().parameters) + let schema_repr = serde_json::to_string(&schema.parameters) .unwrap_or_else(|_| "".to_string()); let message = format!( "invalid arguments for tool `{}`: {detail}; expected schema: {schema_repr}", @@ -456,3 +463,52 @@ impl AgentHarness { Ok(()) } } + +/// Repairs provider-neutral argument shape defects before schema validation. +/// +/// Object arguments are already in the canonical shape. A string is decoded +/// only when it contains a JSON object, optionally inside a markdown code +/// fence. Other non-object values become an empty object only for schemas that +/// declare no required fields; required-field schemas retain the original value +/// so the validation error remains precise and model-visible. +fn normalize_tool_arguments(call: &mut ToolCall, parameters: &Value) { + if call.arguments.is_object() { + return; + } + + if let Some(raw) = call.arguments.as_str() { + let candidate = strip_markdown_code_fence(raw); + if let Ok(value) = serde_json::from_str::(candidate) { + if value.is_object() { + call.arguments = value; + return; + } + } + } + + let has_required_fields = parameters + .get("required") + .and_then(Value::as_array) + .is_some_and(|required| required.iter().any(Value::is_string)); + if !has_required_fields { + call.arguments = serde_json::json!({}); + } +} + +fn strip_markdown_code_fence(raw: &str) -> &str { + let trimmed = raw.trim(); + let Some(after_open) = trimmed.strip_prefix("```") else { + return trimmed; + }; + let body = match after_open.find('\n') { + Some(newline) + if after_open[..newline] + .chars() + .all(|character| character.is_ascii_alphanumeric()) => + { + &after_open[newline + 1..] + } + _ => after_open, + }; + body.trim().strip_suffix("```").unwrap_or(body).trim() +} diff --git a/src/harness/runtime/types.rs b/src/harness/runtime/types.rs index 12420c959..e0a6b2c92 100644 --- a/src/harness/runtime/types.rs +++ b/src/harness/runtime/types.rs @@ -93,6 +93,12 @@ pub enum InvalidArgsPolicy { /// tool's expected parameter schema) back into the transcript and continue /// the loop, letting the model retry with corrected arguments. ReturnToolError, + /// First normalize common provider-shape defects, then apply + /// [`Self::ReturnToolError`] if the resulting arguments still fail schema + /// validation. Normalization decodes a JSON object emitted as a string + /// (including a markdown-fenced string) and coerces a non-object to `{}` + /// only when the tool schema declares no required fields. + NormalizeThenReturnToolError, } /// Controls whether the agent loop captures model and tool **payloads** From e6ef3a0307f5df95a156d338751218d66f9e1b76 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 20:35:24 +0400 Subject: [PATCH 02/11] fix(harness): satisfy strict clippy --- src/harness/agent_loop/tools.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/harness/agent_loop/tools.rs b/src/harness/agent_loop/tools.rs index c5794c767..f56f0dbfe 100644 --- a/src/harness/agent_loop/tools.rs +++ b/src/harness/agent_loop/tools.rs @@ -478,11 +478,11 @@ fn normalize_tool_arguments(call: &mut ToolCall, parameters: &Value) { if let Some(raw) = call.arguments.as_str() { let candidate = strip_markdown_code_fence(raw); - if let Ok(value) = serde_json::from_str::(candidate) { - if value.is_object() { - call.arguments = value; - return; - } + if let Ok(value) = serde_json::from_str::(candidate) + && value.is_object() + { + call.arguments = value; + return; } } From 105892bc0e6157869e5ef9bb5f1e308dd9e6e885 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 21:05:04 +0400 Subject: [PATCH 03/11] fix(harness): preserve primitive tool arguments --- docs/modules/harness/tool.md | 5 +++ src/harness/agent_loop/test.rs | 56 +++++++++++++++++++++++++++++++++ src/harness/agent_loop/tools.rs | 10 ++++++ 3 files changed, 71 insertions(+) diff --git a/docs/modules/harness/tool.md b/docs/modules/harness/tool.md index 6967a4f9b..a08dfa897 100644 --- a/docs/modules/harness/tool.md +++ b/docs/modules/harness/tool.md @@ -263,6 +263,11 @@ are handled separately: governed by `RunPolicy::invalid_args: InvalidArgsPolicy`. `Fail` (default, historical) aborts the turn; `ReturnToolError` injects a repairable tool-error message (carrying the validation detail and the expected schema) and continues. + `NormalizeThenReturnToolError` first repairs common object-schema transport + shapes (a JSON object encoded as a string, including markdown fences, or a + non-object for an object schema with no required fields), then returns any + remaining validation failure as a tool error. Schemas that accept top-level + primitives or arrays are left untouched. - **Unparseable** (malformed JSON the provider could not parse into arguments at all) is surfaced by the provider as a `ToolCall` with `invalid: Some(reason)` and the raw string preserved in `arguments`. Small local models (Ollama, LM diff --git a/src/harness/agent_loop/test.rs b/src/harness/agent_loop/test.rs index a8f21a598..7795316f9 100644 --- a/src/harness/agent_loop/test.rs +++ b/src/harness/agent_loop/test.rs @@ -98,6 +98,34 @@ struct StrictLookupTool { calls: Arc>, } +struct StringEchoTool { + calls: Arc>, +} + +#[async_trait] +impl Tool<()> for StringEchoTool { + fn name(&self) -> &str { + "string_echo" + } + + fn description(&self) -> &str { + "echo a string" + } + + fn schema(&self) -> ToolSchema { + ToolSchema::new("string_echo", "echo a string", json!({ "type": "string" })) + } + + async fn call(&self, _state: &(), call: ToolCall) -> Result { + *self.calls.lock().unwrap() += 1; + Ok(ToolResult::text( + call.id, + self.name(), + call.arguments.to_string(), + )) + } +} + #[async_trait] impl Tool<()> for StrictLookupTool { fn name(&self) -> &str { @@ -1022,6 +1050,34 @@ async fn normalized_non_object_executes_tool_without_required_fields() { assert_eq!(*tool.calls.lock().unwrap(), 1); } +#[tokio::test] +async fn normalization_preserves_valid_primitive_arguments() { + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.register_model( + "mock", + Arc::new(MockModel::with_responses(vec![ + tool_call_response("call-1", "string_echo", json!("hello")), + text_response("done", 1, 1), + ])), + ); + let calls = Arc::new(Mutex::new(0)); + harness.register_tool(Arc::new(StringEchoTool { + calls: Arc::clone(&calls), + })); + harness.with_policy(RunPolicy { + invalid_args: InvalidArgsPolicy::NormalizeThenReturnToolError, + ..RunPolicy::default() + }); + + let run = harness + .invoke_default(&(), vec![Message::user("echo")]) + .await + .expect("a schema-valid primitive must not be rewritten to an object"); + + assert_eq!(run.final_response.unwrap().text(), "done"); + assert_eq!(*calls.lock().unwrap(), 1); +} + #[tokio::test] async fn normalization_preserves_required_field_validation_errors() { let mut harness: AgentHarness<()> = AgentHarness::new(); diff --git a/src/harness/agent_loop/tools.rs b/src/harness/agent_loop/tools.rs index f56f0dbfe..530cb39f4 100644 --- a/src/harness/agent_loop/tools.rs +++ b/src/harness/agent_loop/tools.rs @@ -476,6 +476,16 @@ fn normalize_tool_arguments(call: &mut ToolCall, parameters: &Value) { return; } + let accepts_object = parameters.get("type").is_some_and(|kind| { + kind.as_str() == Some("object") + || kind + .as_array() + .is_some_and(|kinds| kinds.iter().any(|kind| kind.as_str() == Some("object"))) + }) || parameters.get("properties").is_some(); + if !accepts_object { + return; + } + if let Some(raw) = call.arguments.as_str() { let candidate = strip_markdown_code_fence(raw); if let Ok(value) = serde_json::from_str::(candidate) From f33dd5bf3eb15b7989141df040173af6c28598ef Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 21:19:37 +0400 Subject: [PATCH 04/11] fix(harness): normalize object-capable schemas safely --- src/harness/agent_loop/test.rs | 62 ++++++++++++++++++++++++++++++++- src/harness/agent_loop/tools.rs | 12 ++++--- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/harness/agent_loop/test.rs b/src/harness/agent_loop/test.rs index 7795316f9..46cd005d2 100644 --- a/src/harness/agent_loop/test.rs +++ b/src/harness/agent_loop/test.rs @@ -102,6 +102,10 @@ struct StringEchoTool { calls: Arc>, } +struct RequiredOnlyTool { + calls: Arc>, +} + #[async_trait] impl Tool<()> for StringEchoTool { fn name(&self) -> &str { @@ -113,7 +117,11 @@ impl Tool<()> for StringEchoTool { } fn schema(&self) -> ToolSchema { - ToolSchema::new("string_echo", "echo a string", json!({ "type": "string" })) + ToolSchema::new( + "string_echo", + "echo a string", + json!({ "type": ["object", "string"] }), + ) } async fn call(&self, _state: &(), call: ToolCall) -> Result { @@ -126,6 +134,30 @@ impl Tool<()> for StringEchoTool { } } +#[async_trait] +impl Tool<()> for RequiredOnlyTool { + fn name(&self) -> &str { + "required_only" + } + + fn description(&self) -> &str { + "accepts an implicitly object-shaped schema" + } + + fn schema(&self) -> ToolSchema { + ToolSchema::new( + "required_only", + self.description(), + json!({ "required": ["query"] }), + ) + } + + async fn call(&self, _state: &(), call: ToolCall) -> Result { + *self.calls.lock().unwrap() += 1; + Ok(ToolResult::text(call.id, self.name(), "required-output")) + } +} + #[async_trait] impl Tool<()> for StrictLookupTool { fn name(&self) -> &str { @@ -1078,6 +1110,34 @@ async fn normalization_preserves_valid_primitive_arguments() { assert_eq!(*calls.lock().unwrap(), 1); } +#[tokio::test] +async fn normalization_decodes_required_only_object_schema() { + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.register_model( + "mock", + Arc::new(MockModel::with_responses(vec![ + tool_call_response("call-1", "required_only", json!("{\"query\":\"rust\"}")), + text_response("done", 1, 1), + ])), + ); + let calls = Arc::new(Mutex::new(0)); + harness.register_tool(Arc::new(RequiredOnlyTool { + calls: Arc::clone(&calls), + })); + harness.with_policy(RunPolicy { + invalid_args: InvalidArgsPolicy::NormalizeThenReturnToolError, + ..RunPolicy::default() + }); + + let run = harness + .invoke_default(&(), vec![Message::user("lookup")]) + .await + .expect("a required-only object schema should normalize a JSON string"); + + assert_eq!(run.final_response.unwrap().text(), "done"); + assert_eq!(*calls.lock().unwrap(), 1); +} + #[tokio::test] async fn normalization_preserves_required_field_validation_errors() { let mut harness: AgentHarness<()> = AgentHarness::new(); diff --git a/src/harness/agent_loop/tools.rs b/src/harness/agent_loop/tools.rs index 530cb39f4..e1ae28eac 100644 --- a/src/harness/agent_loop/tools.rs +++ b/src/harness/agent_loop/tools.rs @@ -229,7 +229,7 @@ impl AgentHarness { self.policy.invalid_args, InvalidArgsPolicy::NormalizeThenReturnToolError ) { - normalize_tool_arguments(call, &schema.parameters); + normalize_tool_arguments(call, &schema); } if let Err(err) = schema.validate_call(call) { // The model called a registered tool with schema-invalid arguments. @@ -471,17 +471,21 @@ impl AgentHarness { /// fence. Other non-object values become an empty object only for schemas that /// declare no required fields; required-field schemas retain the original value /// so the validation error remains precise and model-visible. -fn normalize_tool_arguments(call: &mut ToolCall, parameters: &Value) { - if call.arguments.is_object() { +fn normalize_tool_arguments(call: &mut ToolCall, schema: &ToolSchema) { + // Never rewrite a value the declared schema already accepts. In + // particular, an object-capable union may validly accept a primitive too. + if schema.validate_call(call).is_ok() { return; } + let parameters = &schema.parameters; let accepts_object = parameters.get("type").is_some_and(|kind| { kind.as_str() == Some("object") || kind .as_array() .is_some_and(|kinds| kinds.iter().any(|kind| kind.as_str() == Some("object"))) - }) || parameters.get("properties").is_some(); + }) || parameters.get("properties").is_some() + || parameters.get("required").is_some(); if !accepts_object { return; } From 3c13ec10358c9d0824795f9ff6c7ec783d4bfe13 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 21:41:19 +0400 Subject: [PATCH 05/11] fix(harness): honor non-object schema union arms --- src/harness/agent_loop/test.rs | 5 +++- src/harness/tool/mod.rs | 53 ++++++++++++++++++++-------------- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/harness/agent_loop/test.rs b/src/harness/agent_loop/test.rs index 46cd005d2..bda5b605a 100644 --- a/src/harness/agent_loop/test.rs +++ b/src/harness/agent_loop/test.rs @@ -120,7 +120,10 @@ impl Tool<()> for StringEchoTool { ToolSchema::new( "string_echo", "echo a string", - json!({ "type": ["object", "string"] }), + json!({ + "type": ["object", "string"], + "properties": { "value": { "type": "string" } } + }), ) } diff --git a/src/harness/tool/mod.rs b/src/harness/tool/mod.rs index d8258f5c3..3b0374634 100644 --- a/src/harness/tool/mod.rs +++ b/src/harness/tool/mod.rs @@ -439,37 +439,46 @@ fn validate_schema_value(schema: &Value, value: &Value, path: &str) -> Result<() // check under `properties` would let such schemas fail open, silently // accepting calls that omit required arguments. if let Some(required) = schema.get("required").and_then(Value::as_array) { - let object = value.as_object().ok_or_else(|| { - TinyAgentsError::Validation(format!("{path} must be an object with declared fields")) - })?; - for field in required.iter().filter_map(Value::as_str) { - if !object.contains_key(field) { - return Err(TinyAgentsError::Validation(format!( - "{path}.{field} is required" - ))); + if let Some(object) = value.as_object() { + for field in required.iter().filter_map(Value::as_str) { + if !object.contains_key(field) { + return Err(TinyAgentsError::Validation(format!( + "{path}.{field} is required" + ))); + } } + } else if schema.get("type").is_none() { + // Preserve the crate's required-only shorthand: without an + // explicit type, `required` declares an object schema. With a + // union type, JSON Schema object keywords apply only to object + // instances; a value accepted by another arm remains valid. + return Err(TinyAgentsError::Validation(format!( + "{path} must be an object with declared fields" + ))); } } if let Some(properties) = schema.get("properties").and_then(Value::as_object) { - let object = value.as_object().ok_or_else(|| { - TinyAgentsError::Validation(format!("{path} must be an object with declared fields")) - })?; - - if schema.get("additionalProperties").and_then(Value::as_bool) == Some(false) { - for field in object.keys() { - if !properties.contains_key(field) { - return Err(TinyAgentsError::Validation(format!( - "{path}.{field} is not allowed" - ))); + if let Some(object) = value.as_object() { + if schema.get("additionalProperties").and_then(Value::as_bool) == Some(false) { + for field in object.keys() { + if !properties.contains_key(field) { + return Err(TinyAgentsError::Validation(format!( + "{path}.{field} is not allowed" + ))); + } } } - } - for (field, field_schema) in properties { - if let Some(field_value) = object.get(field) { - validate_schema_value(field_schema, field_value, &format!("{path}.{field}"))?; + for (field, field_schema) in properties { + if let Some(field_value) = object.get(field) { + validate_schema_value(field_schema, field_value, &format!("{path}.{field}"))?; + } } + } else if schema.get("type").is_none() { + return Err(TinyAgentsError::Validation(format!( + "{path} must be an object with declared fields" + ))); } } From ce9b2863a374cb33d33fed26ce992f5945bf38a4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 21:47:05 +0400 Subject: [PATCH 06/11] test(harness): cover enum and primitive normalization --- src/harness/agent_loop/test.rs | 62 +++++++++++++++++++++++++++++++++ src/harness/agent_loop/tools.rs | 6 +++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/harness/agent_loop/test.rs b/src/harness/agent_loop/test.rs index bda5b605a..5832fe820 100644 --- a/src/harness/agent_loop/test.rs +++ b/src/harness/agent_loop/test.rs @@ -106,6 +106,10 @@ struct RequiredOnlyTool { calls: Arc>, } +struct ObjectEnumTool { + calls: Arc>, +} + #[async_trait] impl Tool<()> for StringEchoTool { fn name(&self) -> &str { @@ -161,6 +165,30 @@ impl Tool<()> for RequiredOnlyTool { } } +#[async_trait] +impl Tool<()> for ObjectEnumTool { + fn name(&self) -> &str { + "object_enum" + } + + fn description(&self) -> &str { + "accepts one enumerated object" + } + + fn schema(&self) -> ToolSchema { + ToolSchema::new( + "object_enum", + self.description(), + json!({ "enum": [{ "query": "rust" }] }), + ) + } + + async fn call(&self, _state: &(), call: ToolCall) -> Result { + *self.calls.lock().unwrap() += 1; + Ok(ToolResult::text(call.id, self.name(), "enum-output")) + } +} + #[async_trait] impl Tool<()> for StrictLookupTool { fn name(&self) -> &str { @@ -1111,6 +1139,12 @@ async fn normalization_preserves_valid_primitive_arguments() { assert_eq!(run.final_response.unwrap().text(), "done"); assert_eq!(*calls.lock().unwrap(), 1); + assert!( + run.messages + .iter() + .any(|message| matches!(message, Message::Tool(_)) && message.text() == "\"hello\""), + "the tool result must contain the original primitive instead of a rewritten object" + ); } #[tokio::test] @@ -1141,6 +1175,34 @@ async fn normalization_decodes_required_only_object_schema() { assert_eq!(*calls.lock().unwrap(), 1); } +#[tokio::test] +async fn normalization_decodes_object_valued_enum_schema() { + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.register_model( + "mock", + Arc::new(MockModel::with_responses(vec![ + tool_call_response("call-1", "object_enum", json!("{\"query\":\"rust\"}")), + text_response("done", 1, 1), + ])), + ); + let calls = Arc::new(Mutex::new(0)); + harness.register_tool(Arc::new(ObjectEnumTool { + calls: Arc::clone(&calls), + })); + harness.with_policy(RunPolicy { + invalid_args: InvalidArgsPolicy::NormalizeThenReturnToolError, + ..RunPolicy::default() + }); + + let run = harness + .invoke_default(&(), vec![Message::user("lookup")]) + .await + .expect("an object-valued enum should normalize a matching JSON string"); + + assert_eq!(run.final_response.unwrap().text(), "done"); + assert_eq!(*calls.lock().unwrap(), 1); +} + #[tokio::test] async fn normalization_preserves_required_field_validation_errors() { let mut harness: AgentHarness<()> = AgentHarness::new(); diff --git a/src/harness/agent_loop/tools.rs b/src/harness/agent_loop/tools.rs index e1ae28eac..49f41c8d9 100644 --- a/src/harness/agent_loop/tools.rs +++ b/src/harness/agent_loop/tools.rs @@ -485,7 +485,11 @@ fn normalize_tool_arguments(call: &mut ToolCall, schema: &ToolSchema) { .as_array() .is_some_and(|kinds| kinds.iter().any(|kind| kind.as_str() == Some("object"))) }) || parameters.get("properties").is_some() - || parameters.get("required").is_some(); + || parameters.get("required").is_some() + || parameters + .get("enum") + .and_then(Value::as_array) + .is_some_and(|values| values.iter().any(Value::is_object)); if !accepts_object { return; } From 2a4ccc1f95c56994fde66d6ec34ef1f497f82187 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 21:48:40 +0400 Subject: [PATCH 07/11] fix(harness): preserve decoded union arguments --- src/harness/agent_loop/test.rs | 69 +++++++++++++++++++++++++++++++++ src/harness/agent_loop/tools.rs | 12 +++--- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/harness/agent_loop/test.rs b/src/harness/agent_loop/test.rs index 5832fe820..e3228972c 100644 --- a/src/harness/agent_loop/test.rs +++ b/src/harness/agent_loop/test.rs @@ -110,6 +110,10 @@ struct ObjectEnumTool { calls: Arc>, } +struct ObjectArrayTool { + calls: Arc>, +} + #[async_trait] impl Tool<()> for StringEchoTool { fn name(&self) -> &str { @@ -189,6 +193,37 @@ impl Tool<()> for ObjectEnumTool { } } +#[async_trait] +impl Tool<()> for ObjectArrayTool { + fn name(&self) -> &str { + "object_array" + } + + fn description(&self) -> &str { + "accepts an object or array" + } + + fn schema(&self) -> ToolSchema { + ToolSchema::new( + "object_array", + self.description(), + json!({ + "type": ["object", "array"], + "properties": { "value": { "type": "string" } } + }), + ) + } + + async fn call(&self, _state: &(), call: ToolCall) -> Result { + *self.calls.lock().unwrap() += 1; + Ok(ToolResult::text( + call.id, + self.name(), + call.arguments.to_string(), + )) + } +} + #[async_trait] impl Tool<()> for StrictLookupTool { fn name(&self) -> &str { @@ -1203,6 +1238,40 @@ async fn normalization_decodes_object_valued_enum_schema() { assert_eq!(*calls.lock().unwrap(), 1); } +#[tokio::test] +async fn normalization_preserves_decoded_array_union_arguments() { + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.register_model( + "mock", + Arc::new(MockModel::with_responses(vec![ + tool_call_response("call-1", "object_array", json!("[1,2]")), + text_response("done", 1, 1), + ])), + ); + let calls = Arc::new(Mutex::new(0)); + harness.register_tool(Arc::new(ObjectArrayTool { + calls: Arc::clone(&calls), + })); + harness.with_policy(RunPolicy { + invalid_args: InvalidArgsPolicy::NormalizeThenReturnToolError, + ..RunPolicy::default() + }); + + let run = harness + .invoke_default(&(), vec![Message::user("lookup")]) + .await + .expect("an object/array union should preserve a decoded array"); + + assert_eq!(run.final_response.unwrap().text(), "done"); + assert_eq!(*calls.lock().unwrap(), 1); + assert!( + run.messages + .iter() + .any(|message| matches!(message, Message::Tool(_)) && message.text() == "[1,2]"), + "the tool result must contain the decoded array instead of an empty object" + ); +} + #[tokio::test] async fn normalization_preserves_required_field_validation_errors() { let mut harness: AgentHarness<()> = AgentHarness::new(); diff --git a/src/harness/agent_loop/tools.rs b/src/harness/agent_loop/tools.rs index 49f41c8d9..56ae52209 100644 --- a/src/harness/agent_loop/tools.rs +++ b/src/harness/agent_loop/tools.rs @@ -496,11 +496,13 @@ fn normalize_tool_arguments(call: &mut ToolCall, schema: &ToolSchema) { if let Some(raw) = call.arguments.as_str() { let candidate = strip_markdown_code_fence(raw); - if let Ok(value) = serde_json::from_str::(candidate) - && value.is_object() - { - call.arguments = value; - return; + if let Ok(value) = serde_json::from_str::(candidate) { + let mut normalized = call.clone(); + normalized.arguments = value; + if schema.validate_call(&normalized).is_ok() { + call.arguments = normalized.arguments; + return; + } } } From edb16cbc4c96bb30dd9df2c56d0311d352e3a02b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 21:55:51 +0400 Subject: [PATCH 08/11] fix(harness): retain decoded validation errors --- src/harness/agent_loop/test.rs | 70 +++++++++++++++++++++++++++++++++ src/harness/agent_loop/tools.rs | 9 +++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/harness/agent_loop/test.rs b/src/harness/agent_loop/test.rs index e3228972c..066faf22c 100644 --- a/src/harness/agent_loop/test.rs +++ b/src/harness/agent_loop/test.rs @@ -114,6 +114,10 @@ struct ObjectArrayTool { calls: Arc>, } +struct OptionalStrictObjectTool { + calls: Arc>, +} + #[async_trait] impl Tool<()> for StringEchoTool { fn name(&self) -> &str { @@ -224,6 +228,34 @@ impl Tool<()> for ObjectArrayTool { } } +#[async_trait] +impl Tool<()> for OptionalStrictObjectTool { + fn name(&self) -> &str { + "optional_strict_object" + } + + fn description(&self) -> &str { + "accepts only an optional query field" + } + + fn schema(&self) -> ToolSchema { + ToolSchema::new( + "optional_strict_object", + self.description(), + json!({ + "type": "object", + "additionalProperties": false, + "properties": { "query": { "type": "string" } } + }), + ) + } + + async fn call(&self, _state: &(), call: ToolCall) -> Result { + *self.calls.lock().unwrap() += 1; + Ok(ToolResult::text(call.id, self.name(), "unexpected")) + } +} + #[async_trait] impl Tool<()> for StrictLookupTool { fn name(&self) -> &str { @@ -1272,6 +1304,44 @@ async fn normalization_preserves_decoded_array_union_arguments() { ); } +#[tokio::test] +async fn normalization_preserves_decoded_invalid_object_for_validation() { + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.register_model( + "mock", + Arc::new(MockModel::with_responses(vec![ + tool_call_response( + "call-1", + "optional_strict_object", + json!("{\"extra\":true}"), + ), + text_response("recovered", 1, 1), + ])), + ); + let calls = Arc::new(Mutex::new(0)); + harness.register_tool(Arc::new(OptionalStrictObjectTool { + calls: Arc::clone(&calls), + })); + harness.with_policy(RunPolicy { + invalid_args: InvalidArgsPolicy::NormalizeThenReturnToolError, + ..RunPolicy::default() + }); + + let run = harness + .invoke_default(&(), vec![Message::user("lookup")]) + .await + .expect("the decoded schema error should be recoverable"); + + assert_eq!(run.final_response.unwrap().text(), "recovered"); + assert_eq!(*calls.lock().unwrap(), 0); + assert!( + run.messages.iter().any(|message| { + matches!(message, Message::Tool(_)) && message.text().contains("extra is not allowed") + }), + "validation must report the decoded invalid field instead of executing with an empty object" + ); +} + #[tokio::test] async fn normalization_preserves_required_field_validation_errors() { let mut harness: AgentHarness<()> = AgentHarness::new(); diff --git a/src/harness/agent_loop/tools.rs b/src/harness/agent_loop/tools.rs index 56ae52209..0c4ae2954 100644 --- a/src/harness/agent_loop/tools.rs +++ b/src/harness/agent_loop/tools.rs @@ -499,10 +499,11 @@ fn normalize_tool_arguments(call: &mut ToolCall, schema: &ToolSchema) { if let Ok(value) = serde_json::from_str::(candidate) { let mut normalized = call.clone(); normalized.arguments = value; - if schema.validate_call(&normalized).is_ok() { - call.arguments = normalized.arguments; - return; - } + // Decoding must be lossless even when the decoded value is still + // schema-invalid. Preserve it so the validation below reports the + // actual bad field/type instead of silently replacing it with `{}`. + call.arguments = normalized.arguments; + return; } } From 5262493eb11b08a6619f1bf14d7b032618e3c9b6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 21:56:17 +0400 Subject: [PATCH 09/11] docs(harness): describe lossless argument recovery --- src/harness/agent_loop/tools.rs | 11 ++++++----- src/harness/runtime/types.rs | 7 ++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/harness/agent_loop/tools.rs b/src/harness/agent_loop/tools.rs index 0c4ae2954..7129d8b61 100644 --- a/src/harness/agent_loop/tools.rs +++ b/src/harness/agent_loop/tools.rs @@ -466,11 +466,12 @@ impl AgentHarness { /// Repairs provider-neutral argument shape defects before schema validation. /// -/// Object arguments are already in the canonical shape. A string is decoded -/// only when it contains a JSON object, optionally inside a markdown code -/// fence. Other non-object values become an empty object only for schemas that -/// declare no required fields; required-field schemas retain the original value -/// so the validation error remains precise and model-visible. +/// Schema-valid arguments are already canonical. A string containing valid +/// JSON is decoded, optionally through a markdown code fence, and the decoded +/// value is preserved for validation even when it remains invalid. Undecodable +/// or non-string values become an empty object only for object-capable schemas +/// that declare no required fields; required-field schemas retain the original +/// value so the validation error remains precise and model-visible. fn normalize_tool_arguments(call: &mut ToolCall, schema: &ToolSchema) { // Never rewrite a value the declared schema already accepts. In // particular, an object-capable union may validly accept a primitive too. diff --git a/src/harness/runtime/types.rs b/src/harness/runtime/types.rs index e0a6b2c92..d3f8d8bd7 100644 --- a/src/harness/runtime/types.rs +++ b/src/harness/runtime/types.rs @@ -95,9 +95,10 @@ pub enum InvalidArgsPolicy { ReturnToolError, /// First normalize common provider-shape defects, then apply /// [`Self::ReturnToolError`] if the resulting arguments still fail schema - /// validation. Normalization decodes a JSON object emitted as a string - /// (including a markdown-fenced string) and coerces a non-object to `{}` - /// only when the tool schema declares no required fields. + /// validation. Normalization decodes valid JSON emitted as a string + /// (including a markdown-fenced string), preserving decoded values for + /// precise validation, and coerces an undecodable/non-string value to `{}` + /// only when an object-capable tool schema declares no required fields. NormalizeThenReturnToolError, } From 02d7425ff244ea63b727e9cae35867c91cbd73cf Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 22:02:15 +0400 Subject: [PATCH 10/11] fix(harness): preserve direct invalid objects --- src/harness/agent_loop/test.rs | 34 +++++++++++++++++++++++++++++++++ src/harness/agent_loop/tools.rs | 7 +++++++ 2 files changed, 41 insertions(+) diff --git a/src/harness/agent_loop/test.rs b/src/harness/agent_loop/test.rs index 066faf22c..7fc8300bb 100644 --- a/src/harness/agent_loop/test.rs +++ b/src/harness/agent_loop/test.rs @@ -1342,6 +1342,40 @@ async fn normalization_preserves_decoded_invalid_object_for_validation() { ); } +#[tokio::test] +async fn normalization_preserves_direct_invalid_object_for_validation() { + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.register_model( + "mock", + Arc::new(MockModel::with_responses(vec![ + tool_call_response("call-1", "optional_strict_object", json!({"extra": true})), + text_response("recovered", 1, 1), + ])), + ); + let calls = Arc::new(Mutex::new(0)); + harness.register_tool(Arc::new(OptionalStrictObjectTool { + calls: Arc::clone(&calls), + })); + harness.with_policy(RunPolicy { + invalid_args: InvalidArgsPolicy::NormalizeThenReturnToolError, + ..RunPolicy::default() + }); + + let run = harness + .invoke_default(&(), vec![Message::user("lookup")]) + .await + .expect("the direct schema error should be recoverable"); + + assert_eq!(run.final_response.unwrap().text(), "recovered"); + assert_eq!(*calls.lock().unwrap(), 0); + assert!( + run.messages.iter().any(|message| { + matches!(message, Message::Tool(_)) && message.text().contains("extra is not allowed") + }), + "validation must report the direct invalid field instead of executing with an empty object" + ); +} + #[tokio::test] async fn normalization_preserves_required_field_validation_errors() { let mut harness: AgentHarness<()> = AgentHarness::new(); diff --git a/src/harness/agent_loop/tools.rs b/src/harness/agent_loop/tools.rs index 7129d8b61..2008db93b 100644 --- a/src/harness/agent_loop/tools.rs +++ b/src/harness/agent_loop/tools.rs @@ -508,6 +508,13 @@ fn normalize_tool_arguments(call: &mut ToolCall, schema: &ToolSchema) { } } + // A provider-native object is already the shape normalization is trying to + // recover. If its contents violate the schema, preserve them so the model + // sees the real validation error instead of executing with an empty object. + if call.arguments.is_object() { + return; + } + let has_required_fields = parameters .get("required") .and_then(Value::as_array) From b3cd4c81cbca2260b0d4b67aeee4bbcfebb2cbee Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 22 Jul 2026 22:10:50 +0400 Subject: [PATCH 11/11] fix(harness): retain raw invalid argument events --- src/harness/agent_loop/test.rs | 14 +++++++++++++- src/harness/agent_loop/tools.rs | 3 ++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/harness/agent_loop/test.rs b/src/harness/agent_loop/test.rs index 7fc8300bb..8ec39cff6 100644 --- a/src/harness/agent_loop/test.rs +++ b/src/harness/agent_loop/test.rs @@ -1327,8 +1327,12 @@ async fn normalization_preserves_decoded_invalid_object_for_validation() { ..RunPolicy::default() }); + use crate::harness::testkit::EventRecorder; + let recorder = EventRecorder::new(); + let ctx = + RunContext::new(RunConfig::new("decoded-invalid-args"), ()).with_events(recorder.sink()); let run = harness - .invoke_default(&(), vec![Message::user("lookup")]) + .invoke_in_context(&(), ctx, vec![Message::user("lookup")]) .await .expect("the decoded schema error should be recoverable"); @@ -1340,6 +1344,14 @@ async fn normalization_preserves_decoded_invalid_object_for_validation() { }), "validation must report the decoded invalid field instead of executing with an empty object" ); + assert!( + recorder.events().iter().any(|event| matches!( + event, + AgentEvent::InvalidToolArgs { arguments, .. } + if arguments == &json!("{\"extra\":true}") + )), + "the invalid-args event must retain the raw model-supplied JSON string" + ); } #[tokio::test] diff --git a/src/harness/agent_loop/tools.rs b/src/harness/agent_loop/tools.rs index 2008db93b..47bfda7a6 100644 --- a/src/harness/agent_loop/tools.rs +++ b/src/harness/agent_loop/tools.rs @@ -225,6 +225,7 @@ impl AgentHarness { } }; let schema = tool.schema(); + let raw_arguments = call.arguments.clone(); if matches!( self.policy.invalid_args, InvalidArgsPolicy::NormalizeThenReturnToolError @@ -253,7 +254,7 @@ impl AgentHarness { let record = ctx.emit(AgentEvent::InvalidToolArgs { call_id, tool_name: call.name.clone(), - arguments: call.arguments.clone(), + arguments: raw_arguments, error: detail, recovery: "tool_error".to_string(), });