fix(agent): stream LLM completions to avoid gateway idle-timeout - #236
fix(agent): stream LLM completions to avoid gateway idle-timeout#236sebastianbraun25 wants to merge 8 commits into
Conversation
Corporate LLM gateways (e.g. AI.proxy on AWS) enforce an idle timeout on buffered (non-streaming) requests, so a long-running compile step can hit a Gateway Timeout even though the provider would have eventually finished. Switch _llm_call() and _llm_call_async() in openkb/agent/compiler.py to litellm.completion()/acompletion() with stream=True: streaming keeps bytes flowing over the connection, so idle-timeout gateways never see a silent connection. Chunks are merged back into the existing response shape via a new _merge_stream_chunks() helper, using LiteLLM's own litellm.stream_chunk_builder() for genuine multi-chunk streams. An exception raised mid-stream propagates as a complete failure (list() never returns a partial buffer), matching prior all-or-nothing behavior. Adapts the compiler test mocks (_mock_completion/_mock_acompletion and a handful of inline mocks) to return a single-chunk fake stream, plus the litellm.completion/acompletion mocks in test_llm_timeout.py. Resolves VectifyAI#235. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add per-chunk debug logging around streamed LiteLLM completion consumption in `openkb.agent.compiler`. This diagnostic instrumentation is enabled via the existing `openkb -v` flag and helps narrow the still-observed ~60s production cutoff to either a no-first-byte case (for example slow time-to-first-token) or a proxy/gateway path that buffers or drops streamed bytes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Per-chunk DEBUG logging (_log_chunk_timing) drowned out the rest of a log on a long response — hundreds of LLM stream chunk [...] #N lines for a single LLM call, e.g. one concepts-plan request logging 205 individual chunk lines. Replaces it with exactly two log lines per LLM call: - _log_stream_start: logged once, when the first chunk arrives (time-to-first-token). - _log_stream_end: logged once, when the stream finishes cleanly (total chunk count + elapsed time for the last chunk). - _log_stream_interrupted: logged once instead of _log_stream_end if the stream raises mid-iteration — reports how many chunks were successfully received and when, right before the exception is re-raised (still a complete failure, no partial buffer). Special-cases zero chunks (failure before any byte arrived) with dedicated wording instead of an inapplicable chunk number. Updated tests/test_compiler.py::TestLLMStreamTimingDebugLogging to match: asserts exactly one start + one end/interrupted line, and the explicit absence of the old per-chunk lines.
_llm_call/_llm_call_async now wrap the stream fetch/consume/merge step in a fixed 3-attempt loop. A dropped connection or other transient error during the now-streamed completion (VectifyAI#236) previously failed the whole concept/entity generation immediately; it now gets 2 automatic retries before giving up. Not a tunable knob, just a resilience floor sitting below the end-of-batch sweep retry added for insert_mode (VectifyAI#241).
_llm_call/_llm_call_async no longer waste their 3-attempt retry floor on litellm.ContextWindowExceededError, since an identical retry is guaranteed to fail again. compile_short_doc additionally preflights the summary prompt's token count (when the model is recognized by litellm) and, on either that check or a ContextWindowExceededError from the summary call itself, skips LLM ingestion for the document entirely: the raw source stays in the KB as a plain reference (like an unreadable image would), with a stub summary noting why, instead of aborting the add or discarding the file.
_write_unprocessable_stub() previously only wrote the stub summary page, never calling _update_index() (that only happened inside _compile_concepts(), which the oversized-doc path deliberately skips). The stub page was therefore an undiscoverable orphan: missing from index.md's ## Documents section, which openkb lint's check_index_sync() flags as an index-sync error. Now calls _update_index(wiki_dir, doc_name, [], doc_brief=description) too, same as every other short-doc path.
…ding the summary The existing concept/entity index (_read_concept_briefs/_read_entity_briefs) grows unbounded with the KB and is appended on top of the already-cached document for the concepts-plan call -- a document whose summary call fit comfortably can still blow the context window once that index gets large enough. Previously this exception propagated uncaught out of _compile_concepts(), through compile_short_doc(), retried the whole doc (including a wasted repeat summary call) via cli.py's outer retry, and ultimately failed the add. _compile_concepts() now catches _NON_RETRYABLE_LLM_ERRORS around the concepts-plan call. Since the summary already succeeded by this point (unlike the doc-too-large case in compile_short_doc's own preflight/summary-call catch), it reuses the same fallback already used for an unparseable/empty plan: write the real v1 summary (ghost-wikilink-stripped) and the normal index.md entry, just skipping concept/entity generation for this doc -- instead of discarding a summary that was already successfully generated. Applies to compile_long_doc() too, since it shares _compile_concepts().
|
Follow-up note (scope clarification): the "oversized document" handling added on this branch ( For short/plain-text documents there is currently no PageIndex-style chunked ingestion path (unlike long PDFs via
This is a deliberate, accepted trade-off for now (simplicity + no dropped documents), not a design goal — filing this as a known limitation here rather than a new issue, since it's directly adjacent to the context-window handling already introduced on this branch. |
Note
This PR was created in collaboration between a human and AI: implementation, tests, and
PR text were created by an AI assistant under the guidance and review of the human author.
Problem
openkb's wiki compiler (openkb/agent/compiler.py) callslitellm.completion()/litellm.acompletion()in buffered (non-streaming) mode. On a long-running compilestep (large documents, many concepts, verbose models), some corporate LLM gateways
sitting in front of the actual provider enforce an idle timeout on buffered
requests: if no bytes are sent back to the client for N seconds while the gateway
waits for the upstream provider to finish generating the full response, the
gateway kills the connection with a Gateway Timeout, even though the underlying
provider call would have eventually succeeded.
This was observed against a corporate
AI.proxygateway (OpenAI-compatible,deployed at AWS): any step whose completion took longer than the gateway's idle
window failed with a timeout purely because of buffering, not because the model
was slow to start responding.
Root Cause
_llm_call()and_llm_call_async()inopenkb/agent/compiler.pycalllitellm.completion()/litellm.acompletion()withoutstream=True. A bufferedrequest produces zero bytes on the wire until the entire response is ready, so an
idle-timeout gateway watching for connection activity can't distinguish "still
generating" from "connection died" and kills it.
Solution / Changes
openkb/agent/compiler.py:_llm_call()and_llm_call_async()now calllitellm.completion()/litellm.acompletion()withstream=True, so bytes keep flowing over theconnection as tokens arrive.
_merge_stream_chunks()helper merges the streamed chunks back into thesame non-streaming response shape the rest of the compiler already expects
(
response.choices[0].message.content,response.usage,response.choices[0].finish_reason), using LiteLLM's ownlitellm.stream_chunk_builder()for genuine multi-chunk streams.complete failure —
list(stream)never returns a partial buffer, matchingthe prior all-or-nothing behavior of a failed
litellm.completion()call.stream=True/Falseconfig toggle.
tests/test_compiler.py:_mock_completion()/_mock_acompletion()(and thehandful of inline mocks that built their own fake response) now return a
single-chunk fake stream (
[mock_resp]) instead of a bare response object,matching the new
stream=Truecall signature.tests/test_llm_timeout.py: same adjustment for itslitellm.completion/acompletionmocks.Backward compatible: no config/CLI surface changes, callers of
_llm_call/_llm_call_asyncsee the same return type and response shape as before.Update: skip non-retryable errors and oversized documents instead of wasting retries
The fixed 3-attempt retry floor this PR introduces in
_llm_call/_llm_call_asyncis a good resilience net for transient stream/connection errors, but it blindly
retried every exception — including
litellm.ContextWindowExceededError, whichmeans the prompt itself exceeds the model's context window. Retrying an identical
request in that case is guaranteed to fail again; it only wastes attempts (and,
for a stream, the connection time to discover the failure again).
Observed in practice: converting a large spreadsheet/text attachment to Markdown
produced a ~570k-token prompt against a 200k-token model context window, and the
short-doc compile path retried it 3 times per attempt across both retry layers
(6 doomed attempts total) before giving up and rolling back the whole
addforthat file.
Changes:
_llm_call()/_llm_call_async(): a new_NON_RETRYABLE_LLM_ERRORStuple(currently just
litellm.ContextWindowExceededError) is checked in the retryloop — a matching exception raises immediately instead of retrying.
compile_short_doc(): preflights the summary prompt's token count vialitellm.token_counter()against the model's context window (via a new_max_input_tokens()helper, itself a best-effortlitellm.get_model_info()lookup that returns
None— and disables the check — for a model litellmdoesn't recognize). A prompt that's already known to exceed the window skips
the LLM call entirely.
model) or its token estimate came in under the real one, the summary call
itself is now wrapped to catch
_NON_RETRYABLE_LLM_ERRORS._write_unprocessable_stub()helper writes a plainplaceholder summary page (reusing
_write_summary()) noting that the documentwas too large for the model's context window. The raw source stays in the
knowledge base as a plain reference — no concept/entity extraction is
attempted for it — mirroring how an unreadable/undecodable image is handled,
rather than aborting the
addfor that file or discarding it.tests/test_compiler.py: newTestNonRetryableLLMErrors(no-retryclassification for both call sites),
TestMaxInputTokens(known vs.unrecognized model), and
TestOversizedDocumentSkip(preflight skip and theContextWindowExceededErrorsafety-net fallback, both writing the stubsummary and producing no concept pages).
Update: fix the stub summary being an undiscoverable orphan
_write_unprocessable_stub()only wrote the stub summary page itself —_update_index()(which normally appends the## Documentsentry inindex.md) is only called from inside_compile_concepts(), which this pathdeliberately skips. The stub page therefore had no
index.mdentry: it wastechnically on disk, but undiscoverable through the wiki's own navigation, and
openkb lint'scheck_index_sync()would flag it as a sync error(
summaries/<doc>.md not mentioned in index.md)._write_unprocessable_stub()now also calls_update_index(wiki_dir, doc_name, [], doc_brief=description)— same as every other short-doc path, just with anempty concept list.
tests/test_compiler.py'sTestOversizedDocumentSkipcases now also assert the
index.mdentry is present.Update: also handle a context-window overflow in the concepts-plan step
The doc-too-large handling above only guards the
summarycall. Separately, adocument can still overflow the context window one step later, at
concepts-plan— even when the document itself fit comfortably:_read_concept_briefs()/_read_entity_briefs()read every concept/entitypage in the wiki with no cap, and that full index is appended on top of the
already-cached document for the
concepts-plancall. As a KB accumulates moreconcepts/entities over time, this index grows without bound, so the same doc
that summarized fine can push
concepts-planover the limit purely becausethe KB has grown — independent of the document's own size.
Observed in practice: a document summarized successfully at 128,692 tokens,
then failed
concepts-planat 220,670 tokens once the existing concept/entityindex was added on top — and (before this fix) the resulting
ContextWindowExceededErrorpropagated uncaught out of_compile_concepts(),through
compile_short_doc(), and was retried wastefully bycli.py's outerretry (including re-running the summary call) before ultimately failing the
whole
addfor that file.Unlike the doc-too-large case, the summary already exists by the time
concepts-planruns, so discarding it for a generic "too large" stub would bewasteful.
_compile_concepts()now catches_NON_RETRYABLE_LLM_ERRORSaroundthe
concepts-plancall and reuses the same fallback already used for anunparseable/empty plan: write the real v1 summary (ghost-wikilink-stripped)
and the normal
index.mdentry, just skipping concept/entity generation forthis document. Applies to
compile_long_doc()too, since it shares_compile_concepts().Note: this only fixes the crash/retry-waste for this specific step. The
underlying scaling problem — an unbounded concept/entity index injected in
full into every
concepts-plancall — remains and will resurface for largerKBs; a follow-up (e.g. bounding/retrieving only the relevant subset of the
index instead of dumping all of it) is tracked separately.
tests/test_compiler.py: newtest_concepts_plan_context_window_exceeded_keeps_real_summaryverifies the real summary (not a stub) is kept, ghost-stripped, indexed, and
that no concept pages are produced, with no wasted retries on the doomed
concepts-planrequest.Issues
Resolves #235.