Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
15 changes: 15 additions & 0 deletions src/harness/agent_loop/run_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,21 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> {
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;
}
Expand Down
38 changes: 38 additions & 0 deletions src/harness/agent_loop/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 4 additions & 4 deletions src/harness/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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((
Expand Down
11 changes: 11 additions & 0 deletions src/harness/runtime/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
}
}
}
Expand Down
1 change: 0 additions & 1 deletion src/harness/stream/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,5 @@ pub fn stream(chunks: &[StreamChunk], modes: &[StreamMode]) -> Vec<StreamChunk>
.collect()
}

#[cfg(test)]
#[cfg(test)]
mod test;