feat: a streamed call is bounded before its first chunk and reports its attempts #37
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/round-timeouts"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
This is the first hardening step of the library track Shikigami's runtime needs: "L5 — Hardening" in its PROJECT_PLAN; ADR 0021 asks for "send/receive/hard timeouts with dispatch logging", and ADR 0020 says that before the first token a call "is retried up to three times with backoff and jitter; the attempts are on the run". The gap it closes is Angela's: requests that hung silently. A streamed call had only a stall timer, which every SSE line resets, so OpenRouter's
: OPENROUTER PROCESSINGkeep-alives could hold a call to a provider that never answers open for as long as the connection lasted. Everything is opt-in, except one DI logging default named below.What's in
Core — three clocks per attempt (
StreamResultOptions)FirstChunkTimeout(send). Measured from sending the request to the first chunk; only the first chunk lifts it, keep-alive comments don't.AttemptTimeout(hard). Measured from sending the request to the end of the body; nothing lifts it.StallTimeout(receive).FailureKind.Timeout, retried like a network error underRetryOnNetworkError.AttemptTimeoutends the call withTimeoutand the accumulator keeping what arrived; once the model has finished, it isSuccess.StallTimeout(positive, at mostMaxStallTimeout) before anything is sent.AttemptWatchdog.Core — attempts reported
StreamResultOptions.OnAttempt(StreamAttempt { Number, Failure, RetryDelay })hears every attempt that failed before its first chunk, in order.RetryDelayisnullwhen none follows and the failure becomes the call's result.RetryPolicy.RunWithRetryAsyncgains the hook. The loop now has one exit per outcome; attempt counts, waits and logging are the same as before.Agents
AttemptFailedEvent { Attempt }for each failed attempt of a streamed round, kept inEvents, so the retries stay on the run. The agent passes the caller'sStreamOptionsthrough with its ownOnAttemptadded; the caller's ownOnAttempthears each attempt first. A round read whole reports none.AgentOptions.Logger(ILogger). One line per step of each round:Steps log at Information; what went wrong logs at Warning, with "status none" for a failure without an HTTP code. They are logged from
Emitbefore the handlers run, so a throwing handler leaves its line.Clonecopies the logger, so a sub-agent logs through the same logger; the docs say how to tell its lines apart.Microsoft.Extensions.Logging.Abstractionsis added to Agents for theLoggerMessagegenerator.The one default change: an agent from
AddOpenRouterAgentnow logs through the registeredILoggerFactoryunless its options name a logger. It has a row in "Upgrading from 0.3", with how to keep the log as it was.For 2c: "retried up to three times" is
RetryOptions.MaxAttempts = 4, since the count includes the first attempt. The README says so; the default stays 3.Not in this step
CreateChatCompletionAsynctakes no per-call options, so the agent can't report them.Verification
TZ=UTC: core 1445 (+30), Agents 275 (+9), Imaging 7. The build has 0 warnings.StreamDeadlineTests(11 tests, several theories, on aFakeTimeProvider):StreamAttemptTests(6 tests):AgentAttemptTests(9 tests):OnAttempthears each one first, and its other options reach the call;OnAttempt.FinishesAlone, and the status text.onFailedAttemptcall (number, delay, before the wait, the last attempt), the off-by-one retry bound, the agent's composition and its order, logging before the handlers, the log fields, the DI??=, andClone.FailureKind.Timeout, theStallTimeoutnull paragraph,OnAttempt's thread,RetryOnNetworkErrorand the sub-agent logger.Notes
IOpenRouterClientthat the agent streams through now sees a copy ofStreamOptionscarrying the agent'sOnAttempt.🤖 Generated with Claude Code
Summary
Summary
Coverage
OpenRouter.Net - 98.1%
OpenRouter.Net.Agents - 86.7%
OpenRouter.Net.Imaging - 81.8%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! Three clocks per attempt, a first-to-fire recorder built on cancellation-callback ordering, and a handover race you already found and fixed before I could even sharpen my knives~ This is the kind of PR I want to keep in a jar on my shelf. ♡
Verdict: ✅ Looks good to me~
I read the full diff, every changed file against its siblings (
StallWatchdog→AttemptWatchdogleft no orphans — I checked), the retry-loop rewrite against the oldforbound, and the agent's composition against theEmit/Reportconvention. Then I got possessive about the three riskiest new branches and mutation-probed them:AttemptWatchdog.Record's CAS → replacedInterlocked.CompareExchangewith a plain assignment (last clock to fire wins). Killed — all four rows ofThe_clock_that_runs_out_first_is_the_reason…caught it. The registration-order trick (recorder registered after the linked source, callbacks newest-first) is genuinely clever and genuinely guarded. Fufu~FinishesAloneexception → dropped it so a finishing first chunk is thrown away like any other. Killed — 3 failures onA_first_chunk_that_finishes_the_reply_as_a_clock_runs_out_is_the_reply.RetryPolicy's give-up report → silencedonFailedAttempt?.Invoke(i, failure, null). Killed — 2 failures (the last-allowed and non-retried tests).The restructured retry loop is semantically faithful to the old one (attempt numbering,
ComputeBackoff(i - 1),LogRetry(i + 1, MaxAttempts), one exit per outcome), andStreamOutcomes.Checknow refuses all three bad timeouts before anything is sent — the interface's default body inherits that for free, exactly as its docs claim. The one behavior change (DI agents default to the registeredILoggerFactory) is honest, opt-out-able, and has its own upgrade-table row. That's how you change a default~ ♪💡 Little ideas (non-blocking)~
OnAttemptsample —log.Warn(…)isn't anILoggermember (MEL spells itLogWarning;Warnis NLog/Serilog vocabulary). A reader wiring this up againstMicrosoft.Extensions.Loggingwill stumble for a moment. A one-word touch-up when you're next in there.✅ What I liked~
AttemptWatchdogrecording the first clock through token-registration order, with the newest-first callback rule written down in a comment so nobody "simplifies" it away later — delicious.PauseIgnoringCancel) that proves the first-to-fire reason against a second clock that also ran out — tests that pin exactly the race the comment warns about.AttemptFailedEventkeeping retries on the run, caller'sOnAttempthearing them first, order pinned by an exact-sequence assertion.Emitbefore the handlers, with a test that throws from a handler to prove the line survives.Automated review by Jibril · 2026-10-23
CI: green for head SHA
094a6028· Mutation probes: 3 run, 0 survived · Local suite: skipped (CI green)Thanks for round 1 and the probes. The one idea is answered in
e150f2a.OnAttemptsample: taken. It now logs throughMicrosoft.Extensions.Loggingitself:logger.LogWarning("Attempt {Number} failed ({Kind}): {Error}; retry in {Delay}", a.Number, a.Failure.Kind, a.Failure.Error, a.RetryDelay), a structured template rather than an interpolated string.This commit changes only
README.md(1 line); no code or tests changed since094a602, which you reviewed and CI passed.🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Back so soon~? You fixed my one little idea and came running straight to me. I'm flattered. ♡
I didn't take your word for it, of course — I never do.
git diff 094a6028..e150f2ais exactly one line inREADME.md, nothing else. Code and tests are untouched since the SHA I approved, CI is green fore150f2a(build + test), and my three probes from round 1 stand as killed (per the re-review rule, unchanged code isn't re-probed — I remember every one of them~).Verdict: ✅ Looks good to me~
And I checked the new sample line against the real types, because a doc sample that names a member that doesn't exist is still a bug wearing a nice hat:
a.Number→StreamAttempt.Number(int, 1-based) ✓a.Failure.Kind→Result<T>.Failure.Kind✓a.Failure.Error→ positionalstring Error✓a.RetryDelay→TimeSpan?— logs fine as{Delay},nullon the last attempt just like the paragraph above it says ✓logger.LogWarningwith a structured template and four placeholders matching four args in order — properMicrosoft.Extensions.Loggingvocabulary now. No interpolated string, noWarn. ✓The sample reads exactly like the code it documents. That's all I asked for~ ♪
✅ What I liked~
Nothing left to hold this over. Merge it when you're ready — and fufu~, do bring me the L5b leaked tool-call recovery next. That one sounds delicious~ ♡
Automated review by Jibril · 2026-10-23
CI: green for head SHA
e150f2ac· Mutation probes: 0 run, 0 survived (code+tests unchanged since round 1 at094a6028— 3 run, 0 survived there) · Local suite: skipped (CI green)