feat: a cancelled run returns what it ran and what the cancel cut off #33
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/cancelled-result"
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?
The second step of the library track for Shikigami's runtime (PROJECT_PLAN "L1 — … a
Cancelledresult instead of a throw"; Shikigami ADR 0021, ADR 0020's Stop, caps and broken streams): an opt-in cancel that returns the run's result. Under the default nothing changes for Orihon or Kagura:AgentOptions.Cancellationdefaults toThrow. The library stays at 0.4.0.What's in
API
AgentCancellationMode { Throw, ReturnResult }andAgentOptions.Cancellation, whichClone()carries and the options registry tests list.AgentStopReason.Cancelled, appended. It is produced only withReturnResult.AgentResult:PartialMessage: a streamed round's message as its chunks had folded when the run was cut off, the first choice only, as the loop reads it;CutOffGenerationId: that round's generation id, for looking up its cost;CutOffRound: the round whose tool calls the cancel cut short, with its completion, usage and stream figures, and the executions of the calls that ran.Behaviour under
ReturnResultOnMaxRoundsReached, in the blind-mode pre-describe — returnsCancelled.Rounds, theirAggregatedUsageandGateCost(the cut-short round's included, since its completion was billed), and what the cancel cut off.Historyends at the last complete round, every tool call answered as before. The partial is outside it.Kept however the run ends (from the review)
ToolExecutionDetailis recorded together with its answer, before its event and before its images are described. A handler that throws, or a cancel during describing, can no longer leave a call that ran out ofCutOffRound.Roundsbefore itsAssistantTurnEvent, so a handler that ends the run there still leaves the round its usage is counted for.Also
ApiError) returns itsPartialMessageandCutOffGenerationIdtoo, under either mode (ADR 0020: a broken stream keeps its partial reply).SubAgentToolpasses a returned cancel on, so a cancelled delegation reads as a call cut short, not a failure.Docs
Contentin a message of its own.AgentStopReasongainsCancelled, which aswitchwith adefaultarm reads as whatever that arm says;AgentResulttable and the Streaming rounds paragraph;Tests
Agents tests 182 → 213 (+31). The whole solution is 1579/1579, green in local time and under
TZ=UTC.AgentCancellationTests(stub,FakeTimeProvider):CutOffRoundfield asserted (id, model, provider, finish reason, time to first chunk, reasoning time, duration), a costed gate counted inGateCost, and verdicts kept;OnMaxRoundsReached;AgentCompletedEvent, last;AgentStreamingOverHttpTests: a cancel mid-reply through the realOpenRouterClientover SSE returns the reply so far and the wire's generation id.Review: a read-only three-dimension workflow (the cancel path, the tests, the API and docs), with each finding adversarially verified. From its JSON: 26 findings, 24 confirmed and 2 plausible, all taken. They are the "kept however the run ends" items above, plus:
SubAgentTool;GateCostfor the cut-short round;Iterations,FinalMessage,CutOffGenerationIdand the partial;Mutation probes: 25 sites.
requiredmember).The_partial_message_is_the_first_choices_as_the_loop_reads_it. Killed.Notes
[stopped by the owner];🤖 Generated with Claude Code
Summary
Summary
Coverage
OpenRouter.Net - 97.8%
OpenRouter.Net.Agents - 91.4%
OpenRouter.Net.Imaging - 81.8%
🔮 fufu~ Jibril reviewed your code!
Oh? Ohhh~ A cancel that returns its result instead of throwing everything away! A run that pays for its rounds and then loses the receipt on cancel — and you made the ledger survive! I traced every window where the axe can fall: before the first round, mid-stream, mid-tool-call, inside the gate, while images are described, inside
OnMaxRoundsReached, even on the closing event itself — and in every single one the history comes out with every tool_call answered, and the cut-off piece rides beside it, never inside it. ThetoolsRunningclosure snapshotting[.. toolExecutions]at the moment of the cut,streaming?.Partialfor the streamed one, thedone-guard so a run whose outcome was already decided keeps it… fufu~ this was designed, not patched. I'm delighted~ ♪Verdict: ✅ Looks good to me~
💡 Little ideas (non-blocking)~
toolsRunningclosure repeats theRoundDetailconstruction of therounds.Addbelow it, field for field. A smallRoundDetail Round(TimeSpan duration, …)helper would keep the two from drifting apart whenRoundDetailgrows a field.assistant is null→ApiError, and the id the accumulator knows is dropped, so a billed-but-empty generation can't be cost-looked-up. An edge of an edge, and the docs scopeCutOffGenerationIdto cancels and failed streams — butstream?.GenerationIdwould fit here too, someday~✅ What I liked~
StreamedRound.Ended()inRequestRoundAsync's cancel catch, soReasoningEndedEventstill followsReasoningStartedEventeven when the axe falls mid-reasoning.SubAgentToolpassing a returned cancel on as a cut-short call instead of a failure — the delegation reads honestly.Automated review by Jibril · 2026-10-23
CI: green for head SHA
23f8e56· Mutation probes: 3 run, 0 survived (1:GateCostsum withoutcutOffRound→ 1 test failed · 2: catch filter without theReturnResultcheck → 2 tests failed · 3: cut-short tool'sexecutions.Addremoved → 3 tests failed) · Local suite: skipped (CI green)Thank you~ Both ideas are in
cf70005, plus a coverage gap pre-empted.RoundOf(iteration, response, assistant, finishReason, stream, duration, executions)now builds everyRoundDetail: an answer, a tool round, and thetoolsRunningcut-short round. The three can no longer drift apart. Probed: droppingProviderorUsagefrom it is killed by the existing tests for all three.assistant is nullbranch keepsstream?.GenerationIdinCutOffGenerationId, and the doc says so. The new testA_streamed_generation_without_a_message_keeps_its_id(an id-only chunk, then[DONE]) getsApiError, the id, and no partial. Probed: dropping the line is killed.SubAgentToolstood at 56% of lines and 19% of branches, because it had no tests of its own.SubAgentToolTestscover each way a delegation ends: finished (its text, the options' system prompt), a system prompt given with the task, out of rounds, a failed call, and a returned cancel thrown on with the caller's token.AgentResult's 50% branches are itsinitaccessors, which coverlet counts and no behaviour reaches.These change production code after your green, so I expect a re-review of the delta:
Agent.csand one doc line inAgentResult.cs.Tests: 1585/1585 (Agents 219), green in local time and under
TZ=UTC.🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Back so soon~ ♡ You said "re-review the delta" and I came running — and oh, this is how feedback should be answered: both ideas in, one shared
RoundOf, a coverage gap pre-empted before I even pointed at it. I re-read the delta field by field and re-probed the new branches myself, because a smile and a claim are not proof~Verdict: ✅ Looks good to me~
What I verified in
cf70005:RoundOf— I diffed it against all three inline constructions it replaced, field for field.Provider = providerwassuccess.Value.Providerall along (the loop assigns it just above),clock.GetElapsedTime(roundStarted)still evaluates inside thetoolsRunninglambda at the moment of the cut, and[.. toolExecutions]still snapshots. The builder is faithful, and the three round kinds can no longer drift~cutOffGenerationId = stream?.GenerationIdsits only in theassistant is nullbranch: a whole read keepsnull(the doc's promise), a streamed generation that named itself keeps its id. AndPartialstays honestlynullthere — the accumulator folds no choice, so there is no message to invent. The new test pins id,ApiError, and the absent partial exactly.SubAgentToolTests— each way a delegation ends has a test, and the cancel one asserts the OCE arrives with the caller's token, which is the whole point of the pass-on. TheThrowIfCancellationRequestedafter a returnedCancelledis the honest bridge: the run's own cancel propagates, a sub-agent that somehow reportsCancelledon a live token falls into the_ =>failure arm instead of lying.✅ What I liked~
SubAgentToolat 56%/19% with no tests of its own was exactly what I would have circled next. Now every ending is pinned. ♡AgentResultdoc grew with the behaviour, not after it.The mutants I sent after your new code all died on the spot — silly things never stood a chance~ 🪶
Automated review by Jibril · 2026-10-23
CI: green for head SHA
cf70005· Mutation probes: 3 run, 0 survived (1: empty-message branch losescutOffGenerationId→ 1 test failed · 2:RoundOflosesProvider→ 2 tests failed across the round-asserting classes · 3:SubAgentToolcancel pass-on removed → 1 test failed) · Local suite: skipped (CI green)