BUG-HUNT: [error-handling] stream_changes re-raises exceptions without yielding __end__ event — inconsistent protocol vs generate_changes #6512

Open
opened 2026-04-09 21:13:33 +00:00 by HAL9000 · 0 comments
Owner

Bug Report: [error-handling] — stream_changes re-raises exceptions without yielding __end__ event

Severity Assessment

  • Impact: Consumers of stream_changes receive a raw Python exception instead of a structured error response, crashing any code that expects the __end__ event protocol. Token/cost usage is also never logged on error paths.
  • Likelihood: High — any transient network failure, rate-limit error, or LangGraph error during streaming will trigger this path.
  • Priority: High

Location

  • File: src/cleveragents/providers/llm/langchain_chat_provider.py
  • Function/Class: LangChainChatProvider.stream_changes → inner _stream() generator
  • Lines: ~170–215 (the _stream inner function)

Description

The _stream() inner generator in stream_changes re-raises all exceptions from the LangGraph workflow without yielding the terminal __end__ event. This creates a broken consumer protocol: callers that iterate stream_changes must handle both normal termination (via __end__) and Python exceptions.

This is directly inconsistent with generate_changes, which always returns a ProviderResponse (with error_message set and changes=[]) and never raises to the caller.

Evidence

# langchain_chat_provider.py — _stream() inner generator

def _stream() -> Iterator[dict[str, object]]:
    usage_tracker: Any | None = None
    state: dict[str, Any] = {
        "generated_changes": [],
        "validation_result": {},
        "error": None,
    }
    try:
        with self._usage_tracker(llm) as tracker:
            usage_tracker = cast(Any | None, tracker)
            if progress_callback:
                progress_callback(5)
            for event in graph.stream(
                project, plan, contexts,
                thread_id=thread_id,
                actor_context=actor_context,
            ):
                node_name, payload = self._extract_event(event)
                if isinstance(payload, dict):
                    state.update(cast(dict[str, Any], payload))
                if node_name in {"__start__", "__end__"}:
                    continue
                if progress_callback:
                    self._emit_progress(node_name, progress_callback)
                yield {node_name: payload or {}}
        if progress_callback:
            progress_callback(100)
    except Exception:               # ← catches the error...
        if progress_callback:
            progress_callback(100)
        raise                        # ← ...but re-raises it!
        # ↑ Everything below this line is UNREACHABLE on exception:

    token_count = self._resolve_token_count(llm, plan, contexts, usage_tracker)
    cost = self._resolve_token_cost(usage_tracker)
    self._log_usage(model=self._model_id, tokens=token_count, cost=cost)
    response = self._build_response(state, token_count)
    yield {"__end__": {"response": response}}   # ← NEVER reached on exception

Contrast with generate_changes which always returns (never raises):

except Exception as exc:  # pragma: no cover - defensive path
    ...
    return ProviderResponse(
        changes=[],
        model_used=self._model_id,
        token_count=token_count,
        error_message=str(exc),     # ← error captured in response
    )

Expected Behavior

When an exception occurs during streaming, stream_changes should yield a final {"__end__": {"response": ProviderResponse(changes=[], error_message=str(exc))}} event so that consumers receive a consistent protocol regardless of whether an error occurred. Token/cost should also be logged on the error path.

Actual Behavior

The exception propagates to the consumer's iteration site. The __end__ event is never yielded. No token/cost logging occurs. Consumers expecting the __end__ event will crash or hang.

Additionally, the non-streaming fallback path (_fallback()) correctly uses generate_changes (which absorbs errors) and always yields __end__. This means the two code paths have fundamentally different error contracts.

Suggested Fix

Replace raise in the except Exception block with logic to build and yield an error __end__ event:

except Exception as exc:
    if progress_callback:
        progress_callback(100)
    token_count = self._resolve_token_count(llm, plan, contexts, usage_tracker)
    self._log_usage(model=self._model_id, tokens=token_count, cost=None)
    error_response = ProviderResponse(
        changes=[],
        model_used=self._model_id,
        token_count=token_count,
        error_message=str(exc),
    )
    yield {"__end__": {"response": error_response}}
    return

Category

error-handling

TDD Note

After this bug issue is verified, a corresponding Type/Testing issue will be created for TDD. The test will use tags: @tdd_issue, @tdd_issue_, and @tdd_expected_fail to prove the bug exists before fixing it.


Automated by CleverAgents Bot
Supervisor: Bug Hunting | Agent: bug-hunter

## Bug Report: [error-handling] — `stream_changes` re-raises exceptions without yielding `__end__` event ### Severity Assessment - **Impact**: Consumers of `stream_changes` receive a raw Python exception instead of a structured error response, crashing any code that expects the `__end__` event protocol. Token/cost usage is also never logged on error paths. - **Likelihood**: High — any transient network failure, rate-limit error, or LangGraph error during streaming will trigger this path. - **Priority**: High ### Location - **File**: `src/cleveragents/providers/llm/langchain_chat_provider.py` - **Function/Class**: `LangChainChatProvider.stream_changes` → inner `_stream()` generator - **Lines**: ~170–215 (the `_stream` inner function) ### Description The `_stream()` inner generator in `stream_changes` re-raises all exceptions from the LangGraph workflow without yielding the terminal `__end__` event. This creates a broken consumer protocol: callers that iterate `stream_changes` must handle both normal termination (via `__end__`) **and** Python exceptions. This is directly inconsistent with `generate_changes`, which always returns a `ProviderResponse` (with `error_message` set and `changes=[]`) and never raises to the caller. ### Evidence ```python # langchain_chat_provider.py — _stream() inner generator def _stream() -> Iterator[dict[str, object]]: usage_tracker: Any | None = None state: dict[str, Any] = { "generated_changes": [], "validation_result": {}, "error": None, } try: with self._usage_tracker(llm) as tracker: usage_tracker = cast(Any | None, tracker) if progress_callback: progress_callback(5) for event in graph.stream( project, plan, contexts, thread_id=thread_id, actor_context=actor_context, ): node_name, payload = self._extract_event(event) if isinstance(payload, dict): state.update(cast(dict[str, Any], payload)) if node_name in {"__start__", "__end__"}: continue if progress_callback: self._emit_progress(node_name, progress_callback) yield {node_name: payload or {}} if progress_callback: progress_callback(100) except Exception: # ← catches the error... if progress_callback: progress_callback(100) raise # ← ...but re-raises it! # ↑ Everything below this line is UNREACHABLE on exception: token_count = self._resolve_token_count(llm, plan, contexts, usage_tracker) cost = self._resolve_token_cost(usage_tracker) self._log_usage(model=self._model_id, tokens=token_count, cost=cost) response = self._build_response(state, token_count) yield {"__end__": {"response": response}} # ← NEVER reached on exception ``` Contrast with `generate_changes` which always returns (never raises): ```python except Exception as exc: # pragma: no cover - defensive path ... return ProviderResponse( changes=[], model_used=self._model_id, token_count=token_count, error_message=str(exc), # ← error captured in response ) ``` ### Expected Behavior When an exception occurs during streaming, `stream_changes` should yield a final `{"__end__": {"response": ProviderResponse(changes=[], error_message=str(exc))}}` event so that consumers receive a consistent protocol regardless of whether an error occurred. Token/cost should also be logged on the error path. ### Actual Behavior The exception propagates to the consumer's iteration site. The `__end__` event is never yielded. No token/cost logging occurs. Consumers expecting the `__end__` event will crash or hang. Additionally, the non-streaming fallback path (`_fallback()`) correctly uses `generate_changes` (which absorbs errors) and always yields `__end__`. This means the two code paths have fundamentally different error contracts. ### Suggested Fix Replace `raise` in the `except Exception` block with logic to build and yield an error `__end__` event: ```python except Exception as exc: if progress_callback: progress_callback(100) token_count = self._resolve_token_count(llm, plan, contexts, usage_tracker) self._log_usage(model=self._model_id, tokens=token_count, cost=None) error_response = ProviderResponse( changes=[], model_used=self._model_id, token_count=token_count, error_message=str(exc), ) yield {"__end__": {"response": error_response}} return ``` ### Category error-handling ### TDD Note After this bug issue is verified, a corresponding Type/Testing issue will be created for TDD. The test will use tags: @tdd_issue, @tdd_issue_<this-issue-number>, and @tdd_expected_fail to prove the bug exists before fixing it. --- **Automated by CleverAgents Bot** Supervisor: Bug Hunting | Agent: bug-hunter
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
cleveragents/cleveragents-core#6512
No description provided.