Ignore empty reasoning heartbeats
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
async fn cancellation_before_stream_start_is_a_normal_stop() {
|
||||
let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user