Repository navigation
feat(combo): add agentic-stall response classifier - #13954
patrykkopycinski wants to merge 5 commits into
Conversation
Adds a standalone, side-effect-free classifier that detects when a model
answers a tool-result turn with prose instead of the tool call the loop is
waiting for — the "agentic stall" that leaves an agent hanging until timeout.
This commit is the classifier and its tests ONLY. It is not wired into any
dispatch path here: the wiring in services/combo.ts depends on refactors that
do not exist on this branch, so porting it would mean rewriting unrelated code.
Landing the pure logic first keeps this reviewable and lets wiring follow in a
separate PR.
Design notes:
- Pure functions, zero imports; never mutates or consumes the caller's
Response (clone-only contract, covered by a test).
- Returns null on anything it cannot confidently parse, so an unknown
content-type can never break the request path.
- Kill switch: OMNIROUTE_AGENTIC_STALL_FAILOVER=0 disables classification.
Two exemptions prevent false positives observed in production:
- Intentional silence: an exact "[SILENT]" (or blocked-state) reply is a
valid answer, not a stall.
- Instructed terminal echo: when the request itself instructs the model to
reply with a specific short literal, echoing it verbatim is compliance.
Matching is verbatim and length-capped, so a drifted paraphrase still
classifies as a stall.
Tests: 64 cases over both providers x JSON/SSE, both exemptions, the kill
switch, the null-safety path and the no-consume contract.
The previous run failed at `npm run check:node-runtime` (ERR_MODULE_NOT_FOUND: tsx) even though the install step reported success, and every downstream job was skipped as a result. No other open PR fails that step. Re-running to distinguish a transient install/cache failure from a real one.
|
CI attribution — all 22 failures are pre-existing on the PR base, not this diff. This PR touches 2 files ( Root cause of the 15
Why The retry loop's last line is Attribution — reproduced on the untouched base commit:
The failure is npm-major-specific (Node 24 ships npm 11; this machine has npm 10.9.8). Drift is in Already being fixed upstream: #13865 ( Other failures, also not this diff: What this PR does verify locally, on the same base commit: the classifier suite passes through the repo's own gate, |
|
Hi @patrykkopycinski — closing this pull request to clear the open queue. The branch |
|
Hi @patrykkopycinski — reopening this pull request. The close was a mistake, and it is back in the merge queue. The branch |
|
Re-homed to |
…s env var OMNIROUTE_AGENTIC_STALL_FAILOVER now defaults OFF (enabled only by 1/true), matching the project rule that new behavior-changing flags are opt-in. The variable is documented in .env.example and docs/reference/ENVIRONMENT.md so check:env-doc-sync passes, and a changelog fragment is added. Tests: the classify cases enable the flag explicitly; new cases prove the default-OFF and 0/false/empty/1/true behavior (red before the change).
Resolves the .env.example and docs/reference/ENVIRONMENT.md conflicts by keeping both sides: the OMNIROUTE_AGENTIC_STALL_FAILOVER entry plus every variable the release added. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
…omplexity ratchet quotedTerminalLiterals, extractFromSse and classifyAgenticStallResponse exceeded the complexity/cognitive-complexity threshold (15) of eslint.complexity-ratchets.config.mjs. Extract message-part text and quoted-literal collection, per-line SSE parsing with OpenAI/Anthropic folding helpers (Anthropic events via a per-type handler map), and response-signal reading plus the stop/no-tool-call checks. Behaviour is unchanged; the existing 68-test suite covers the JSON and SSE paths. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Summary
Adds a standalone agentic-stall response classifier for the combo failover path.
Some upstream providers answer a mid-agentic-turn request —
toolspresent, conversation tail a tool result — withfinish_reason: stopand a progress report as content, and zero tool calls. Agent harnesses readstop+ no tool call as turn-over, so the client re-prompts, the model reports progress again, and the loop never converges. Every existing quality gate passes the response: it is a valid, non-empty completion.This PR lands the classification layer only: pure functions that decide whether a 200 response is that stall shape. It adds no imports, no call sites, and changes no existing behaviour.
Related Issues
<summary>envelopes as final content withfinish_reason=stop, breaking agent tool loops)tool_callscompletion — same "post-tool-call turn is not usable" class)I did not claim
Closes #13691: this PR classifies the stall but does not yet act on it. The classification has to land before a retry/failover caller can consume it. See Reviewer Notes.Validation
bash scripts/quality/test-scoped.sh→ 66/66 passnpm run lint— cannot run in my environment: ESLint 10.9.1 crashes loadingreact/display-name(contextOrFilename.getFilename is not a function) on any file, including pristineorigin/mainfiles. Repo/environment debt, not this diff.prettier --checkpasses.origin/main@a3ca33fa6, which was still the tip after fetchClassifier
toolspresent and conversation tail is a tool result (OpenAIrole: "tool"; Anthropic last user message carriestool_result)stop/end_turnand contains no tool calls<summary>…,# Summary,Summary: …) or short structureless prose reporting progressThree terminal protocols are exempt because their response is turn-over by design:
Blocked: … still OPEN,Cannot proceed …),[SILENT],NO_REPLY, and the CJK equivalents) — an autonomous lane's "nothing to report" reply,Exemption 3 is deliberately narrow: verbatim, case-insensitive, whole-response match against a literal present in the request, under a length cap. A model drifting into a summary cannot satisfy it, because summarization text is not quoted in the brief. If the brief contains no literal, the function returns
falseand the normal path is unchanged.Tests Added Or Updated
tests/unit/agentic-stall.test.ts— new, 66 testsCovers: the request-shape gate in both API shapes, the narration heuristic, full classification across OpenAI/Anthropic × JSON/SSE, the positive controls (no tools pending / a real tool call / structured output must not classify), the kill switch, the clone-only contract on the response body, and the three exemption classes including their negative controls.
Coverage Notes
open-sse/services/combo/agenticStall.tsis covered by the new unit suite; there is no uncovered branch reachable from the exported API.The cap is enforced at three points (the extraction filter, the whole-response check, and the extraction regex). Disabling any single layer is an equivalent mutant — the others still enforce it — which is intentional defence in depth; removing all three is what the tests catch.
Reviewer Notes
open-sse/services/combo/combo.tsis where the failover decision belongs, and that file has diverged substantially on active integration branches. Landing the classifier first keeps this change reviewable in isolation and avoids a large conflict-prone diff. A follow-up PR can wire it into the combo loop once a call site is agreed.<summary>again. Failover to the next combo member is the remedy shape; retrying the same member is not.SILENCE_MARKERS— if that list should be narrower or wider for OmniRoute, say so and I will adjust.