Skip to content

Commit e87d98d

Browse files
authored
Fail an empty provider completion instead of a blank final answer (#40)
* 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 * 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.
1 parent c18765b commit e87d98d

4 files changed

Lines changed: 74 additions & 0 deletions

File tree

‎src/error.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,16 @@ pub enum TinyAgentsError {
124124
#[error("limit exceeded: {0}")]
125125
LimitExceeded(String),
126126

127+
/// The provider returned an empty completion — no text, no tool calls, and
128+
/// no structured output — while
129+
/// [`crate::harness::runtime::RunPolicy`]'s `error_on_empty_response` guard
130+
/// was enabled. Raised in the agent loop's finalization branch instead of
131+
/// terminating the run with a blank final answer, so the caller can
132+
/// re-prompt or surface a real failure rather than silently succeeding with
133+
/// empty content.
134+
#[error("model returned an empty response")]
135+
EmptyResponse,
136+
127137
/// The run exceeded its wall-clock deadline.
128138
#[error("run timed out: {0}")]
129139
Timeout(String),

‎src/harness/agent_loop/run_loop.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,21 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> {
280280
let output = extractor.extract(&response)?;
281281
run.structured = Some(output.value);
282282
}
283+
// An empty provider completion — no text, no tool calls, and no
284+
// structured output — must not silently become the terminal
285+
// answer (openhuman#4638). When the policy opts in, drop the
286+
// empty assistant row appended above and fail with a typed error
287+
// so the caller can re-prompt instead of returning a blank
288+
// success. Gated off by default to preserve callers that rely on
289+
// empty finals.
290+
if self.policy.error_on_empty_response
291+
&& run.structured.is_none()
292+
&& tool_calls.is_empty()
293+
&& response.text().trim().is_empty()
294+
{
295+
messages.pop();
296+
return Err(TinyAgentsError::EmptyResponse);
297+
}
283298
run.final_response = Some(response);
284299
break;
285300
}

‎src/harness/agent_loop/test.rs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,44 @@ async fn single_model_call_no_tools() {
421421
assert_eq!(run.messages.len(), 2);
422422
}
423423

424+
#[tokio::test]
425+
async fn empty_response_fails_the_run_when_guard_enabled() {
426+
// openhuman#4638: an empty provider completion (no text, no tool calls, no
427+
// structured output) must not terminate the run with a blank final answer
428+
// when the guard is enabled — it fails with a typed `EmptyResponse` so the
429+
// caller can re-prompt instead of silently succeeding on empty content.
430+
let mut harness: AgentHarness<()> = AgentHarness::new();
431+
harness.register_model("mock", Arc::new(MockModel::constant("")));
432+
harness.with_policy(RunPolicy {
433+
error_on_empty_response: true,
434+
..RunPolicy::default()
435+
});
436+
437+
let err = harness
438+
.invoke_default(&(), vec![Message::user("hi")])
439+
.await
440+
.expect_err("an empty response should fail the run");
441+
assert!(
442+
matches!(err, TinyAgentsError::EmptyResponse),
443+
"expected EmptyResponse, got {err:?}"
444+
);
445+
}
446+
447+
#[tokio::test]
448+
async fn empty_response_terminates_normally_when_guard_disabled() {
449+
// The guard is opt-in: with the default policy an empty completion still
450+
// terminates the run with a blank final answer (preserved behavior).
451+
let mut harness: AgentHarness<()> = AgentHarness::new();
452+
harness.register_model("mock", Arc::new(MockModel::constant("")));
453+
454+
let run = harness
455+
.invoke_default(&(), vec![Message::user("hi")])
456+
.await
457+
.expect("run succeeds with a blank final by default");
458+
assert_eq!(run.model_calls, 1);
459+
assert_eq!(run.text(), Some(String::new()));
460+
}
461+
424462
#[tokio::test]
425463
async fn model_requests_tool_then_finishes() {
426464
let mut harness: AgentHarness<()> = AgentHarness::new();

‎src/harness/runtime/types.rs‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,15 @@ pub struct RunPolicy {
144144
/// [`crate::harness::model::ModelRequest::cache_policy`] does not override
145145
/// it. A request-level `cache_policy` always wins over this default.
146146
pub cache: CachePolicy,
147+
/// When `true`, an empty provider completion in the finalization branch (no
148+
/// text, no tool calls, and no structured output) fails the run with
149+
/// [`crate::error::TinyAgentsError::EmptyResponse`] instead of terminating
150+
/// with a blank final answer.
151+
///
152+
/// Defaults to `false` to preserve the historical behavior for callers who
153+
/// rely on empty finals; opt in to turn a silent blank success into a typed
154+
/// error the caller can re-prompt on.
155+
pub error_on_empty_response: bool,
147156
}
148157

149158
impl Default for RunPolicy {
@@ -161,6 +170,8 @@ impl Default for RunPolicy {
161170
response_cache_enabled: true,
162171
protect_prompt_prefix: false,
163172
},
173+
// Opt-in: preserve the historical blank-final behavior by default.
174+
error_on_empty_response: false,
164175
}
165176
}
166177
}

0 commit comments

Comments
 (0)