Skip to content

agent_scan: timeout_ms on SSE responses is enforced as inter-chunk idle timeout, killing thinking agents mid-reply #707

Closed

Description

@wjl8636

Summary

PR #617 makes HTTP targets honor the configured timeout_ms, but it passes that value to a non-streaming httpx.Client(timeout=...) request. For text/event-stream (SSE) responses the value is then enforced as the idle gap between socket reads, not as the intended overall conversation budget — so a thinking agent whose SSE stream pauses longer than timeout_ms between chunks is killed mid-conversation and its whole reply is lost.

This is a real, reproducible semantic mismatch that will surface the moment #617 lands (today timeout_ms is only honored on the WebSocket path; the HTTP/SSE path still uses the fixed default self.timeout, so the bug is latent).

Reproduction (offline, no external API)

Run a local HTTP server that emits SSE with a pause longer than the configured timeout between two chunks, then call AIProviderClient._make_http_request with timeout_ms set:

# server: sends one data chunk, sleeps 3s, sends the rest
# client:
client = AIProviderClient(timeout=30)          # adapter default
config = ProviderConfig(url=..., timeout_ms=2000)  # user wants a 2s overall budget
result = client._make_http_request(..., timeout_seconds=client._get_timeout_seconds(config))

Observed (verified with httpx 0.28.1, proxy disabled):

SSE stream shape timeout given to httpx.Client Result
short, immediate 2s full body returned
chunks every 0.5s, total 2s 2s full body returned (2.1s)
2s thinking gap between chunks 2s httpx.TimeoutException at 2.0s → whole reply lost

So timeout_ms ends up meaning "no two chunks may be more than N ms apart" instead of "the whole conversation may take up to N ms".

Why it matters

Agent conversation streams routinely include long thinking pauses (reasoning models think for 10–60s before the first content token). A user who configures timeout_ms: 30000 to allow a 30s reply will instead get every response killed the moment the model pauses 30s between chunks — a silent, complete loss of output, exactly the failure the option exists to avoid.

Suggested fix

Read the SSE body streamingly and bound the idle time separately from the overall budget, e.g.:

  • Use client.stream(...) (or httpx.Client(timeout=httpx.Timeout(...)) with a distinct read budget) for text/event-stream responses, accumulating chunks and feeding the assembled text to _parse_sse_response, so a long pause is only fatal when it exceeds the configured idle allowance, not the whole-stream duration.
  • Alternatively, when the response is SSE, do not apply timeout_ms as the read timeout at all — keep a conservative default for the idle gap and treat timeout_ms as advisory for the total only if streamed with an overall deadline.

The fix belongs in _make_http_request; _parse_sse_response itself is correct and stays unchanged. A regression test can drive a local text/event-stream response with a > timeout gap and assert the reply is preserved (and separately that a truly stalled stream still times out).

Notes

  • The non-streaming request() does receive the full SSE body when chunk gaps stay under the timeout; the problem is specifically the semantics of the timeout value against streaming gaps.
  • Existing tests mock httpx.Client and pre-populate response.text, so they do not exercise this path — the regression test should use a real local server (or a scripted httpx transport).

Activity

  1. added a commit that references this issue on Oct 15, 2026
    1e2c69d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions