From 37590c86fb0cfc006a352b5f696e2a18bf89c1c0 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 7 Jul 2026 19:29:44 +0530 Subject: [PATCH 1/2] Fail an empty provider completion instead of a blank final answer Add an opt-in RunPolicy.error_on_empty_response guard: when set, an assistant completion with no text, no tool calls, and no structured output in the finalization branch drops the empty row and returns a typed TinyAgentsError::EmptyResponse rather than terminating the run with a blank success. Defaults off to preserve existing callers. Closes tinyhumansai/openhuman#4638 --- src/error.rs | 10 ++++++++ src/harness/agent_loop/run_loop.rs | 15 ++++++++++++ src/harness/agent_loop/test.rs | 38 ++++++++++++++++++++++++++++++ src/harness/runtime/types.rs | 11 +++++++++ 4 files changed, 74 insertions(+) diff --git a/src/error.rs b/src/error.rs index 0c3e2ed..c766b29 100644 --- a/src/error.rs +++ b/src/error.rs @@ -124,6 +124,16 @@ pub enum TinyAgentsError { #[error("limit exceeded: {0}")] LimitExceeded(String), + /// The provider returned an empty completion — no text, no tool calls, and + /// no structured output — while + /// [`crate::harness::runtime::RunPolicy`]'s `error_on_empty_response` guard + /// was enabled. Raised in the agent loop's finalization branch instead of + /// terminating the run with a blank final answer, so the caller can + /// re-prompt or surface a real failure rather than silently succeeding with + /// empty content. + #[error("model returned an empty response")] + EmptyResponse, + /// The run exceeded its wall-clock deadline. #[error("run timed out: {0}")] Timeout(String), diff --git a/src/harness/agent_loop/run_loop.rs b/src/harness/agent_loop/run_loop.rs index e18dd4f..e97b480 100644 --- a/src/harness/agent_loop/run_loop.rs +++ b/src/harness/agent_loop/run_loop.rs @@ -280,6 +280,21 @@ impl AgentHarness { let output = extractor.extract(&response)?; run.structured = Some(output.value); } + // An empty provider completion — no text, no tool calls, and no + // structured output — must not silently become the terminal + // answer (openhuman#4638). When the policy opts in, drop the + // empty assistant row appended above and fail with a typed error + // so the caller can re-prompt instead of returning a blank + // success. Gated off by default to preserve callers that rely on + // empty finals. + if self.policy.error_on_empty_response + && run.structured.is_none() + && tool_calls.is_empty() + && response.text().trim().is_empty() + { + messages.pop(); + return Err(TinyAgentsError::EmptyResponse); + } run.final_response = Some(response); break; } diff --git a/src/harness/agent_loop/test.rs b/src/harness/agent_loop/test.rs index 9627f7a..397876b 100644 --- a/src/harness/agent_loop/test.rs +++ b/src/harness/agent_loop/test.rs @@ -421,6 +421,44 @@ async fn single_model_call_no_tools() { assert_eq!(run.messages.len(), 2); } +#[tokio::test] +async fn empty_response_fails_the_run_when_guard_enabled() { + // openhuman#4638: an empty provider completion (no text, no tool calls, no + // structured output) must not terminate the run with a blank final answer + // when the guard is enabled — it fails with a typed `EmptyResponse` so the + // caller can re-prompt instead of silently succeeding on empty content. + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.register_model("mock", Arc::new(MockModel::constant(""))); + harness.with_policy(RunPolicy { + error_on_empty_response: true, + ..RunPolicy::default() + }); + + let err = harness + .invoke_default(&(), vec![Message::user("hi")]) + .await + .expect_err("an empty response should fail the run"); + assert!( + matches!(err, TinyAgentsError::EmptyResponse), + "expected EmptyResponse, got {err:?}" + ); +} + +#[tokio::test] +async fn empty_response_terminates_normally_when_guard_disabled() { + // The guard is opt-in: with the default policy an empty completion still + // terminates the run with a blank final answer (preserved behavior). + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.register_model("mock", Arc::new(MockModel::constant(""))); + + let run = harness + .invoke_default(&(), vec![Message::user("hi")]) + .await + .expect("run succeeds with a blank final by default"); + assert_eq!(run.model_calls, 1); + assert_eq!(run.text(), Some(String::new())); +} + #[tokio::test] async fn model_requests_tool_then_finishes() { let mut harness: AgentHarness<()> = AgentHarness::new(); diff --git a/src/harness/runtime/types.rs b/src/harness/runtime/types.rs index 18c7893..c9cd8d2 100644 --- a/src/harness/runtime/types.rs +++ b/src/harness/runtime/types.rs @@ -144,6 +144,15 @@ pub struct RunPolicy { /// [`crate::harness::model::ModelRequest::cache_policy`] does not override /// it. A request-level `cache_policy` always wins over this default. pub cache: CachePolicy, + /// When `true`, an empty provider completion in the finalization branch (no + /// text, no tool calls, and no structured output) fails the run with + /// [`crate::error::TinyAgentsError::EmptyResponse`] instead of terminating + /// with a blank final answer. + /// + /// Defaults to `false` to preserve the historical behavior for callers who + /// rely on empty finals; opt in to turn a silent blank success into a typed + /// error the caller can re-prompt on. + pub error_on_empty_response: bool, } impl Default for RunPolicy { @@ -161,6 +170,8 @@ impl Default for RunPolicy { response_cache_enabled: true, protect_prompt_prefix: false, }, + // Opt-in: preserve the historical blank-final behavior by default. + error_on_empty_response: false, } } } From 3fa321350d37b2d3f561c5c286f9d053fd498cd5 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 7 Jul 2026 19:38:09 +0530 Subject: [PATCH 2/2] harness: fix clippy lints surfaced by stable 1.96 collapsible_if in StreamAccumulator::push_tool_chunk and a duplicated #[cfg(test)] on the stream test module both fail CI's cargo clippy --all-targets -- -D warnings under rust 1.96. Behavior-neutral. --- src/harness/model/mod.rs | 8 ++++---- src/harness/stream/mod.rs | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/harness/model/mod.rs b/src/harness/model/mod.rs index aa24cb1..ea3e6b8 100644 --- a/src/harness/model/mod.rs +++ b/src/harness/model/mod.rs @@ -753,10 +753,10 @@ impl StreamAccumulator { fn push_tool_chunk(&mut self, call_id: &str, content: &str, tool_name: Option<&str>) { if let Some(entry) = self.tool_chunks.iter_mut().find(|(id, ..)| id == call_id) { entry.1.push_str(content); - if entry.2.is_none() { - if let Some(name) = tool_name.filter(|n| !n.is_empty()) { - entry.2 = Some(name.to_string()); - } + if entry.2.is_none() + && let Some(name) = tool_name.filter(|n| !n.is_empty()) + { + entry.2 = Some(name.to_string()); } } else { self.tool_chunks.push(( diff --git a/src/harness/stream/mod.rs b/src/harness/stream/mod.rs index 14411f0..1712142 100644 --- a/src/harness/stream/mod.rs +++ b/src/harness/stream/mod.rs @@ -143,6 +143,5 @@ pub fn stream(chunks: &[StreamChunk], modes: &[StreamMode]) -> Vec .collect() } -#[cfg(test)] #[cfg(test)] mod test;