Repository navigation
agent_scan: timeout_ms on SSE responses is enforced as inter-chunk idle timeout, killing thinking agents mid-reply #707
Description
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(...)(orhttpx.Client(timeout=httpx.Timeout(...))with a distinct read budget) fortext/event-streamresponses, 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_msas the read timeout at all — keep a conservative default for the idle gap and treattimeout_msas 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.Clientand pre-populateresponse.text, so they do not exercise this path — the regression test should use a real local server (or a scripted httpx transport).
Activity
fix(agent-scan): stream SSE responses and stop at [DONE] instead of w…