use pyo3::prelude::*;
use super::AgentId;
enum StepContent {
Output,
Error { message: String, http_code: u16 },
Empty,
}
const DEFAULT_MAX_CONSECUTIVE_MODEL_ERRORS: u32 = 3;
const DEFAULT_MAX_CONSECUTIVE_EMPTY_STEPS: u32 = 500;
#[derive(Debug, Clone, Copy)]
pub(crate) struct StreamLimits {
pub max_model_errors: u32,
pub max_empty_steps: u32,
pub channel_buffer: usize,
}
impl Default for StreamLimits {
fn default() -> Self {
Self {
max_model_errors: DEFAULT_MAX_CONSECUTIVE_MODEL_ERRORS,
max_empty_steps: DEFAULT_MAX_CONSECUTIVE_EMPTY_STEPS,
channel_buffer: crate::streaming::DEFAULT_CHANNEL_BUFFER,
}
}
}
impl StreamLimits {
pub fn from_config(config: &super::config::RuntimeConfig) -> Self {
Self {
max_model_errors: config
.max_consecutive_model_errors
.unwrap_or(DEFAULT_MAX_CONSECUTIVE_MODEL_ERRORS),
max_empty_steps: config
.max_consecutive_empty_steps
.unwrap_or(DEFAULT_MAX_CONSECUTIVE_EMPTY_STEPS),
channel_buffer: config
.streaming_channel_buffer
.unwrap_or(crate::streaming::DEFAULT_CHANNEL_BUFFER),
}
}
}
const RUNAWAY_THINKING_ERROR: &str = "aborted: model output contained only thinking with no text or tool calls \
after too many consecutive steps (runaway rumination)";
fn is_model_quality_error(message: &str) -> bool {
message.contains("model output")
}
#[derive(Default)]
struct StreamErrorState {
last_error: Option<String>,
last_http_code: u16,
output_after_error: bool,
consecutive_model_errors: u32,
consecutive_empty_steps: u32,
limits: StreamLimits,
}
impl StreamErrorState {
fn new(limits: StreamLimits) -> Self {
Self {
limits,
..Self::default()
}
}
fn observe(&mut self, content: &StepContent) -> bool {
match content {
StepContent::Error { message, http_code } => {
self.consecutive_empty_steps = 0;
if is_model_quality_error(message) {
self.consecutive_model_errors += 1;
} else {
self.consecutive_model_errors = 0;
}
self.last_error = Some(message.clone());
self.last_http_code = *http_code;
self.output_after_error = false;
self.limits.max_model_errors > 0
&& self.consecutive_model_errors >= self.limits.max_model_errors
}
StepContent::Output => {
self.consecutive_model_errors = 0;
self.consecutive_empty_steps = 0;
if self.last_error.is_some() {
self.output_after_error = true;
}
false
}
StepContent::Empty => {
self.consecutive_empty_steps += 1;
if self.limits.max_empty_steps > 0
&& self.consecutive_empty_steps >= self.limits.max_empty_steps
{
self.last_error = Some(RUNAWAY_THINKING_ERROR.to_string());
self.last_http_code = crate::error::HTTP_CODE_UNKNOWN;
self.output_after_error = false;
true
} else {
false
}
}
}
}
}
async fn forward_step_to_writer(
writer: &crate::streaming::ChatResponseWriter,
mut step: crate::types::Step,
agent_id: AgentId,
streamed_text: &mut String,
) -> StepContent {
let has_error_status = step.status == crate::types::StepStatus::Error;
let has_error_field = !step.error.is_empty();
if has_error_status || has_error_field {
let error_msg = format_error_message(&step);
let http_code = step.http_code;
let is_model_quality = is_model_quality_error(&error_msg);
tracing::warn!(
agent_id = ?agent_id,
http_code,
error = %error_msg,
"{}",
if is_model_quality {
"Model produced invalid output. Stream continues (backend will retry)"
} else {
"Error step received. Stream continues (backend controls iteration)"
}
);
crate::streaming::ChatResponseWriter::fan_out(
&writer.subs.step,
&writer.step_tx,
std::mem::take(&mut step),
"step",
)
.await;
return StepContent::Error {
message: error_msg,
http_code,
};
}
let step_idx = step.step_index;
let tool_names: Vec<String> = step.tool_calls.iter().map(|tc| tc.name.clone()).collect();
let usage_summary = step.usage_metadata.as_ref().map(|u| {
format!(
"{}p/{}o/{}t",
u.prompt_token_count.unwrap_or(0),
u.candidates_token_count.unwrap_or(0),
u.thoughts_token_count.unwrap_or(0),
)
});
let text_len = step.content.len() + step.content_delta.len();
let thinking_len = step.thinking.len() + step.thinking_delta.len();
let has_tool_calls = !step.tool_calls.is_empty();
let is_complete_response = step.is_complete_response == Some(true);
forward_text(writer, &mut step, streamed_text).await;
if is_complete_response {
streamed_text.clear();
}
forward_thoughts(writer, &mut step).await;
forward_tool_calls(writer, &mut step, agent_id).await;
apply_step_metadata(writer, &mut step);
crate::streaming::ChatResponseWriter::fan_out(&writer.subs.step, &writer.step_tx, step, "step")
.await;
if !tool_names.is_empty() {
tracing::info!(
agent_id = ?agent_id,
step = step_idx,
tools = ?tool_names,
usage = ?usage_summary,
"tool_call"
);
} else if text_len > 0 || thinking_len > 0 {
tracing::debug!(
agent_id = ?agent_id,
text_len,
thinking_len,
usage = ?usage_summary,
"model_output"
);
}
if text_len > 0 || has_tool_calls {
StepContent::Output
} else {
StepContent::Empty
}
}
fn format_error_message(step: &crate::types::Step) -> String {
if !step.error.is_empty() {
return step.error.clone();
}
let content = if step.content.is_empty() {
&step.content_delta
} else {
&step.content
};
format!("Step error (status={:?}): {content}", step.status)
}
async fn forward_text(
writer: &crate::streaming::ChatResponseWriter,
step: &mut crate::types::Step,
streamed_text: &mut String,
) {
let is_model = step.source == crate::types::StepSource::Model;
let is_target_user = step.target == crate::types::StepTarget::User;
if !(is_model && is_target_user) {
return;
}
let (raw, is_delta) = if step.content_delta.is_empty() {
(std::mem::take(&mut step.content), false)
} else {
(std::mem::take(&mut step.content_delta), true)
};
if raw.is_empty() {
return;
}
let Some(text) = dedup_model_text(raw, is_delta, streamed_text) else {
return;
};
crate::streaming::ChatResponseWriter::fan_out(
&writer.subs.event,
&writer.event_tx,
crate::streaming::ResponseEvent::TextChunk(text.clone()),
"event",
)
.await;
crate::streaming::ChatResponseWriter::fan_out(
&writer.subs.chunk,
&writer.chunk_tx,
crate::streaming::StreamChunk::Text(text.clone()),
"chunk",
)
.await;
crate::streaming::ChatResponseWriter::fan_out(&writer.subs.text, &writer.text_tx, text, "text")
.await;
}
fn dedup_model_text(raw: String, is_delta: bool, streamed: &mut String) -> Option<String> {
if is_delta {
streamed.push_str(&raw);
return Some(raw);
}
if streamed.is_empty() {
streamed.push_str(&raw);
return Some(raw);
}
if raw == *streamed {
return None;
}
if let Some(suffix) = raw.strip_prefix(streamed.as_str()) {
let suffix = suffix.to_owned();
streamed.push_str(&suffix);
return Some(suffix);
}
streamed.clear();
streamed.push_str(&raw);
Some(raw)
}
async fn forward_thoughts(
writer: &crate::streaming::ChatResponseWriter,
step: &mut crate::types::Step,
) {
let is_model = step.source == crate::types::StepSource::Model;
let is_target_user = step.target == crate::types::StepTarget::User;
if !(is_model && is_target_user) {
return;
}
let thinking = if step.thinking_delta.is_empty() {
std::mem::take(&mut step.thinking)
} else {
std::mem::take(&mut step.thinking_delta)
};
if thinking.is_empty() {
return;
}
crate::streaming::ChatResponseWriter::fan_out(
&writer.subs.event,
&writer.event_tx,
crate::streaming::ResponseEvent::ThoughtChunk(thinking.clone()),
"event",
)
.await;
crate::streaming::ChatResponseWriter::fan_out(
&writer.subs.chunk,
&writer.chunk_tx,
crate::streaming::StreamChunk::Thought(thinking.clone()),
"chunk",
)
.await;
crate::streaming::ChatResponseWriter::fan_out(
&writer.subs.thought,
&writer.thought_tx,
thinking,
"thought",
)
.await;
}
async fn forward_tool_calls(
writer: &crate::streaming::ChatResponseWriter,
step: &mut crate::types::Step,
agent_id: AgentId,
) {
for tc in std::mem::take(&mut step.tool_calls) {
tracing::debug!(
agent_id = ?agent_id,
tool = %tc.name,
"Streaming tool call event"
);
let event = crate::streaming::ToolCallEvent {
name: tc.name,
args: tc.args,
id: tc.id,
canonical_path: tc.canonical_path,
};
crate::streaming::ChatResponseWriter::fan_out(
&writer.subs.event,
&writer.event_tx,
crate::streaming::ResponseEvent::ToolCall(event.clone()),
"event",
)
.await;
crate::streaming::ChatResponseWriter::fan_out(
&writer.subs.chunk,
&writer.chunk_tx,
crate::streaming::StreamChunk::ToolCall(event.clone()),
"chunk",
)
.await;
crate::streaming::ChatResponseWriter::fan_out(
&writer.subs.tool_call,
&writer.tool_call_tx,
event,
"tool_call",
)
.await;
}
}
fn apply_step_metadata(
writer: &crate::streaming::ChatResponseWriter,
step: &mut crate::types::Step,
) {
if let Some(usage) = step.usage_metadata.take() {
writer.set_usage(usage);
}
if let Some(out) = step.structured_output.take() {
writer.set_structured_output(out);
}
}
enum StepIterationResult {
Step(Box<crate::types::Step>),
Stop,
Error(String),
}
fn classify_py_step_error(err: &pyo3::PyErr, agent_id: AgentId) -> StepIterationResult {
let is_stop =
Python::attach(|py| err.is_instance_of::<pyo3::exceptions::PyStopAsyncIteration>(py));
if is_stop {
tracing::debug!(agent_id = ?agent_id, "Step stream ended (StopAsyncIteration)");
return StepIterationResult::Stop;
}
let err_msg = Python::attach(|py| crate::error::classify_py_error(py, err).to_string());
tracing::error!(agent_id = ?agent_id, error = %err_msg, "Python step iteration failed");
StepIterationResult::Error(err_msg)
}
async fn process_next_step_iteration(
aiter_py: &Py<PyAny>,
agent_id: AgentId,
) -> StepIterationResult {
let next_fut = Python::attach(|py| -> PyResult<_> {
let aiter_bound = aiter_py.bind(py);
let coro = aiter_bound.call_method0("__anext__")?;
pyo3_async_runtimes::tokio::into_future(coro)
});
let next_fut = match next_fut {
Ok(fut) => fut,
Err(e) => return classify_py_step_error(&e, agent_id),
};
let step_py = match next_fut.await {
Ok(obj) => obj,
Err(e) => return classify_py_step_error(&e, agent_id),
};
Python::attach(|py| {
let step_bound = step_py.bind(py);
if step_bound.is_none() {
return StepIterationResult::Stop;
}
match super::py_scripts::to_dict_py(step_bound)
.and_then(|d| d.extract::<crate::types::Step>())
{
Ok(step) => StepIterationResult::Step(Box::new(step)),
Err(e) => {
let err_msg = format!("Failed to extract Step from Python object: {e}");
tracing::error!(agent_id = ?agent_id, "{err_msg}");
StepIterationResult::Error(err_msg)
}
}
})
}
pub async fn stream_steps_to_writer(
writer: &crate::streaming::ChatResponseWriter,
agent_id: AgentId,
aiter_py: &Py<PyAny>,
limits: StreamLimits,
) {
tracing::debug!(agent_id = ?agent_id, ?limits, "Starting step streaming");
let mut state = StreamErrorState::new(limits);
let mut streamed_text = String::new();
loop {
match process_next_step_iteration(aiter_py, agent_id).await {
StepIterationResult::Step(step) => {
let content =
forward_step_to_writer(writer, *step, agent_id, &mut streamed_text).await;
if state.observe(&content) {
tracing::warn!(
agent_id = ?agent_id,
consecutive_model_errors = state.consecutive_model_errors,
consecutive_empty_steps = state.consecutive_empty_steps,
"Stopping stream (repeated invalid output or runaway \
thinking-only rumination) — handing off to orchestrator recovery"
);
break;
}
}
StepIterationResult::Stop => break,
StepIterationResult::Error(err_msg) => {
send_stream_error(writer, err_msg, crate::error::HTTP_CODE_UNKNOWN);
return;
}
}
}
if let Some(error_msg) = state.last_error {
if state.output_after_error {
tracing::info!(
agent_id = ?agent_id,
error = %error_msg,
"Stream recovered after error — not propagating"
);
} else {
tracing::warn!(
agent_id = ?agent_id,
error = %error_msg,
"Stream ended with unrecovered error — propagating"
);
send_stream_error(writer, error_msg, state.last_http_code);
}
}
}
fn send_stream_error(
writer: &crate::streaming::ChatResponseWriter,
message: String,
http_code: u16,
) {
if let Err(e) = writer
.error_tx
.try_send(crate::streaming::StreamError::with_http_code(
message, http_code,
))
{
tracing::debug!("Error channel full or closed (first error wins): {e}");
}
}
#[cfg(test)]
#[path = "streaming_tests.rs"]
mod tests;