Ignore empty reasoning heartbeats

This commit is contained in:
Ryan Ward
2026-08-26 09:47:06 -05:00
parent 2022716f91
commit c25e823e56
3 changed files with 58 additions and 8 deletions
@@ -732,6 +732,13 @@ impl ProviderRunCoordinator {
return Ok(()); return Ok(());
} }
} }
AgentEvent::ReasoningCompleted { text, signature }
if text.is_empty() && signature.is_none() =>
{
// Ignore provider heartbeat-shaped empty reasoning events. They are not
// meaningful model progress and must not keep an otherwise idle turn alive.
continue;
}
AgentEvent::ReasoningCompleted { text, signature } => { AgentEvent::ReasoningCompleted { text, signature } => {
if !self if !self
.ensure_model_started_acknowledged( .ensure_model_started_acknowledged(
@@ -86,6 +86,45 @@ async fn rig_stream_maps_reasoning_text_usage_and_stop() {
); );
} }
#[tokio::test]
async fn rig_stream_ignores_empty_reasoning_heartbeats() {
let http_client = MockStreamingClient {
sse_bytes: sse(&[
r#"{"id":"cmpl-1","model":"test-model","choices":[{"delta":{"reasoning_content":"","tool_calls":[]},"finish_reason":null}],"usage":null}"#,
r#"{"id":"cmpl-1","model":"test-model","choices":[{"delta":{"content":"done","tool_calls":[]},"finish_reason":"stop"}],"usage":null}"#,
"[DONE]",
]),
};
let client = openai::CompletionsClient::builder()
.api_key("test-key")
.base_url("http://localhost/v1")
.http_client(http_client)
.build()
.unwrap();
let model = client.completion_model("test-model");
let (_, control) = galaxy_agent_core::turn_control();
let events = start_model_turn(model, text_request(), control, None, true)
.await
.unwrap()
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(!events.iter().any(|event| matches!(
event,
AgentEvent::ReasoningCompleted { text, signature }
if text.is_empty() && signature.is_none()
)));
assert!(
events
.iter()
.any(|event| matches!(event, AgentEvent::TextDelta { text } if text == "done"))
);
}
#[tokio::test] #[tokio::test]
async fn cancellation_before_stream_start_is_a_normal_stop() { async fn cancellation_before_stream_start_is_a_normal_stop() {
let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig { let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig {
+12 -8
View File
@@ -99,10 +99,13 @@ where
} }
Ok(StreamedAssistantContent::Reasoning(reasoning)) => { Ok(StreamedAssistantContent::Reasoning(reasoning)) => {
let text = reasoning.display_text(); let text = reasoning.display_text();
yield Ok(AgentEvent::ReasoningCompleted { let signature = reasoning.first_signature().map(str::to_string);
text, // Some Responses providers send empty reasoning blocks as heartbeats.
signature: reasoning.first_signature().map(str::to_string), // Do not expose them as model progress: doing so continually resets the
}); // coordinator's idle timeout and can leave a turn stuck forever.
if !text.is_empty() || signature.is_some() {
yield Ok(AgentEvent::ReasoningCompleted { text, signature });
}
} }
Ok(StreamedAssistantContent::ReasoningDelta { reasoning, .. }) => { Ok(StreamedAssistantContent::ReasoningDelta { reasoning, .. }) => {
if !reasoning.is_empty() { if !reasoning.is_empty() {
@@ -236,10 +239,11 @@ where
} }
} }
rig_core::completion::AssistantContent::Reasoning(reasoning) => { rig_core::completion::AssistantContent::Reasoning(reasoning) => {
yield Ok(AgentEvent::ReasoningCompleted { let text = reasoning.display_text();
text: reasoning.display_text(), let signature = reasoning.first_signature().map(str::to_string);
signature: reasoning.first_signature().map(str::to_string), if !text.is_empty() || signature.is_some() {
}); yield Ok(AgentEvent::ReasoningCompleted { text, signature });
}
} }
rig_core::completion::AssistantContent::ToolCall(tool_call) => { rig_core::completion::AssistantContent::ToolCall(tool_call) => {
yield Ok(AgentEvent::Tool { yield Ok(AgentEvent::Tool {