chore: version packages - #88
Conversation
There was a problem hiding this comment.
⚠️ APPROVE withheld by policy — PR author @github-actions[bot] is not a member of OpenRouterTeam (association: CONTRIBUTOR). Review posted as COMMENT; a maintainer must approve out-of-band.
Summary
Automated Changesets release PR: consumes the three pending changesets, bumps @openrouter/agent 0.8.0 → 0.9.0 (minor, matching three minor changesets) and @openrouter/mcp 0.0.1 → 0.0.2 (patch, per updateInternalDependencies: "patch" in .changeset/config.json), with matching CHANGELOG entries. No source changes; version/changelog math is internally consistent and no dependency range edit is needed since packages/mcp/package.json uses @openrouter/agent: workspace:*.
✅ No findings.
a97ef22 to
4125c61
Compare
There was a problem hiding this comment.
⚠️ APPROVE withheld by policy — PR author @github-actions[bot] is not a member of OpenRouterTeam (association: CONTRIBUTOR). Review posted as COMMENT; a maintainer must approve out-of-band.
Summary
Update review of head 4125c61: the diff content is byte-identical to the version I reviewed at a97ef22 (same three changesets consumed, @openrouter/agent 0.8.0 → 0.9.0, @openrouter/mcp 0.0.1 → 0.0.2, same CHANGELOG bodies) — the synchronize was a regeneration/rebase of the Changesets branch, not a content change. Re-verified at the new head: .changeset/ contains no leftover pending changesets, packages/agent/package.json:3 reads 0.9.0 with the ./doom-loop subpath export present (matching the changelog's claim of a new @openrouter/agent/doom-loop entry point), and packages/mcp/package.json still depends on @openrouter/agent: workspace:*, so no dependency range rewrite is missing.
✅ No findings.
9ea3633 to
36f6f6b
Compare
36f6f6b to
3155a97
Compare
3155a97 to
02fc0ba
Compare
02fc0ba to
a30c9f2
Compare
…ew (#106) pr-gate.sh refused to PASS until a check named exactly perry/review appeared and reached a terminal state — but perry has never posted a check in this repo (the AI reviewer that runs here is Devin Review). The requirement was inherited from the SDK bump flow the script was ported from. The first train run to ever reach the gate step (dry-run 31418471846) confirmed the stall: PENDING 'waiting for perry/review' until cancelled. CI is the gate on PRs in this repo, so: - drop the perry_present/perry_terminal requirement, the PERRY_TIMEOUT machinery, and the never-appeared alert path - keep failing on any AI reviewer check that does run and goes red, and on reviewDecision=CHANGES_REQUESTED — a red check is a red check - PASS now means: no failing checks, none pending, mergeable Verified live against PR #88 in report-only mode: verdict evaluates correctly end-to-end.
This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
Releases
@openrouter/mcp@1.0.0
Major Changes
#86
53d71ccThanks @LukasParke! - Support both MCP protocol revisions. Any server now works out of the box, whether itspeaks
2025-11-25or2026-07-28.Migrates from
@modelcontextprotocol/sdk@^1.29.0to@modelcontextprotocol/client@^2.0.0and adds
protocolNegotiation?: 'legacy' | 'auto' | { pin: string }, defaulting to'auto'. Under'auto'the client probes withserver/discoverand then speaks whicheverrevision the server offers — the per-request
_metaenvelope for2026-07-28, or theclassic
initializehandshake for2025-11-25and earlier.Breaking: Node 20+ is required —
@modelcontextprotocol/client@2.0.0declaresengines.node: >=20, and this package now declares the same so Node 18/19 consumers get aninstall-time warning instead of a runtime failure.
Breaking: if you pass
auth: { kind: 'oauth', provider }, your provider must satisfy@modelcontextprotocol/client@2.0.0'sOAuthClientProvider— change the import specifier,and note
tokens()now returnsStoredOAuthTokens(same fields, so most providers compileunchanged). Type it with the newly exported
MCPOAuthClientProviderto avoid depending onthat path again.
protocolNegotiationdefaults to'auto', where the SDK defaults to'legacy', so everyconnection's first request is a
server/discoverprobe. This is not a connectivitybreak: when you have not set
protocolNegotiationyourself, a failed connect is retriedonce with
'legacy', so a proxy, WAF, or gateway that hangs or 5xx's on an unknown methodstill connects exactly as it did before. Modern servers get
2026-07-28, everything elselands where it always did.
The
server/discoverprobe is bounded at 30s (probeTimeoutMsto change it). The SDKotherwise gives the probe the full 60s request timeout, which under
'auto'— where the probeis the first request of every connection — would mean minutes of hang against a gateway that
black-holes requests. It is not tighter than that on purpose: on HTTP a probe timeout is
classified as an outage and the legacy retry sends an
initializethat revision 2026-07-28removed, so a modern-only server slower than the ceiling would fail to connect rather than
merely take longer. Serverless cold starts make an aggressive value a correctness problem.
The cost lands only on already-failing connects: the retry re-walks the same transport ladder
under
'legacy', so a genuinely unreachable server is dialled up to four times rather thantwo. The retry deliberately does not pin one transport — a legacy server reachable only
over SSE, behind probe-hostile infrastructure, needs SSE offered again under
'legacy'or itstops connecting entirely, which is the regression this mechanism exists to prevent.
The retry is skipped on an auth failure — the SDK's
UnauthorizedError, or (when an OAuthprovider is configured) a 401 status from the probe, which the SDK reports as an
SdkHttpErrorinstead of routing through the OAuth flow. A 403 always degrades, evenunder OAuth: the SDK's PKCE side effects live exclusively behind its 401 branch, so a 403
retry re-drives nothing — while gateways commonly answer unknown methods with 403, which is
the very scenario the retry exists to rescue. Auth failures are recognised whether they came
from the last attempt or an earlier one. Rejected credentials are not something a different protocol revision fixes, and re-running
the attempt would drive an OAuth provider's authorization flow a second time and overwrite the
stored PKCE verifier.
connect-level calls accept asignalthat aborts the whole ladder — every transportattempt, the probe, and the implicit legacy retry — so a caller with its own deadline can
bound the worst case (~3 minutes against a black-holing gateway on the default path). An
aborted connect is never retried. Snapshot replays also no longer perform the construction
write-back: the snapshot it would write is the one just read, so it was a store round-trip
per rehydrate buying nothing. Three maintenance writes survive, all best-effort: an OAuth
provider under
cacheCredentials: truere-persists its current tokens (so the stored entrytracks rotation instead of expiring into a forced fresh connect), a rotated static credential
(
bearer/headersauth passed by the caller undercacheCredentials: true) updates thestored header block the same way, and an entry carrying a
legacy
sessionIdfrom an earlier version is rewritten once without it. Every maintenance write reads thestore first and only ever rewrites an entry it already holds: the rotation writes graft the
credential block onto the stored entry (never the caller's possibly-older input snapshot, which could
roll back a newer entry written by a concurrent
refresh()), skip entirely when the storedentry never held that credential block, and write nothing at all when the credential is
unchanged — an unrotated OAuth token in particular is never re-persisted, which would ratchet
its recorded expiry forward (
expires_inis relative to issuance) and could drop storedfields the provider no longer reports —
expires_inis relative to issuance, so restamping it per replay wouldpush the recorded expiry forward forever. No maintenance write can introduce credentials into a
store that lacked them. If your store implementation extends entry TTLs on write
(Redis
SETEX-style), note that warm hits no longer touch the store, so such entries nowexpire on their own schedule rather than being kept alive by access; size the TTL to the
staleness window you actually want.
MCPConnectionErrornow carries every underlying failure inerrors(matchingAggregateError), flat and in attempt order across both negotiation passes, so a caller seesall of them rather than only the last — which is all
causeholds.An explicit
protocolNegotiationis honoured exactly, including'auto'— asking for amode means asking for its failures too, and silently overriding a
{ pin }would defeat thepoint of pinning. Pass
'legacy'to skip the probe entirely.Cache writes are best-effort, the probe is tunable, and multi-attempt failures carry every
underlying error:
When more than one transport was tried,
MCPConnectionErrorcarries every underlying failureon
errors—causestill holds the last one, which on its own hides an auth rejection froman earlier attempt:
Rehydrating a snapshot now enforces
staleness.maxAgeMson every path, includingreconnectOnExpiry: false. If the re-list that would refresh an over-age snapshot fails,the call rejects rather than quietly serving tools you declared too old — catch
MCPStaleSnapshotErrorto opt back into stale-but-usable tools:Also in this release:
callToolbridge: SDK v2 dropped the middle argument, so theold call shape would have put
signalandonprogressin an unread slot, disablingcancellation and progress streaming.
clientInfoversion, which said0.1.0while the package was0.0.1, and now generates it frompackage.jsonso it cannot drift.staleness.maxAgeMsis now honoured byrehydrateMCPTools()too, not just bycreateMCPTools()'s cache-hit path — a direct rehydrate previously replayed snapshots ofany age. This holds under
reconnectOnExpiry: falseas well: a stale snapshot re-listsover the replayed connection rather than being served as-is, since "stale" means the tool
set needs re-reading, not that the transport needs rebuilding. If that re-list fails, the
call rejects with the new
MCPStaleSnapshotErrorrather than quietly serving tools thecaller declared too old — catch it specifically to opt into stale-but-usable tools. It
subclasses
MCPCacheError, so existing catch sites are unaffected.onElicitationis no longer deprecated: it works on both revisions, since themulti-round-trip driver routes
input_requiredthrough the same handler.MCPCacheStorefailing no longer fails the call, on either side. Writes arebest-effort: a store outage previously discarded a live connection whose tools had been
read successfully, and broke the documented stale-snapshot recovery, which writes through
the same store. Reads are a miss: a failing
store.getfalls through to a fresh connect,exactly as an empty cache would.
handle.refresh()reports a write failure as the newMCPCacheWriteError(a subclass ofMCPCacheError) for callers who do want to treat itas fatal, and
onToolsChangedsubscribers are still notified whenever the re-list itselfsucceeded, whatever failed afterwards (a store write, or the OAuth provider rejecting
while the snapshot was being built) — the
tools were re-read successfully, and skipping the announcement would leave subscribers
permanently out of sync with
handle.tools.to try SSE with the same
authProvider, re-entering the SDK's auth path for a secondredirectToAuthorizationand overwriting the saved PKCE verifier — the same duplicated sideeffect the negotiation retry already guarded against, one layer down.
handle.refresh()genuinely re-reads the tool list again. SDK v2 added a per-clientresponse cache honouring the server's
ttlMsontools/list(up to 24h), so a refreshinside that window would have returned the cached list — an app calling
refresh()topick up new server tools could have kept the old set. Every internal
tools/listnowsends
cacheMode: 'refresh', as doeslist_resources— a listing's job is to report whatexists now, and a cached one reads to the model like its write silently failed.
read_resourcedeliberately still honours the server's TTL: contents can be large, andthe SDK already evicts per-URI on
notifications/resources/updated.rehydrateMCPTools()no longer replays a snapshot'ssessionId. With one present, SDK v2skips negotiation entirely and leaves server capabilities and version undefined without
erroring — so the replayed handle would silently lose its resource tools. Sessions are
removed in 2026-07-28 anyway (SEP-2567), so the replay does a fresh handshake.
sessionIdat all. A Streamable HTTPMcp-Session-Idisbearer-equivalent to an authenticated server session, and with the replay path no longer
reading it, writing it to a cache store was attack surface for no functionality. The field
stays on the snapshot type so existing cache entries keep deserializing; treat any value
found there as untrusted legacy data.
connect()no longer leaks its transport. The SDK does not close a transportwhose
start()threw, so a rejected connection left an open keep-alive socket — mostvisibly on the new probe-failure path, where a strict gateway leaked one per attempt.
'auto'probe applies to SSE as well, including a pinnedtransport: 'sse'and theStreamable HTTP → SSE fallback, since all three share one client factory. The legacy retry
covers each of them, so pinning SSE for a legacy server still connects.
protocolNegotiation: { pin }now autocompletes and typo-checks the two known revisions(
'2025-11-25','2026-07-28') while still accepting any other string, so pinning afuture revision compiles without a cast. The revision union is exported as
MCPProtocolRevision.InMemoryTransport, for theStreamable HTTP → SSE fallback, for
tools/list_changeddispatch end to end, and for theclientInfowe self-report.Minor Changes
#74
f412281Thanks @LukasParke! - Doom-looploopKeysupport for MCP-wrapped tools (pairs with@openrouter/agent'sdoomLoopoption).Two ways to declare a wrapped tool's call identity: a client-side
loopKeysmap oncreateMCPTools/rehydrateMCPTools(keyed by unprefixed MCP tool name; anyToolLoopKeyform — function, field-name array, orfalseto exempt), and a server-advertised_meta['openrouter/loopKey']on the tool definition (data-only: field-name array orfalse). Client config takes precedence. Server-advertised declarations ride the cache snapshot (SerializedMCPToolDef.loopKey), so rehydrated tool sets keep their identities without alistTools()round-trip; function forms are client-side only and cannot be cached.A server can advertise the same thing itself via
_meta['openrouter/loopKey']on the tool definition (data-only: a field-namearray or
false). ClientloopKeyswin over a server declaration, andserver-advertised values survive a cache round-trip via
SerializedMCPToolDef.loopKey.Patch Changes
53d71ccThanks @LukasParke! - Generate theclientInfoversion frompackage.jsoninstead of hardcoding it.buildregenerates
src/version.ts, and a unit test fails if the committed value drifts, so theversion reported to MCP servers cannot go stale across a release.
e8d7d6d,78c562e,78c562e,75271c3,5a7ed03,a629cf1,78c562e,3028554,231fb65,0efdbb0]:@openrouter/agent@0.9.0
Minor Changes
#90
e8d7d6dThanks @LukasParke! - Async tool support: a unifiedrun()tool interface with lifecycles, model-side task check-ins, steering, subagent tools (tool.agent()), per-tool cancellation & timeouts, and tool concurrency controls.One tool interface. Every tool is declared the same way: a
runhandler (async function or async generator) pluslifecycle: 'sync' (default) | 'background' | 'deferred'. Generator yields become the task's log (feeding check-ins,tool.preliminary_resultevents, and transcripts); the generator's return is the result, validated againstoutputSchema. Non-generator bodies log viactx.log(). The releasedexecute/execute: false/onToolCalledforms are unchanged.'background'— the loop keeps going. Work settling within the grace window (graceMs, default 250ms) behaves like a sync call; otherwise the model receives a pending placeholder immediately (satisfying the provider requirement that everyfunction_callin follow-up history has a paired output) and the result is injected as atool_task_resultuser message when it settles.asyncTools.onRunEnd: 'drain' (default) | 'detach' | 'cancel'governs run end.'deferred'—runreturnsctx.defer(taskId)to park the call on durable external work; the run pauses with the newConversationStatus'awaiting_async_tools'. The built tool carries typed.resolve()/.fail()/.cancel()completion methods (output checked againstoutputSchemaat compile time and runtime), callable from any process holding theStateAccessor;resumeToolResults()is the low-level batch entry point. Double resolution throwsToolTaskAlreadySettledError.Model-side task interactions. When any long-running tool is registered, the SDK appends ONE universal
tasktool — a single static wire definition no matter how many async tools exist (per-tool schemas are never augmented; context cost stays constant). The model addresses tasks bytaskId:action: 'check' (default) | 'steer' | 'result' | 'cancel', withview: 'status' | 'logs' | 'transcript'for checks. Calls are engine-intercepted and dispatched to the OWNING tool'scheck: { schema, execute }config when declared (customparamsvalidated againstcheck.schema), else the SDK default views — universal interface, tool-specific handling. Check handlers receiveturnContext.toolCallStatus,turnContext.accumulatedYieldedEvents, and aturnContext.taskhandle (statusView/tailLogs/transcript/send/cancel). Task-tool calls are doom-loop-exempt and bypass concurrency/timeout gates. Opt out withasyncTools: { checkins: false }. After a process restart, deferred tasks answerstatusfrom persisted state (including a boundedlastLog— a new additivePendingAsyncToolfield).Steering. Running tasks have an inbox:
runbodies opt in viactx.onMessage(handler); deliver from code withModelResult.sendToTask(taskId, message)or from the model via a custom check param forwarded withturnContext.task.send(...). NewModelResult.queueUserMessage(text)injects a user message at the next safe turn boundary.Subagent tools.
tool.agent()creates a tool whose work IS a childcallModelconversation, running as a background task: the parent loop keeps going, each child turn becomes a log entry, the child conversation is the check-in transcript (statusaddsturnsCompleted/currentActivity), theresultmapper (default:{ text: await child.getText() }) shapes the delivered output,cancelTask/ parent abort /timeoutMscancel the child, and steering messages are injected into the child as user messages. Children run in-memory and do not inherit parent hooks (pass child hooks in theagentspec explicitly).Cancellation & timeouts. Tool contexts carry
ctx.signal(fires on run abort, per-tooltimeoutMs/ run-leveltoolTimeoutMs,cancelTask,ModelResult.cancel()), plusctx.callId/ctx.conversationId. Timeouts bound the round's wait, not the tool body ({ error, code: 'tool_timeout' }). Behavior change:ModelResult.cancel()now also aborts in-flight tool work (previously stream-only).Concurrency.
toolConcurrency: number | { round?, background? }(round unbounded by default; background pool default 16) plus per-toolmaxConcurrency. Output order stays call order.Events. New
tool.async_started/tool.async_settled(withdelivery: 'injected' | 'pending_resume' | 'dropped'); progress reusestool.preliminary_result;tool.resultfires exactly once per call with the final value.ModelResult.getAsyncTasks()inspects live tasks. Doom-loop detection treats a late-result delivery as forward progress.State fields (
pendingAsyncToolswithlastLog,settledAsyncCallIds) are additive within ConversationState version 1. New subpath exports:resume-tool-results,tool-concurrency,async-tool-registry,tool-task,tool-check,agent-tool. The reserved tool nametaskis rejected bytool()and, when supplied dynamically, suppresses the built-in with a warning.Note:
tool.background()andtool.deferred()existed only on this PR's branch and were never published; they are replaced bylifecycle. No released consumer is affected.API example
#73
78c562eThanks @LukasParke! - Doom-loop detection for the tool-execution loop (opt-in viadoomLooponcallModel).Catches runs that stop making progress while continuing to spend: the model re-issuing the same tool call with identical arguments in consecutive rounds (including repeated empty
{}calls and repeated invalid-JSON calls), repeating identical server-tool requests (web_search_calletc., detected post-execution at the step checkpoint), or emitting the same text tokens over and over. Detection is deterministic — a verdict is a pure function of the transcript — and responds through a configurable graduated ladder:observe(emit the newDoomLoopDetectedhook) →steer(inject corrective guidance; queued guidance persists across pauses) →block(refuse the call with an explanatory tool error, before execution) →stop(halt before any further model request; unresolved calls in the final turn get synthesized halt-error outputs so persisted history stays well-formed;SessionEnd.reason: 'doom_loop').Streaks are round-scoped: N identical calls fanned out in parallel within one round count once (a streak measures the model re-issuing a call after seeing its result). Tools declare call identity via
loopKeyon the tool definition — a computed function over the call's validated arguments (e.g.({ command, cwd }) => ({ command, cwd }); returningnullexempts a call), orfalse(statically exempt); absent means the full validated arguments. MCP-wrapped tools acceptloopKeyviamarkMcp(tool, { loopKey }). Fingerprints are a cross-port contract: RFC 8785 (JCS) canonicalization + SHA-256 over UTF-8 via WebCrypto, with conformance vectors intests/vectors/doom-loop-fingerprints.jsonfor the Python/Go ports. Unhashable key material (bigint, circular, >64 deep) falls back to the full-arguments identity — detection never fails a run.Detector state persists inside
ConversationState.doomLoop: streaks survive serialize → resume, astopverdict survives decision-only resumes (approve/reject) and clears on a fresh conversational turn, and queued steer guidance is delivered on resume. Ladder configs warn on dead rungs and onblockwithstop: false(unbounded block/re-issue). Documented, test-locked limits: varying-input (nonce) loops evade the default identity without aloopKey; paraphrased text repetition is not detected; manual/client-executed calls are not recorded. New@openrouter/agent/doom-loopsubpath exports the primitives;ModelResult.getDoomLoopVerdict()reports a stopping verdict.#73
78c562eThanks @LukasParke! - Doom-loop escalation recovery: a newescalateladder rung betweensteerandblockthat unblocks a stuck run by throwing more intelligence at the next turn instead of refusing or halting.Configure via
doomLoop.escalation:modelruns the NEXT turn on a stronger model (one-turn override, automatic revert), and/oradvisorforces anopenrouter:advisorconsult (the advisor server tool is appended withforwardTranscript: trueand loop-diagnosing instructions, andtoolChoiceis pinned to it viaallowed_tools/requiredso the stuck model must ask for guidance first; an object form passes through as advisor parameters). A user notice naming the detected loop accompanies the escalated turn.Escalations are real spend on a run already suspected of wasting it, so they are budgeted:
maxEscalations(default 2) caps recoveries per conversation, budget is consumed when a recovery is applied (not at verdict time),escalationsUsedpersists inConversationState.doomLoopso resumes cannot reset it, and concurrent detector verdicts in one window escalate once. Exhausted or unconfigured escalations fall through to the weaker rungs; resolve-time warnings flag anescalaterung without a mechanism (and vice versa). TheDoomLoopDetectedhook'saction/overrideActionenums gain'escalate'— an override without config/budget downgrades toobserve, never silently to a stronger action.#89
75271c3Thanks @LukasParke! - Fix doom-loop detection missing a repeated same-tool fan-out.Streaks compared a tool's last fingerprint, so
read(a), read(b), read(c)reissued verbatim had a different last call every round and each round's first
call reset the streak to 1. Eight identical rounds of a three-call fan-out
produced zero detections, while single-call rounds tripped at round 2 — and
distinct-argument fan-out is the dominant shape in parallel-tool-calling agents.
A round's identity for one tool is now the set of fingerprints it was called
with, compared across rounds. The engine declares a round's complete set before
any of its calls is scored, so ordering within the round does not matter, a
changed member resets the streak, and neither a strict subset nor a superset is
a repeat — a round that adds new work is progress, not repetition. Every call in
a repeating round reports that round's streak, so at the block rung a repeating
fan-out stops spending rather than only its last call being refused.
Per-call streaks accumulate alongside the round-set streak, and the
stronger evidence decides. Each
(tool, arguments)identity counts its ownconsecutive rounds, whatever its round-mates did — so a call repeating inside
varying company (
[a,b],[a,c],[a,d]:ais a 3-peat) is flagged eventhough every round's set differs, a repeat keeps counting when a paused HITL
member drops from the resumed round, and undeclared paths (server-tool records,
direct callers) get order-independent per-call detection without a declaration.
When the per-call count alone crosses a rung, only that call is refused and its
verdict quotes its own identity; genuinely new round-mates run free. For an
exactly-repeating round both counts are equal, so nothing double-fires. A
partial repeat (
[a,b,c]then[a,b]) flags the re-issued calls at theobserve rung rather than being invisible; a superset round (
[a,b],[a,b],[a,b,c]) flags the repeated members while the new call always executes.A call that a round's declaration could not include (unhashable key material)
cannot inherit or move the round's counters; its own verbatim repetition still
accumulates per-call evidence like any other repeat.
Resumed runs: a multi-call round's fingerprint set and per-call counts are
persisted alongside its streak (new optional
roundFingerprintsandcallStreaksonDoomLoopStreak— additive; pre-existing blobs restore withtheir old single-call semantics). A repeating
fan-out therefore keeps its evidence across save/resume boundaries: approval
pauses no longer reset a fan-out sitting at the block rung, and per-turn-resume
topologies (one
callModelper user turn, state persisted between) accumulateacross turns instead of re-baselining on every one. Because the streak travels
with the exact set that earned it, a resumed round containing only a subset of
that set is a different round and starts at 1 — a lesser call can never inherit
a fan-out's evidence. Single-call streaks behave exactly as before.
New API:
DoomLoopMonitor.declareRound(round, calls)— declares a round'scomplete call set before any of it is scored.
DoomLoopMonitoris exported, sothis is a new public method, additive only. Callers using
callModelneed nottouch it (the engine calls it); direct
DoomLoopMonitorusers and SDK portsshould, so a repeating fan-out is flagged as one unit (shared verdict, shared
steer message) rather than only via each member's individual per-call count.
Single-call round timing, in-round duplicate collapsing, verdict payloads, and
the number of times a tool's
loopKeyis invoked (once per checked call) areunchanged. The persisted shape gains two optional fields (
roundFingerprintsand
callStreaks, both above); everything existing is untouched and old blobsrestore cleanly with their old semantics.
Newly reachable false positive. The detector compares arguments, not
results, so repetition shapes that were previously invisible now accumulate and
are refused at the default
blockrung from round 3. Two variants:same context files each turn, or a fixed fan-out of pollers — blocks with one
synthesized error per call in the round.
anchor file (README, config, schema) while exploring new files each turn
(
[a],[a,b],[a,b,c]:ablocks from round 3 even though every roundadds work). The per-call detector counts the call's own consecutive rounds,
so the round being "progress" does not exempt a member that itself repeats:
a file already read is in context, and re-reading it is spend without
progress.
Exempt such tools with
loopKey: false(or aloopKeyreturningnullforthe call). These classes were invisible to the detector before, so no existing
exemption covered them; the graduated ladder gives every shape a free round and
an
observewarning before anything is refused.For
callModelusers, nothing to change —doomLoopis configured exactly asbefore, and the engine declares each round for you. What changed is when it
fires:
Driving
DoomLoopMonitordirectly (or porting it) is the case that needs thenew call — declare a round's whole batch before recording any of it.
resolveDoomLoopOptionandResolvedDoomLoopConfigare now exported too:DoomLoopMonitorwas previously exported without its config resolver, so itcould not actually be constructed from the public API.
#97
a629cf1Thanks @LukasParke! - NewModelResult.getUsage()accessor: aggregate token/cost usage across every model call a run made.getResponse()resolves to the final round's response, so in a multi-round tool loop the tokens spent on the intermediatetool_callsgenerations were unreachable — andgetItemsStream()carries output items only, never surfacing theresponse.completedevents that hold each round's usage block. Callers streaming items therefore had no way to account for a run's real token spend without registering a hook.await result.getUsage()returns the sameSessionUsageTotalsshape as theSessionEndhook'stotalUsage(modelCalls,inputTokens,outputTokens,totalTokens,cachedTokens,reasoningTokens, andcostwhen the server reported it), summed over the initial request, each tool-round follow-up, the empty-final retry, theallowFinalResponsefinal turn, and approval-resume requests. It gates on run completion likegetResponse()does, so totals are final whether awaited directly, aftergetResponse(), or after draining any streaming getter — includinggetItemsStream()(on an approval-resumed run, reading usage never advances the tool loop; awaitgetResponse()/getText()first for final totals there). UnlikegetResponse()it never rejects (a failed run still consumed tokens), returning the totals accrued so far.The usage aggregate is now accumulated independently of the hook system, so it is correct for callers who configured no hooks at all; previously it only advanced as a side effect of
PostModelCallemission.SessionEnd.totalUsageandgetUsage()read from one snapshot helper and cannot drift.#73
78c562eThanks @LukasParke! - Run-level cancellation and per-request timeout composition.New
signaloption oncallModel: aborting it stops the tool-execution loop at the next turn boundary AND aborts the in-flight API request/stream, so a stalled provider fails fast with the abort reason instead of hanging until an outer caller/test timeout. A pre-aborted signal fails before any network dispatch.RequestOptions.timeoutMs(the thirdcallModelargument) now reliably bounds each request the loop makes even when a signal is present: the underlying SDK skips its owntimeoutMswiring whenever a request carries a signal, so the engine composes{run signal, caller signal, per-request timeout}viaAbortSignal.anyper dispatch — each request gets a fresh timeout budget (not one shared per-run timer), and whichever bound fires first wins.#99
3028554Thanks @devin-ai-integration! - Addstrictto all client function-tool definitions, includingtool.agent(), and pass it through serialization instead of hardcodingstrict: null, so providers can enforce structured-outputs-style schema adherence on tool-call arguments.The SDK forwards the caller's generated schema unchanged and propagates provider validation errors. OpenAI-style strict schemas require every object property to be listed in
required; use Zod.nullable()for conceptually optional values because.optional()allows the key to be omitted.Patch Changes
#95
5a7ed03Thanks @LukasParke! - Clarify thevalidateFinalResponseerror messages so an empty final turn can't be misread as "validation rejected my tool call" (issue #45).Invalid final response: empty or invalid outputnow names the actual defect:output array is empty (length 0) for response "<id>"— with the response id and a pointer to thestrictFinalResponse/allowFinalResponseoptions — versusoutput is not an array (got <type>)when the payload is malformed.Invalid final response: missing required fieldsnow lists which fields were absent (id,output, or both).Diagnostics only — no behavior change. Validation remains a pure array-length check, so tool-call-only output still passes (it always did; that was the misdiagnosis in #45). Both historical message prefixes are unchanged, so any matcher on them keeps working.
#91
231fb65Thanks @w0nche0l! - Thread the executed tool call into the hook execute context.context.toolCallis part of the tool-facing contract, but only the non-streaming orchestrator populated it — the streamingModelResultloop builds its turn context with justnumberOfTurns, soexecute/onToolCalledhooks sawtoolCall: undefinedon the streaming path.buildExecuteCtxnow fills the gap from the executed call: a caller-providedturnContext.toolCallstill wins (the orchestrator's carriesstatus), and otherwise the executedParsedToolCallis converted back to a wire-shapedFunctionCallItem. TheonResponseReceivedpath intentionally threads nothing — only thefunction_call_outputitem is in scope there.#100
0efdbb0Thanks @devin-ai-integration! - Relax an unchanged forcedtoolChoice(required, a specific tool, orallowed_toolswithmode: 'required') toautoafter it produces a tool call, including follow-ups resumed after approval, HITL, client-tool, or async-tool pauses. Dynamically resolved choices re-arm when their semantic value changes. This lets the model synthesize a final text answer instead of being forced to call tools until the step budget runs out (DEV-785).