Repository navigation
feat(agent): callModel streamTimeout, armed per model request (DEV-723 2/3) - #137
Draft
LukasParke wants to merge 2 commits into
Draft
LukasParke wants to merge 2 commits into
LukasParke wants to merge 2 commits into
Conversation
This was referenced Oct 21, 2026
Port of OpenRouterTeam/typescript-sdk#773, re-implemented for the agent engine. - New client-only `streamTimeout` option ({ firstContentMs, contentIntervalMs }) on CallModelInput / GetResponseOptions. It is stripped from the API request on both resolution paths. - All five betaResponsesSend call sites (initial, tool-round follow-up, final, empty-output retry, unsent-results resume) now go through one sendModelRequest() helper, so deadlines re-arm for every request. - With a deadline configured, each request gets its own AbortController. dispatchRequestOptions composes it with the run signal, the caller signal and the replicated timeoutMs. On a stall the watchdog aborts that controller, which tears down the hung request. An abort of the composed signal (user or timeoutMs) disarms the watchdog, so it is reported as itself. - Same no-op rejection handler on executionPromise as PR #122: an early consumer exit followed by a stalled follow-up turn must not produce an unhandled rejection. Option named `streamTimeout` rather than the SDK's `timeout`: CallModelInput spreads every ResponsesRequest key, and a bare `timeout` would collide with any future API field of that name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Keeps model-result.ts at the structural gate's fan-out ceiling. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LukasParke
force-pushed
the
feat/dev-723-callmodel-timeout
branch
from
October 21, 2026 18:19
d8d1edb to
d539674
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack 2/3:
callModelstreamTimeout, armed for every model requestLinear: DEV-723. Base: #136 (watchdog core and typed errors). Next: stall retries (#138). Ports OpenRouterTeam/typescript-sdk#773, re-implemented for the agent engine rather than copied.
Design
One dispatch point. The engine has five
betaResponsesSendsites: initial, tool-round follow-up, final (allowFinalResponse), empty-output retry, and unsent-results resume. All five now go through a newsendModelRequest(), so deadlines re-arm for every request. That matches per-stepfirstChunkMsin vercel/ai.Abort composition. With a deadline set, each request gets its own
AbortController.dispatchRequestOptions(turnSignal)composes it with the existing pieces:signalRequestOptions.signaltimeoutMsOn a stall the watchdog aborts that controller. That tears down the hung fetch, and every consumer of the turn rejects with
StreamStalledError. The watchdog also receives the composed signal and disarms when it aborts, so a user abort ortimeoutMsrejects with its own reason.Client-only field.
streamTimeoutis stripped from the API request on both resolution paths:clientOnlyFieldsinasync-params.tsand the destructure inresolveRequestForContext.Off by default. Without
streamTimeout,dispatchRequestOptionsis byte-for-byte the old behavior. There is no extra signal and no wrapper.No unhandled rejections. A consumer that
breaks out of a tool-enabled getter while a later turn stalls used to leaveexecutionPromiserejecting unhandled. This adds the same one-linevoid executionPromise.catch(() => {})as fix(agent): prevent unhandled rejection when aborting stream #122, with the same text so the two merge cleanly in either order. A regression test fails without it.The SDK's idempotent
ToolEventBroadcaster.complete()was not needed. Every agent getter in the tool loop awaitsexecutionPromiseafter draining the broadcaster, so a follow-up stall still reaches all concurrent consumers. A test covers this, as Perry suggested on feat: callModel per-turn stall timeouts (DEV-723 2/5) typescript-sdk#773.Review-thread pitfalls
timeoutMsacross SDK retries (Devin and Perry on feat: stream watchdog core for stalled-stream detection (DEV-723 1/5) typescript-sdk#770 and feat: callModel per-turn stall timeouts (DEV-723 2/5) typescript-sdk#773). Supplying any signal disables the SDK's per-attemptAbortSignal.timeout. The replicated timeout then spans everyretryConfigattempt of one request.signalwas set. With this PR,streamTimeoutalone also triggers it.??vs||difference fortimeoutMs: 0also predates this PR and is left as is.client._options.timeoutMs: existing code, unchanged.Naming
The SDK called the option
timeout. This PR usesstreamTimeout.CallModelInputmaps everyResponsesRequestkey, so a baretimeoutwould shadow any future API field of that name. It is also easy to confuse withRequestOptions.timeoutMsandtoolTimeoutMs. Renaming is a one-line change if reviewers prefer parity.Tests
call-model-stream-timeout.test.ts(12 tests, fake timers). betaResponsesSend is mocked, and each mocked body errors when its request signal aborts, as fetch does. It covers:retryable: falseRequestOptionspreservedstreamTimeoutnever sent to the API (static and async-resolved requests)Port notes (Python / Go bots)
Carry:
streamTimeoutoption and its client-only strippingThe
AbortSignal.anydetails and the SDKRequestOptionsquirk are TypeScript-specific, but the Go and Python ports should check whether their own SDK disables per-attempt timeouts when a cancel token is supplied.Open questions
timeoutMscumulative across SDKretryConfigattempts (existing behavior), or move to per-attempt? A per-attempt fix needs either an SDK hook or not passingsignalat all.streamTimeout(this PR) ortimeout(SDK parity)?Verification
CI only runs on PRs into
main, so this stacked PR shows no checks. Everything below was run locally on this branch:pnpm install --frozen-lockfile,pnpm typecheck,pnpm lint,pnpm test(agent 1328, mcp 12, coverage ratchet holds),sentrux gate(no degradation).Once this stack lands, OpenRouterTeam/typescript-sdk#770, OpenRouterTeam/typescript-sdk#773, OpenRouterTeam/typescript-sdk#774 and OpenRouterTeam/typescript-sdk#775 can be closed.
🤖 Generated with Claude Code