Repository navigation
v1.16.2 — ask_agentic loop streaming + stallMs heartbeat watchdog (T33 TRIM)
Changed — ask_agentic loop streaming + heartbeat-aware stallMs watchdog (T33 TRIM)
ask_agentic's main loop now uses generateContentStream + collectStream instead of non-streaming generateContent. Closes T33 — the last open item in the v1.6/v1.7 streaming refactor track. The post-loop forced-finalization rescue keeps the v1.15.0 P3 apiCallSucceeded non-streaming contract unchanged.
Why now: ask_agentic was the last tool in the codebase still using non-streaming dispatch. Operator consequences:
- No heartbeat-aware liveness watchdog. Only wall-clock
iterationTimeoutMswas available. A dead socket at second 5 of a 120s-budgeted iter wasted 115 seconds before the wall-clock fired.stallMs(the right knob perask/codev1.12.0 conventions) closes that gap. - No live thought-chunk progress visibility. During the 30-120s thinking window each iter, operators saw only iter-counter notifications between iters.
ask+codealready stream thought previews live; this brings parity. - UX inconsistency — every other long-running tool in the codebase streams.
ask_agenticwas the outlier.
What's new
stallMsper-call schema field (1s–10min, optional) — heartbeat-aware stall watchdog that resets on every chunk yielded bygenerateContentStream(text or thought). Fires ONLY when the stream goes silent for this long. Does NOT fire while the model is actively thinking. Documented as the right knob for "kill dead sockets quickly without penalising deep reasoning."GEMINI_CODE_CONTEXT_AGENTIC_STALL_MSenv var — fallback path identical toask/code(GEMINI_CODE_CONTEXT_ASK_STALL_MS,GEMINI_CODE_CONTEXT_CODE_STALL_MS). Recommended:60_000(60s).structuredContent.timeoutKindonerrorCode: 'TIMEOUT'—'stall' | 'total'discriminates which watchdog fired.timeoutMsreports the configured TOTAL wall-clock cap (ornullwhen disabled) — verbatim mirror ofask.tool.ts:1132; the newstallMssibling field reports the configured stall watchdog (ornullwhen disabled). Wrappers can apply different retry policies based ontimeoutKind—'stall'is usually safe to retry (dead socket);'total'means raiseiterationTimeoutMsor narrow the prompt.- Live
thinking: …progress events — same UX as ask/code, throttled to ~1.5s bycollectStreamso the MCP host's progress channel doesn't get flooded on long thinking bursts.
What's preserved verbatim (TRIM scope)
The v1.6/v1.7 plan recommended converting BOTH the loop AND the rescue to streaming. After cross-reviewer consultation (gcc-ask: GO full scope; grok: TRIM), TRIM was adopted:
- Rescue keeps
generateContent(not streaming) — see the dispatch site atsrc/tools/ask-agentic.tool.ts(search for the rescue's// Each agentic iteration is its owncomment). The v1.15.0 P3+P4+P5apiCallSucceededcontract — Google billed → finalize budget; not billed → cancel — was stabilized through 4 review rounds +/6stepcross-corroboration. Streaming the rescue would re-open that recently-stabilized surface for marginal liveness gain on a once-per-call path. - Mid-stream failures cannot be retried (Gemini's
generateContentStreamhas no resume) — the v1.6/v1.7 plan §11 explicitly cites this. The rescue's transient-failure budget routing depends on knowing whether the API was billed; partial-stream-then-fail blurs that signal. The loop tolerates a discarded mid-stream failure (next iteration retries the question); the rescue is the last shot before failing the whole call. - Content-aware
signatureCountsdedupe (v1.16.0) unchanged —collectStreamreassembles into the sameGenerateContentResponseshape;iterResult.signaturesextraction is identical. withNetworkRetryouter wrap unchanged — wraps stream OPENING only, mirroring ask/code.iterDispatchedflag (v1.14.x H2) unchanged — set immediately beforegenerateContentStream(which IS the dispatch).createTimeoutControllerrescue site explicitly keepsstallEnvVar: ''— proves rescue is non-streaming and stall is intentionally disabled there.
Backward compatibility
- No breaking change.
stallMsis an optional new schema field; existing callers that don't set it see identical behavior to v1.16.1. structuredContentshape:timeoutMscontinues to report the configured TOTAL wall-clock cap (ornullwhen disabled) — verbatim mirror ofask.tool.ts:1132. New sibling fieldstallMsreports the configured stall watchdog (ornullwhen disabled). The discriminator for which one fired is the newtimeoutKind: 'stall' | 'total'field. Wrappers that branched ontimeoutMs > Xpre-v1.16.2 see a stable contract — onlynull(disabled) is a new case, and existing branches handle it correctly becausenull > Xis alwaysfalse.- Rescue path completely unchanged. Operators who hit the rescue see byte-identical behavior to v1.16.1 (
convergenceForced,rescueErrorCode, etc.).
PR-Round-1 review folds (gemini Finding #1 + #2)
Round-1 cross-review (gemini-code-context + grok + codex) flagged two empirically verifiable issues. Both folded same PR per workflow:
- Finding #1 (HIGH) —
collectStreamcandidate-preservation defensive gap (src/tools/shared/stream-collector.ts:174-176). Pre-fix gate waschunk.candidates.length > 0(outer-array length only). If Gemini's stream protocol ever emits a final terminator shape like{ candidates: [{ content: { parts: [] }, finishReason: 'STOP' }] }, the previous chunk'sfunctionCallPart would be silently overwritten — the agentic loop would treat the iteration as final-text and never dispatch the tool. Fixed by adding aparts.length > 0gate on the inner array. Empirical current-protocol behaviour (functionCall + finishReason packed into one chunk) is preserved either way; this hardens against fragmentation patterns that future Gemini SDK / thinking-mode variants could legitimately introduce. Side-effect onask/code:lastCandidates.finishReasonmay now come from a non-final chunk if the final chunk has no parts — observably equivalent for both, since they read accumulatedresponse.text, notcandidates[0].finishReason. - Finding #2 (MEDIUM) —
timeoutMspayload contract divergence from ask/code (src/tools/ask-agentic.tool.ts). Initial commit collapsedtimeoutMsto "the limit that fired" (stall value when stall fired).ask.tool.ts:1132always reports the configured total wall-clock cap regardless of which watchdog fired — the discriminator istimeoutKind. Reverted to ask/code parity:timeoutMs= configured total (or null),stallMs= configured stall (or null),timeoutKind= which one fired.
Two new regression tests pin the fixes:
test/unit/stream-collector.test.ts—does not overwrite with terminator-only candidates that have empty parts (v1.16.2 fragmentation guard)— exercises the multi-chunk fragmentation pattern at the collector level.test/unit/ask-agentic.test.ts—dispatches functionCall correctly when stream fragments fc into one chunk and terminator into the next (v1.16.2 PR-Round-1)— exercises the same pattern at the agentic-loop level, asserting thatread_fileactually dispatches and the loop reaches organic final-text.
Coverage
764 passed | 9 skipped (759 → 762 in initial commit; 762 → 764 after Round-1 folds: added 2 regression tests for the fragmentation guard + adjusted 2 stallMs tests for the corrected timeoutMs semantics). All v1.15.0 rescue tests + v1.16.0 content-aware NO_PROGRESS tests still green — proves the TRIM scope-exclusion held.
Notes
- The v1.6/v1.7 streaming refactor track is now FULLY SHIPPED across v1.6.0 (T19 timeout + AbortController), v1.7.0 (T20 ask/code streaming + T18 budget + D#7 status UX), and v1.16.2 (T33 ask_agentic loop streaming).
- T34+ items in
docs/FOLLOW-UP-PRS.mdare smaller / unrelated.