From c25e823e56da761c256026251a29f26d9b28d370 Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Wed, 26 Aug 2026 09:47:06 -0500 Subject: [PATCH] Ignore empty reasoning heartbeats --- .../ai/runtime/provider_run_coordinator.rs | 7 ++++ .../src/openai_compatible_tests.rs | 39 +++++++++++++++++++ crates/galaxy_agent_rig/src/stream.rs | 20 ++++++---- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/app/src/ai/runtime/provider_run_coordinator.rs b/app/src/ai/runtime/provider_run_coordinator.rs index ce5f22c2..1c61fd7c 100644 --- a/app/src/ai/runtime/provider_run_coordinator.rs +++ b/app/src/ai/runtime/provider_run_coordinator.rs @@ -732,6 +732,13 @@ impl ProviderRunCoordinator { 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 } => { if !self .ensure_model_started_acknowledged( diff --git a/crates/galaxy_agent_rig/src/openai_compatible_tests.rs b/crates/galaxy_agent_rig/src/openai_compatible_tests.rs index 795d632c..5eff56e1 100644 --- a/crates/galaxy_agent_rig/src/openai_compatible_tests.rs +++ b/crates/galaxy_agent_rig/src/openai_compatible_tests.rs @@ -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::>() + .await + .into_iter() + .collect::, _>>() + .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] async fn cancellation_before_stream_start_is_a_normal_stop() { let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig { diff --git a/crates/galaxy_agent_rig/src/stream.rs b/crates/galaxy_agent_rig/src/stream.rs index 8a9519fd..65328753 100644 --- a/crates/galaxy_agent_rig/src/stream.rs +++ b/crates/galaxy_agent_rig/src/stream.rs @@ -99,10 +99,13 @@ where } Ok(StreamedAssistantContent::Reasoning(reasoning)) => { let text = reasoning.display_text(); - yield Ok(AgentEvent::ReasoningCompleted { - text, - signature: reasoning.first_signature().map(str::to_string), - }); + let signature = reasoning.first_signature().map(str::to_string); + // Some Responses providers send empty reasoning blocks as heartbeats. + // 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, .. }) => { if !reasoning.is_empty() { @@ -236,10 +239,11 @@ where } } rig_core::completion::AssistantContent::Reasoning(reasoning) => { - yield Ok(AgentEvent::ReasoningCompleted { - text: reasoning.display_text(), - signature: reasoning.first_signature().map(str::to_string), - }); + let text = reasoning.display_text(); + let 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) => { yield Ok(AgentEvent::Tool {