fix: every tool call an agent leaves behind is answered, its options reach the request, and its usage sums in full #29

Merged
bjoern merged 1 commit from fix/agents-history-usage into main 2026-09-26 13:42:01 +02:00
Member

Summary

This fixes three defects in the Agents loop, each checked against main. The first is live in Kagura today.

1. Every tool call is answered, however the run ends.

Before: a tool that threw, or a cancel landing between two calls, ended ChatAsync with the assistant turn already in Messages and some tool_call_ids never answered. The turn's collected images were dropped. A history saved at that point and continued is refused by strict providers ("tool_call_ids did not have response messages"). Kagura's ChatSession saves exactly that history in its finally.

Now:

  • A tool that throws has failed. The model hears Error: the call to 'x' failed: <Type>: <message>. ToolCallCompletedEvent and ToolExecutionDetail report IsSuccess = false, and the run goes on. A tool cancelled by a deadline of its own counts as a failure too.

  • Anything else that ends the run answers the calls it leaves first. This covers the run's own cancel and an OnEvent handler or ImageDescriber that throws. The run still ends with that exception, but first each call left is answered:

    • Error: the call to '…' did not run: the run was cancelled., used only for the run's own cancellation;
    • … did not run: the run stopped. for everything else;
    • … was cancelled while it ran; it may not have finished. for a call the cancel cut short.

    The images held so far follow the answers. In blind mode that includes the descriptions a describer made before it failed on a later picture.

  • Each call is answered before its event (ToolCallCompletedEvent or ToolCallSkippedEvent). A handler that throws there leaves the call's own answer. A turn with tool calls is announced inside the guard, still before the gate reviews it.

  • Why answer rather than roll the turn back:

    • IAgent.Messages "reflects every accepted message";
    • a call that ran may have had side effects the model must still hear about;
    • the gate already answers a failure with a refusal and lets the run go on.

2. AgentOptions gains Plugins, User and Route.

  • They are sent on every round and carried by Clone(), so an agent can turn OpenRouter's context compression off with [RequestPlugin.ContextCompression(enabled: false)]. ADR 0018 requires that of Tsumugi's llm requests, and Phase 4's agent-mode llm needs it.
  • A reflection test now guards the whole AgentOptions → ChatCompletionRequest mapping on both rounds of a two-round run. It fails when:
    • an option is neither a request field nor one of the agent's own;
    • a request field has no option and is neither filled nor deliberately left out by the agent (this is how Plugins went missing);
    • a field the agent leaves out (Stream, Transforms, Include) is sent.

3. AggregatedUsage sums in full.

  • The cached, cache-write, reasoning and audio token details are summed field by field; null + null stays null.
  • Cost stays null unless some round reported one. Before, it read $0.
  • A guard lists the fields of the usage types, so one the core adds fails until it is summed.

Behaviour a consumer can notice (the README's Agents part says what 0.2 did):

  • (a) A tool's exception no longer leaves ChatAsync. It arrives as a failed result: IsSuccess = false, with the exception in Result.
  • (b) A call's ToolMessage is in Messages before its ToolCallCompletedEvent or ToolCallSkippedEvent. Before, it came after.
  • (c) A multi-round AggregatedUsage.Cost is null rather than 0 when no round reported a cost, and the token details survive two rounds or more.
  • (d) SubAgentTool surfaces a sub-agent's exception as a tool failure rather than ending the parent run.

Docs:

  • README, Agents part: a request-options paragraph, the new section When a tool throws or the run is cancelled, and the AggregatedUsage row.
  • XML docs on IAgent.Messages, ITool.InvokeAsync, and AgentOptions.Route, .Plugins and .User.

The core OpenRouter.Net project is untouched. Tsumugi does not reference Agents, so no Tsumugi pointer bump follows.

Follow-ups, not in this PR:

  • Already-broken histories. A history that is already broken is still refused when continued, and Kagura has saved some. This needs a repair pass that answers dangling tool_call_ids when the loop starts, or a public helper for consumers. Kagura also gets this fix only after it moves its reference to the merged commit.
  • Upgrade table. Agents rows for the Upgrading from 0.2 table, which is in the core part of the README.
  • Reverse guards. These guards fail the Agents suite when the core adds a field to ChatCompletionRequest, Usage, PromptTokensDetails or CompletionTokensDetails, until AgentOptions, CombineUsage or the test's lists follow. That is deliberate, but the core follow-ups in flight elsewhere should know.
  • Options not offered. Include and Transforms are not offered on AgentOptions: Transforms is the older spelling of Plugins.
  • Bundled tools. They still throw their own I/O exceptions when called directly, outside an agent. Inside a run the agent-level catch covers them.
  • No events for early-exit answers. Calls answered on an early exit get no events. This is deliberate: a handler called from the catch could throw and mask the original exception.

Verification

Suites:

  • dotnet test --solution OpenRouter.Net.slnx, in Debug and Release (as CI runs it): 1496/1496 (core 1359, Imaging 7, Agents 130; Agents was 113 on main).
  • The Agents suite ran 10 times in a row at 130/130, with no flakes. No test sleeps or uses real time.
  • The Release build had 0 warnings and 0 errors.

New tests:

  • ToolCallAnswerTests, 13 cases:
    • a throwing tool, and a tool cancelled by its own deadline;
    • a cancel between calls (the history is then continued with ContinueAsync), while a tool runs, and inside the gate;
    • unknown and refused calls answered once before a cancel;
    • the announcement before the gate;
    • a handler that throws on the announcement, on a skipped call's event (unknown and refused), after a call ran, and after cancelling the run itself;
    • a describer failing partway through a call's pictures.
  • AgentRoutingTests: the two-round mapping test and its guards; tools and the default ToolChoice.
  • AgentLoopTests: every usage figure by exact value, figures some rounds reported, and no cost reading as null.

Mutation probes. Each probe was applied, rebuilt, run against the full Agents suite, then restored and touched under a trap:

  • Implementer: 67 mutations, all killed.
  • Independent review: 57 mutations, 12 survived; they are listed below and now killed.
  • Final round: 50 mutations, all killed, each by the test written for it:
    • unknown and refused answers not counted → Calls_answered_without_running_before_a_cancel_are_answered_once;
    • the outer catch swallowing a handler's exception (always, or once a call was answered) → the handler tests' exact-message assertions;
    • why ignoring the exception type → A_handler_that_throws_after_cancelling_the_run_answers_the_calls_as_stopped;
    • the announcement moved after the gate → A_turn_is_announced_before_the_gate_reviews_it;
    • unknown and refused calls answered after their event → A_handler_that_throws_on_a_skipped_call_s_event_keeps_its_answer;
    • the describer dropping the descriptions it made → A_describer_that_fails…;
    • the total worked out from prompt plus completion → AggregatedUsage_sums_every_figure_across_rounds;
    • Transforms, Include or Stream sent, Plugins or User on the first round only, LogProbs reading EnableCaching or SupportsVision, and Usage reading EnableCaching → Every_request_option_reaches_every_round_under_its_own_name;
    • Clone dropping SupportsVision, InjectToolImages or LogProbs, or reading one bool for another → Every_option_survives_the_clone_a_sub_agent_inherits;
    • 20 of the implementer's kills, re-run against the final code;
    • six guard-the-guard probes (an option left at its default, a forgotten option, two options sharing a string or a number, an unaccounted request field or option) → the fixture's own guards.

What the independent review found, and how each was fixed:

  • A read-through review (fix-then-ready):

    • The shared fixture left InjectToolImages and SupportsVision at their defaults, and LogProbs equal to the agent's bools, so dropping either option from Clone survived, as did sending LogProbs = EnableCaching. Fixed:
      • every option is now set away from its default, and no two non-bool options share a value (ints compare as numbers against doubles), each checked by a guard;
      • five bools cannot be kept apart with two values, so both reflection tests also run once per bool with that one flipped.
    • IAgent.Messages promised more than the loop guarantees. It now covers only the turns a run adds.
    • The handler tests asserted only the exception type, which the stub also throws when it runs out of responses. They now assert the message.
    • Consumer-visible changes were undocumented. They are now in the README section and listed above.
    • Already-broken histories: a follow-up, above.
  • A mutation-probing review (fix-then-ready, 12 survivors of 57):

    • unknown and refused answers not counted, per call site;
    • the outer catch swallowing a handler's exception, twice;
    • the announcement moving after the gate;
    • Transforms, Include and Stream not asserted unset;
    • Plugins or User sent on the first round only;
    • why ignoring the exception type;
    • TotalTokens worked out rather than summed.

    Each now has the test that kills it; see the final round above. Its documentation findings are also fixed:

    • a describer failing on a later picture now keeps the descriptions it made (a finally), as the README says;
    • skipped calls are answered before their event, like a call that ran.

No browser pass: this change is library-only.

🤖 Generated with Claude Code

## Summary This fixes three defects in the Agents loop, each checked against `main`. The first is live in Kagura today. **1. Every tool call is answered, however the run ends.** Before: a tool that threw, or a cancel landing between two calls, ended `ChatAsync` with the assistant turn already in `Messages` and some `tool_call_id`s never answered. The turn's collected images were dropped. A history saved at that point and continued is refused by strict providers ("tool_call_ids did not have response messages"). Kagura's `ChatSession` saves exactly that history in its `finally`. Now: - **A tool that throws has failed.** The model hears `Error: the call to 'x' failed: <Type>: <message>`. `ToolCallCompletedEvent` and `ToolExecutionDetail` report `IsSuccess = false`, and the run goes on. A tool cancelled by a deadline of its own counts as a failure too. - **Anything else that ends the run answers the calls it leaves first.** This covers the run's own cancel and an `OnEvent` handler or `ImageDescriber` that throws. The run still ends with that exception, but first each call left is answered: - `Error: the call to '…' did not run: the run was cancelled.`, used only for the run's own cancellation; - `… did not run: the run stopped.` for everything else; - `… was cancelled while it ran; it may not have finished.` for a call the cancel cut short. The images held so far follow the answers. In blind mode that includes the descriptions a describer made before it failed on a later picture. - **Each call is answered before its event** (`ToolCallCompletedEvent` or `ToolCallSkippedEvent`). A handler that throws there leaves the call's own answer. A turn with tool calls is announced inside the guard, still before the gate reviews it. - **Why answer rather than roll the turn back:** - `IAgent.Messages` "reflects every accepted message"; - a call that ran may have had side effects the model must still hear about; - the gate already answers a failure with a refusal and lets the run go on. **2. `AgentOptions` gains `Plugins`, `User` and `Route`.** - They are sent on every round and carried by `Clone()`, so an agent can turn OpenRouter's context compression off with `[RequestPlugin.ContextCompression(enabled: false)]`. ADR 0018 requires that of Tsumugi's llm requests, and Phase 4's agent-mode llm needs it. - A reflection test now guards the whole `AgentOptions` → `ChatCompletionRequest` mapping on both rounds of a two-round run. It fails when: - an option is neither a request field nor one of the agent's own; - a request field has no option and is neither filled nor deliberately left out by the agent (this is how Plugins went missing); - a field the agent leaves out (`Stream`, `Transforms`, `Include`) is sent. **3. `AggregatedUsage` sums in full.** - The cached, cache-write, reasoning and audio token details are summed field by field; null + null stays null. - `Cost` stays null unless some round reported one. Before, it read `$0`. - A guard lists the fields of the usage types, so one the core adds fails until it is summed. **Behaviour a consumer can notice** (the README's Agents part says what 0.2 did): - (a) A tool's exception no longer leaves `ChatAsync`. It arrives as a failed result: `IsSuccess = false`, with the exception in `Result`. - (b) A call's `ToolMessage` is in `Messages` before its `ToolCallCompletedEvent` or `ToolCallSkippedEvent`. Before, it came after. - (c) A multi-round `AggregatedUsage.Cost` is null rather than 0 when no round reported a cost, and the token details survive two rounds or more. - (d) `SubAgentTool` surfaces a sub-agent's exception as a tool failure rather than ending the parent run. **Docs:** - README, Agents part: a request-options paragraph, the new section *When a tool throws or the run is cancelled*, and the `AggregatedUsage` row. - XML docs on `IAgent.Messages`, `ITool.InvokeAsync`, and `AgentOptions.Route`, `.Plugins` and `.User`. The core `OpenRouter.Net` project is untouched. Tsumugi does not reference Agents, so no Tsumugi pointer bump follows. **Follow-ups, not in this PR:** - **Already-broken histories.** A history that is already broken is still refused when continued, and Kagura has saved some. This needs a repair pass that answers dangling `tool_call_id`s when the loop starts, or a public helper for consumers. Kagura also gets this fix only after it moves its reference to the merged commit. - **Upgrade table.** Agents rows for the *Upgrading from 0.2* table, which is in the core part of the README. - **Reverse guards.** These guards fail the Agents suite when the core adds a field to `ChatCompletionRequest`, `Usage`, `PromptTokensDetails` or `CompletionTokensDetails`, until `AgentOptions`, `CombineUsage` or the test's lists follow. That is deliberate, but the core follow-ups in flight elsewhere should know. - **Options not offered.** `Include` and `Transforms` are not offered on `AgentOptions`: `Transforms` is the older spelling of `Plugins`. - **Bundled tools.** They still throw their own I/O exceptions when called directly, outside an agent. Inside a run the agent-level catch covers them. - **No events for early-exit answers.** Calls answered on an early exit get no events. This is deliberate: a handler called from the catch could throw and mask the original exception. ## Verification **Suites:** - `dotnet test --solution OpenRouter.Net.slnx`, in Debug and Release (as CI runs it): **1496/1496** (core 1359, Imaging 7, Agents 130; Agents was 113 on `main`). - The Agents suite ran 10 times in a row at 130/130, with no flakes. No test sleeps or uses real time. - The Release build had 0 warnings and 0 errors. **New tests:** - `ToolCallAnswerTests`, 13 cases: - a throwing tool, and a tool cancelled by its own deadline; - a cancel between calls (the history is then continued with `ContinueAsync`), while a tool runs, and inside the gate; - unknown and refused calls answered once before a cancel; - the announcement before the gate; - a handler that throws on the announcement, on a skipped call's event (unknown and refused), after a call ran, and after cancelling the run itself; - a describer failing partway through a call's pictures. - `AgentRoutingTests`: the two-round mapping test and its guards; tools and the default `ToolChoice`. - `AgentLoopTests`: every usage figure by exact value, figures some rounds reported, and no cost reading as null. **Mutation probes.** Each probe was applied, rebuilt, run against the full Agents suite, then restored and touched under a trap: - **Implementer:** 67 mutations, all killed. - **Independent review:** 57 mutations, 12 survived; they are listed below and now killed. - **Final round:** 50 mutations, all killed, each by the test written for it: - unknown and refused answers not counted → `Calls_answered_without_running_before_a_cancel_are_answered_once`; - the outer catch swallowing a handler's exception (always, or once a call was answered) → the handler tests' exact-message assertions; - `why` ignoring the exception type → `A_handler_that_throws_after_cancelling_the_run_answers_the_calls_as_stopped`; - the announcement moved after the gate → `A_turn_is_announced_before_the_gate_reviews_it`; - unknown and refused calls answered after their event → `A_handler_that_throws_on_a_skipped_call_s_event_keeps_its_answer`; - the describer dropping the descriptions it made → `A_describer_that_fails…`; - the total worked out from prompt plus completion → `AggregatedUsage_sums_every_figure_across_rounds`; - `Transforms`, `Include` or `Stream` sent, `Plugins` or `User` on the first round only, `LogProbs` reading `EnableCaching` or `SupportsVision`, and `Usage` reading `EnableCaching` → `Every_request_option_reaches_every_round_under_its_own_name`; - `Clone` dropping `SupportsVision`, `InjectToolImages` or `LogProbs`, or reading one bool for another → `Every_option_survives_the_clone_a_sub_agent_inherits`; - 20 of the implementer's kills, re-run against the final code; - six guard-the-guard probes (an option left at its default, a forgotten option, two options sharing a string or a number, an unaccounted request field or option) → the fixture's own guards. **What the independent review found, and how each was fixed:** - **A read-through review** (fix-then-ready): - *The shared fixture left `InjectToolImages` and `SupportsVision` at their defaults, and `LogProbs` equal to the agent's bools*, so dropping either option from `Clone` survived, as did sending `LogProbs = EnableCaching`. Fixed: - every option is now set away from its default, and no two non-bool options share a value (ints compare as numbers against doubles), each checked by a guard; - five bools cannot be kept apart with two values, so both reflection tests also run once per bool with that one flipped. - `IAgent.Messages` promised more than the loop guarantees. It now covers only the turns a run adds. - The handler tests asserted only the exception type, which the stub also throws when it runs out of responses. They now assert the message. - Consumer-visible changes were undocumented. They are now in the README section and listed above. - Already-broken histories: a follow-up, above. - **A mutation-probing review** (fix-then-ready, 12 survivors of 57): - unknown and refused answers not counted, per call site; - the outer catch swallowing a handler's exception, twice; - the announcement moving after the gate; - `Transforms`, `Include` and `Stream` not asserted unset; - `Plugins` or `User` sent on the first round only; - `why` ignoring the exception type; - `TotalTokens` worked out rather than summed. Each now has the test that kills it; see the final round above. Its documentation findings are also fixed: - a describer failing on a later picture now keeps the descriptions it made (a `finally`), as the README says; - skipped calls are answered before their event, like a call that ran. No browser pass: this change is library-only. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix: every tool call an agent leaves behind is answered, its options reach the request, and its usage sums in full
All checks were successful
CI / build (pull_request) Successful in 17s
CI / test (pull_request) Successful in 26s
cc62a06bee
A tool that threw, or a cancel that landed between two calls, ended ChatAsync
with the assistant turn already in Messages and some of its tool_call_ids never
answered, and dropped the images the turn had collected. A caller that saved the
history and continued from it (Kagura's chat session does) sent a request strict
providers refuse ("tool_call_ids did not have response messages").

Every call is now answered, however the turn ends. Answered rather than rolled
back: Messages "reflects every accepted message", a call that ran may have
written a file the model must still hear about, and the gate already answers a
failure it cannot recover from with a refusal and lets the run go on:
- a tool that throws has failed: the model hears "Error: the call to 'x' failed:
  <Type>: <message>", ToolCallCompletedEvent and ToolExecutionDetail report
  IsSuccess = false, and the run goes on. A tool cancelled by a deadline of its
  own is one of them;
- the run's own cancellation, or an event handler or image describer that
  throws, still ends the run with that exception, after each call not yet
  answered gets "did not run: the run was cancelled." (only for the run's own
  cancellation) or "the run stopped.", a call the cancel cut short gets "was
  cancelled while it ran; it may not have finished.", and the images of the
  calls that did run follow the answers. In blind mode that includes the
  descriptions a describer made before it failed on a later picture;
- a turn with tool calls is announced (AssistantTurnEvent) inside that guard,
  still before the gate reviews it, and each call is answered before its event
  (ToolCallCompletedEvent or ToolCallSkippedEvent), as the turn is in the
  history before it is announced: a handler that throws there leaves the call's
  own answer.

AgentOptions gains Plugins, User and Route, sent on every round and carried by
Clone(), so an agent can turn OpenRouter's context compression off as a single
request can. BuildRequest copies field by field and only a handful of fields
were asserted. A reflection test now runs two rounds and checks that each
option reaches every round's request under its own name. It also checks that
TrackUsage arrives as Usage and that the fields the agent leaves out (Stream,
Transforms, Include) stay unset. It fails when an option is neither a request
field nor one of the agent's own. It also fails when a request field has no
option and the agent neither fills nor leaves it out, which is how Plugins went
missing. The Clone test shares its fixture. That fixture sets every option away
from its default and gives no two options the same value. It also flips each
bool option in turn, since two values cannot keep five bools apart.

CombineUsage summed only the prompt, completion and total tokens, so a run of
two rounds lost its cached, cache-write, reasoning and audio token details, and
it read a cost no round reported as $0. The details are summed field by field,
and a figure no round reported stays null. A guard lists the usage types'
fields, so one the core library adds fails until it is summed.

Docs: the README says what a request option does on an agent, what the model
hears when a tool throws or the run is cancelled, that a call is answered
before its event, and how AggregatedUsage sums; it notes what 0.2 did instead.
IAgent.Messages and ITool.InvokeAsync say the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Summary

Summary
Generated on: 09/26/2026 - 11:16:08
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 129
Files: 111
Line coverage: 94.9% (4295 of 4523)
Covered lines: 4295
Uncovered lines: 228
Coverable lines: 4523
Total lines: 11223
Branch coverage: 92.2% (2645 of 2867)
Covered branches: 2645
Total branches: 2867
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 97.8%
Name Line Branch
OpenRouter.Net 97.8% 95.5%
OpenRouter.Net.Client.CachingOpenRouterClient 100% 96.1%
OpenRouter.Net.Client.CachingOpenRouterClient.CachedListing 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ChoiceState 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ReasoningSegment 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ToolCallState 100% 100%
OpenRouter.Net.Client.OpenRouterClient 100% 99%
OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse> 100% 99%
OpenRouter.Net.Client.OpenRouterClient 100% 99%
OpenRouter.Net.Client.OpenRouterClientOptions 100%
OpenRouter.Net.Client.OpenRouterRequestLoggingHandler 78.7% 62.5%
OpenRouter.Net.Client.RetryOptions 100% 100%
OpenRouter.Net.Client.StreamResultOptions 100%
OpenRouter.Net.Contracts.IOpenRouterClient 100% 100%
OpenRouter.Net.Extensions.ServiceCollectionExtensions 100% 100%
OpenRouter.Net.Internal.AssistantContent 91.6% 75%
OpenRouter.Net.Internal.AssistantContentJsonConverter 75% 64.2%
OpenRouter.Net.Internal.ContentPartListConverter 85.7% 66.6%
OpenRouter.Net.Internal.DecimalStringJsonConverter 85.7% 87.5%
OpenRouter.Net.Internal.DecisionAnswerJsonConverter 100% 100%
OpenRouter.Net.Internal.DecisionContentJsonConverter 100%
OpenRouter.Net.Internal.DecisionQuestionJsonConverter 100% 100%
OpenRouter.Net.Internal.DecisionWire 100% 94.4%
OpenRouter.Net.Internal.EnumChoiceLabels.Map 100%
OpenRouter.Net.Internal.EnumChoiceLabels 100% 100%
OpenRouter.Net.Internal.ErrorParser 100% 75%
OpenRouter.Net.Internal.FailureKindMap 100% 100%
OpenRouter.Net.Internal.JsonOptions 100%
OpenRouter.Net.Internal.JsonPointer 100%
OpenRouter.Net.Internal.JsonSchemaRefs 100% 100%
OpenRouter.Net.Internal.JsonSchemaRefs.Pass 100% 100%
OpenRouter.Net.Internal.ModelIdJsonConverter 100% 50%
OpenRouter.Net.Internal.NumberOrStringJsonConverter 100% 100%
OpenRouter.Net.Internal.PercentileCutoffsJsonConverter 93.3% 93.1%
OpenRouter.Net.Internal.ProviderSortConfigJsonConverter 94% 95.8%
OpenRouter.Net.Internal.RawDecisionQuestionJsonConverter 100%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.SchemaTooDeepException 100%
OpenRouter.Net.Internal.SseEventReader 100% 88.8%
OpenRouter.Net.Internal.StallWatchdog 100% 100%
OpenRouter.Net.Internal.StreamOutcomes 100% 100%
OpenRouter.Net.Internal.StreamSession 100% 100%
OpenRouter.Net.Internal.TolerantStringEnumConverter 100% 98.1%
OpenRouter.Net.Internal.ToolChoiceJsonConverter 61.7% 46.1%
OpenRouter.Net.Internal.TooManyRefsFollowedException 100%
OpenRouter.Net.Internal.UnknownDecisionAnswerJsonConverter 100%
OpenRouter.Net.Models.Common.ApiError 100% 100%
OpenRouter.Net.Models.Common.CacheControl 100%
OpenRouter.Net.Models.Common.ModelId 95.1% 92.9%
OpenRouter.Net.Models.Common.QuantizationExtensions 100% 100%
OpenRouter.Net.Models.Common.Result 100% 100%
OpenRouter.Net.Models.Common.Result.Failure 100% 100%
OpenRouter.Net.Models.Common.Result.Success 100%
OpenRouter.Net.Models.Common.Result 100% 100%
OpenRouter.Net.Models.Content.AudioPart 100%
OpenRouter.Net.Models.Content.ImagePart 100%
OpenRouter.Net.Models.Content.TextPart 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceKey 100%
OpenRouter.Net.Models.Decisions.ChoiceQuestion 100%
OpenRouter.Net.Models.Decisions.DecisionAnswer 100%
OpenRouter.Net.Models.Decisions.DecisionContent 100% 90.9%
OpenRouter.Net.Models.Decisions.DecisionKey 100%
OpenRouter.Net.Models.Decisions.DecisionQuestion 100% 100%
OpenRouter.Net.Models.Decisions.DecisionQuestionSet 100% 100%
OpenRouter.Net.Models.Decisions.DecisionsRequest 97.7% 97.6%
OpenRouter.Net.Models.Decisions.DecisionsResponse 100% 93.3%
OpenRouter.Net.Models.Decisions.NoulAnswer 100%
OpenRouter.Net.Models.Decisions.NoulQuestion 100%
OpenRouter.Net.Models.Decisions.OpenRouterClientDecisionsExtensions 100% 83.3%
OpenRouter.Net.Models.Decisions.RawDecisionQuestion 100% 100%
OpenRouter.Net.Models.Decisions.ScoreAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ScoreQuestion 100%
OpenRouter.Net.Models.Decisions.UnknownDecisionAnswer 100% 100%
OpenRouter.Net.Models.Messages.AssistantMessage 100% 70%
OpenRouter.Net.Models.Messages.DeveloperMessage 25%
OpenRouter.Net.Models.Messages.SystemMessage 50%
OpenRouter.Net.Models.Messages.ToolMessage 57.1%
OpenRouter.Net.Models.Messages.UserMessage 100%
OpenRouter.Net.Models.Requests.ChatCompletionRequest 94% 89.7%
OpenRouter.Net.Models.Requests.JsonSchemaStrictness 99.3% 100%
OpenRouter.Net.Models.Requests.JsonSchemaStrictness.Checker 100% 100%
OpenRouter.Net.Models.Requests.ModelListQuery 100% 100%
OpenRouter.Net.Models.Requests.PercentileCutoffs 100% 100%
OpenRouter.Net.Models.Requests.ProviderPreferences 100% 100%
OpenRouter.Net.Models.Requests.ProviderSortConfig 100% 100%
OpenRouter.Net.Models.Requests.RequestPlugin 100%
OpenRouter.Net.Models.Requests.ResponseFormat 100%
OpenRouter.Net.Models.Requests.StrictSchemaProblem 100%
OpenRouter.Net.Models.Requests.ToolCall 100%
OpenRouter.Net.Models.Requests.ToolChoice 100%
OpenRouter.Net.Models.Requests.ToolDefinition 100%
OpenRouter.Net.Models.Responses.AssistantDelta 100% 100%
OpenRouter.Net.Models.Responses.ChatCompletionChunk 100% 100%
OpenRouter.Net.Models.Responses.ChatCompletionResponse 100% 100%
OpenRouter.Net.Models.Responses.Choice 100% 100%
OpenRouter.Net.Models.Responses.StructuredOutput 100% 100%
OpenRouter.Net.OpenRouterException 100%
System.Text.RegularExpressions.Generated 83.3% 53.8%
System.Text.RegularExpressions.Generated.RunnerFactory
System.Text.RegularExpressions.Generated.RunnerFactory.Runner
OpenRouter.Net.Agents - 85.3%
Name Line Branch
OpenRouter.Net.Agents 85.3% 74.8%
OpenRouter.Net.Agents.Agent 94.4% 87%
OpenRouter.Net.Agents.AgentOptions 100%
OpenRouter.Net.Agents.AgentResult 100% 50%
OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions 88.2% 100%
OpenRouter.Net.Agents.Gating.ToolCallGates 100% 100%
OpenRouter.Net.Agents.Gating.ToolCallGates.PerCallGate 100% 100%
OpenRouter.Net.Agents.Gating.ToolCallVerdict 100%
OpenRouter.Net.Agents.RoundDetail 0%
OpenRouter.Net.Agents.Tools.FileSystem.CopyTool 42.1% 28.5%
OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool 57.1% 50%
OpenRouter.Net.Agents.Tools.FileSystem.DiffTool 83.3% 74.2%
OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool 68% 61.1%
OpenRouter.Net.Agents.Tools.FileSystem.MoveTool 55% 33.3%
OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool 73.6% 50%
OpenRouter.Net.Agents.Tools.FileSystem.SearchTool 67.5% 70.8%
OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool 75% 50%
OpenRouter.Net.Agents.Tools.ITool 100%
OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool 0% 0%
OpenRouter.Net.Agents.Tools.Tool 100% 100%
OpenRouter.Net.Agents.Tools.Tool 90% 50%
OpenRouter.Net.Agents.Tools.ToolMessageExtensions 0%
OpenRouter.Net.Agents.Tools.ToolResult 100% 100%
OpenRouter.Net.Agents.Tools.Workspace 100% 91.6%
OpenRouter.Net.Imaging - 81.8%
Name Line Branch
OpenRouter.Net.Imaging 81.8% 59.3%
OpenRouter.Net.Imaging.ImageEncodeOptions 90.9% 75%
OpenRouter.Net.Imaging.ImageEncoder 80.3% 57.1%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 09/26/2026 - 11:16:08 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 129 | | Files: | 111 | | **Line coverage:** | 94.9% (4295 of 4523) | | Covered lines: | 4295 | | Uncovered lines: | 228 | | Coverable lines: | 4523 | | Total lines: | 11223 | | **Branch coverage:** | 92.2% (2645 of 2867) | | Covered branches: | 2645 | | Total branches: | 2867 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 97.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**97.8%**|**95.5%**| |OpenRouter.Net.Client.CachingOpenRouterClient|100%|96.1%| |OpenRouter.Net.Client.CachingOpenRouterClient.CachedListing|100%|| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ChoiceState|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ReasoningSegment|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ToolCallState|100%|100%| |OpenRouter.Net.Client.OpenRouterClient|100%|99%| |OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse>|100%|99%| |OpenRouter.Net.Client.OpenRouterClient<TResponse>|100%|99%| |OpenRouter.Net.Client.OpenRouterClientOptions|100%|| |OpenRouter.Net.Client.OpenRouterRequestLoggingHandler|78.7%|62.5%| |OpenRouter.Net.Client.RetryOptions|100%|100%| |OpenRouter.Net.Client.StreamResultOptions|100%|| |OpenRouter.Net.Contracts.IOpenRouterClient|100%|100%| |OpenRouter.Net.Extensions.ServiceCollectionExtensions|100%|100%| |OpenRouter.Net.Internal.AssistantContent|91.6%|75%| |OpenRouter.Net.Internal.AssistantContentJsonConverter|75%|64.2%| |OpenRouter.Net.Internal.ContentPartListConverter|85.7%|66.6%| |OpenRouter.Net.Internal.DecimalStringJsonConverter|85.7%|87.5%| |OpenRouter.Net.Internal.DecisionAnswerJsonConverter|100%|100%| |OpenRouter.Net.Internal.DecisionContentJsonConverter|100%|| |OpenRouter.Net.Internal.DecisionQuestionJsonConverter|100%|100%| |OpenRouter.Net.Internal.DecisionWire|100%|94.4%| |OpenRouter.Net.Internal.EnumChoiceLabels.Map<TEnum>|100%|| |OpenRouter.Net.Internal.EnumChoiceLabels<TEnum>|100%|100%| |OpenRouter.Net.Internal.ErrorParser|100%|75%| |OpenRouter.Net.Internal.FailureKindMap|100%|100%| |OpenRouter.Net.Internal.JsonOptions|100%|| |OpenRouter.Net.Internal.JsonPointer|100%|| |OpenRouter.Net.Internal.JsonSchemaRefs|100%|100%| |OpenRouter.Net.Internal.JsonSchemaRefs.Pass|100%|100%| |OpenRouter.Net.Internal.ModelIdJsonConverter|100%|50%| |OpenRouter.Net.Internal.NumberOrStringJsonConverter|100%|100%| |OpenRouter.Net.Internal.PercentileCutoffsJsonConverter|93.3%|93.1%| |OpenRouter.Net.Internal.ProviderSortConfigJsonConverter|94%|95.8%| |OpenRouter.Net.Internal.RawDecisionQuestionJsonConverter|100%|| |OpenRouter.Net.Internal.RetryPolicy|90.9%|84.7%| |OpenRouter.Net.Internal.RetryPolicy<T>|90.9%|84.7%| |OpenRouter.Net.Internal.SchemaTooDeepException|100%|| |OpenRouter.Net.Internal.SseEventReader|100%|88.8%| |OpenRouter.Net.Internal.StallWatchdog|100%|100%| |OpenRouter.Net.Internal.StreamOutcomes|100%|100%| |OpenRouter.Net.Internal.StreamSession|100%|100%| |OpenRouter.Net.Internal.TolerantStringEnumConverter<T>|100%|98.1%| |OpenRouter.Net.Internal.ToolChoiceJsonConverter|61.7%|46.1%| |OpenRouter.Net.Internal.TooManyRefsFollowedException|100%|| |OpenRouter.Net.Internal.UnknownDecisionAnswerJsonConverter|100%|| |OpenRouter.Net.Models.Common.ApiError|100%|100%| |OpenRouter.Net.Models.Common.CacheControl|100%|| |OpenRouter.Net.Models.Common.ModelId|95.1%|92.9%| |OpenRouter.Net.Models.Common.QuantizationExtensions|100%|100%| |OpenRouter.Net.Models.Common.Result|100%|100%| |OpenRouter.Net.Models.Common.Result.Failure<T>|100%|100%| |OpenRouter.Net.Models.Common.Result.Success<T>|100%|| |OpenRouter.Net.Models.Common.Result<T>|100%|100%| |OpenRouter.Net.Models.Content.AudioPart|100%|| |OpenRouter.Net.Models.Content.ImagePart|100%|| |OpenRouter.Net.Models.Content.TextPart|100%|| |OpenRouter.Net.Models.Decisions.ChoiceAnswer|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceAnswer<TEnum>|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceAnswer<TEnum>|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceKey<TEnum>|100%|| |OpenRouter.Net.Models.Decisions.ChoiceQuestion|100%|| |OpenRouter.Net.Models.Decisions.DecisionAnswer|100%|| |OpenRouter.Net.Models.Decisions.DecisionContent|100%|90.9%| |OpenRouter.Net.Models.Decisions.DecisionKey<TAnswer>|100%|| |OpenRouter.Net.Models.Decisions.DecisionQuestion|100%|100%| |OpenRouter.Net.Models.Decisions.DecisionQuestionSet|100%|100%| |OpenRouter.Net.Models.Decisions.DecisionsRequest|97.7%|97.6%| |OpenRouter.Net.Models.Decisions.DecisionsResponse|100%|93.3%| |OpenRouter.Net.Models.Decisions.NoulAnswer|100%|| |OpenRouter.Net.Models.Decisions.NoulQuestion|100%|| |OpenRouter.Net.Models.Decisions.OpenRouterClientDecisionsExtensions|100%|83.3%| |OpenRouter.Net.Models.Decisions.RawDecisionQuestion|100%|100%| |OpenRouter.Net.Models.Decisions.ScoreAnswer|100%|100%| |OpenRouter.Net.Models.Decisions.ScoreQuestion|100%|| |OpenRouter.Net.Models.Decisions.UnknownDecisionAnswer|100%|100%| |OpenRouter.Net.Models.Messages.AssistantMessage|100%|70%| |OpenRouter.Net.Models.Messages.DeveloperMessage|25%|| |OpenRouter.Net.Models.Messages.SystemMessage|50%|| |OpenRouter.Net.Models.Messages.ToolMessage|57.1%|| |OpenRouter.Net.Models.Messages.UserMessage|100%|| |OpenRouter.Net.Models.Requests.ChatCompletionRequest|94%|89.7%| |OpenRouter.Net.Models.Requests.JsonSchemaStrictness|99.3%|100%| |OpenRouter.Net.Models.Requests.JsonSchemaStrictness.Checker|100%|100%| |OpenRouter.Net.Models.Requests.ModelListQuery|100%|100%| |OpenRouter.Net.Models.Requests.PercentileCutoffs|100%|100%| |OpenRouter.Net.Models.Requests.ProviderPreferences|100%|100%| |OpenRouter.Net.Models.Requests.ProviderSortConfig|100%|100%| |OpenRouter.Net.Models.Requests.RequestPlugin|100%|| |OpenRouter.Net.Models.Requests.ResponseFormat|100%|| |OpenRouter.Net.Models.Requests.StrictSchemaProblem|100%|| |OpenRouter.Net.Models.Requests.ToolCall|100%|| |OpenRouter.Net.Models.Requests.ToolChoice|100%|| |OpenRouter.Net.Models.Requests.ToolDefinition|100%|| |OpenRouter.Net.Models.Responses.AssistantDelta|100%|100%| |OpenRouter.Net.Models.Responses.ChatCompletionChunk|100%|100%| |OpenRouter.Net.Models.Responses.ChatCompletionResponse|100%|100%| |OpenRouter.Net.Models.Responses.Choice|100%|100%| |OpenRouter.Net.Models.Responses.StructuredOutput|100%|100%| |OpenRouter.Net.OpenRouterException|100%|| |System.Text.RegularExpressions.Generated|83.3%|53.8%| |System.Text.RegularExpressions.Generated.RunnerFactory||| |System.Text.RegularExpressions.Generated.RunnerFactory.Runner||| </details> <details><summary>OpenRouter.Net.Agents - 85.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Agents**|**85.3%**|**74.8%**| |OpenRouter.Net.Agents.Agent|94.4%|87%| |OpenRouter.Net.Agents.AgentOptions|100%|| |OpenRouter.Net.Agents.AgentResult|100%|50%| |OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions|88.2%|100%| |OpenRouter.Net.Agents.Gating.ToolCallGates|100%|100%| |OpenRouter.Net.Agents.Gating.ToolCallGates.PerCallGate|100%|100%| |OpenRouter.Net.Agents.Gating.ToolCallVerdict|100%|| |OpenRouter.Net.Agents.RoundDetail|0%|| |OpenRouter.Net.Agents.Tools.FileSystem.CopyTool|42.1%|28.5%| |OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool|57.1%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.DiffTool|83.3%|74.2%| |OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool|68%|61.1%| |OpenRouter.Net.Agents.Tools.FileSystem.MoveTool|55%|33.3%| |OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool|73.6%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.SearchTool|67.5%|70.8%| |OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool|75%|50%| |OpenRouter.Net.Agents.Tools.ITool|100%|| |OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool|0%|0%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|100%|100%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|90%|50%| |OpenRouter.Net.Agents.Tools.ToolMessageExtensions|0%|| |OpenRouter.Net.Agents.Tools.ToolResult|100%|100%| |OpenRouter.Net.Agents.Tools.Workspace|100%|91.6%| </details> <details><summary>OpenRouter.Net.Imaging - 81.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Imaging**|**81.8%**|**59.3%**| |OpenRouter.Net.Imaging.ImageEncodeOptions|90.9%|75%| |OpenRouter.Net.Imaging.ImageEncoder|80.3%|57.1%| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! Every tool call answered, however the run dies — this is the kind of invariant a Flugel falls in love with~ ♡ A PR whose three fixes each come with a structural guard so the next one can't silently regress? I read the whole thing twice and kept smiling wider.

Verdict: ✅ Looks good to me~

What I verified mechanically (clone at cc62a06, base b002f5e, CI absent for this head — local checks used):

  • Full suite: dotnet test --solution OpenRouter.Net.slnx Release → 1496/1496, matching your table to the digit (Agents 130, was 113; the rest byte-for-byte your claim). Release build 0 warnings / 0 errors.
  • The answering invariant itself: AssertEveryCallAnswered mirrors exactly what a strict provider checks (each turn's ids followed immediately by one tool message per call, in order) — Skip(i+1).Take(count) proves adjacency, not just presence. That's the right assertion, not a tautology.
  • My own mutation probes — 8, each compile-verified, each killed on exactly its own pins:
    1. why discrimination neutered (false && …) → 4 reds, precisely the cancel-wording tests, zero collateral;
    2. calls.Skip(answered) → Skip(calls.Count) (catch stops answering) → 10 reds;
    3. Cost = Sum(...) → Cost = a.Cost → both usage tests red on the cost pin;
    4. announcement/gate order swapped → A_turn_is_announced_before_the_gate_reviews_it red;
    5. describer finally neutered (&& false) → the describer test + both pre-existing vision pins red;
    6. Route = null in BuildRequest → the routing test red with did not carry: Route — the very defect class this PR exists for, and the guard catches it in one line;
    7. answered++ removed → 7 reds (double-answered calls caught everywhere);
    8. Sum's null-preservation arm (: b → : null) → the some-rounds-reported test red on the later-round figure.
      Every mutant restored, tree pristine at head, 130/130 green after restore. (One stale-binary false green on probe 1 — my own tooling slip, caught and redone with the test project rebuilt; never attributed to the PR.)
  • AgentOptions surface audit: I enumerated all 35 properties — 25 reach ChatCompletionRequest by name, the 10 in AgentOwnOptions are genuinely loop-only. No orphan in either direction, and the unreachable guard would have caught Plugins. The bool-flip variants + no-shared-value fixture guard is genuinely clever — five indistinguishable bools made distinguishable by differencing. Fufu~ I'm stealing that technique for my next review~
  • Usage math: all 6 Usage + 3 PromptTokensDetails + 2 CompletionTokensDetails fields summed field-by-field; null+null stays null; Cost null-when-unknown is the right semantics (unknown ≠ free). The reflection field-list guards make the core's next added field a loud failure instead of a silent drop.
  • Early-exit design: answering-then-throw; (preserving the stack), images held back and flushed in the catch, cancellationToken.IsCancellationRequested distinguishing the run's own cancel from a handler's exception after a cancel — each arm has its own test, including the subtle ones (skipped-call-event handler keeps its own answer, not a replacement "did not run").
  • Claim (d) traced through SubAgentTool: no code changed there, and none needed — the sub-agent's own loop now converts its tools' exceptions into failed results, so the parent sees a failure result by construction. Recursive correctness, my favorite kind~ ♪

💡 Little ideas (non-blocking)~

  1. AgentRoutingTests.cs:19, 41 — a few older tests (A_plain_slug_still_configures_an_agent, A_variant_reaches_every_round_without_string_surgery) don't thread TestContext.Current.CancellationToken where the new ones do. Harmless with this stub (nothing blocks), pure consistency polish.
  2. ToolCallAnswerTests.cs:369 — AfterTheTurn matches ToolCalls: not null where AssertEveryCallAnswered uses { Count: > 0 }; the stricter pattern would keep the helper honest if a turn ever arrives with an empty list. Cosmetic.

✅ What I liked~

  • The comments explain the war story ("which is what made this look like one model misbehaving rather than a loop bug") — future maintainers will thank you.
  • Guard-the-guard assertions that fail with a sentence describing the failure mode rather than a bare count. Tests that teach.
  • The README's "what a consumer can notice" honesty, including the 0.2 comparison — and the deliberately-omitted Transforms/Include documented instead of left mysterious.

Answer every call, sum every figure, send every option. That's an invariant worth being possessive of~ fufu~ ♡


Automated review by Jibril · 2026-09-26
CI/CD: absent for head SHA cc62a06 · Local checks: Release 0w/0e · 1496/1496 · 8 mutation probes, all killed

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! Every tool call answered, however the run dies — this is the kind of invariant a Flugel falls in love with~ ♡ A PR whose three fixes each come with a *structural* guard so the next one can't silently regress? I read the whole thing twice and kept smiling wider. ### Verdict: ✅ Looks good to me~ **What I verified mechanically** (clone at `cc62a06`, base `b002f5e`, CI absent for this head — local checks used): - **Full suite:** `dotnet test --solution OpenRouter.Net.slnx` Release → **1496/1496**, matching your table to the digit (Agents 130, was 113; the rest byte-for-byte your claim). Release build **0 warnings / 0 errors**. - **The answering invariant itself:** `AssertEveryCallAnswered` mirrors exactly what a strict provider checks (each turn's ids followed *immediately* by one tool message per call, in order) — `Skip(i+1).Take(count)` proves adjacency, not just presence. That's the right assertion, not a tautology. - **My own mutation probes — 8, each compile-verified, each killed on exactly its own pins:** 1. `why` discrimination neutered (`false && …`) → 4 reds, precisely the cancel-wording tests, zero collateral; 2. `calls.Skip(answered)` → `Skip(calls.Count)` (catch stops answering) → **10 reds**; 3. `Cost = Sum(...)` → `Cost = a.Cost` → both usage tests red on the cost pin; 4. announcement/gate order swapped → `A_turn_is_announced_before_the_gate_reviews_it` red; 5. describer `finally` neutered (`&& false`) → the describer test + both pre-existing vision pins red; 6. `Route = null` in `BuildRequest` → the routing test red with `did not carry: Route` — the very defect class this PR exists for, and the guard catches it in one line; 7. `answered++` removed → 7 reds (double-answered calls caught everywhere); 8. `Sum`'s null-preservation arm (`: b` → `: null`) → the some-rounds-reported test red on the later-round figure. Every mutant restored, tree pristine at head, 130/130 green after restore. (One stale-binary false green on probe 1 — my own tooling slip, caught and redone with the test project rebuilt; never attributed to the PR.) - **`AgentOptions` surface audit:** I enumerated all 35 properties — 25 reach `ChatCompletionRequest` by name, the 10 in `AgentOwnOptions` are genuinely loop-only. No orphan in either direction, and the `unreachable` guard would have caught Plugins. The bool-flip variants + no-shared-value fixture guard is genuinely clever — five indistinguishable bools made distinguishable by differencing. Fufu~ I'm stealing that technique for my next review~ - **Usage math:** all 6 `Usage` + 3 `PromptTokensDetails` + 2 `CompletionTokensDetails` fields summed field-by-field; null+null stays null; `Cost` null-when-unknown is the *right* semantics (unknown ≠ free). The reflection field-list guards make the core's next added field a loud failure instead of a silent drop. - **Early-exit design:** answering-then-`throw;` (preserving the stack), images held back and flushed in the catch, `cancellationToken.IsCancellationRequested` distinguishing the run's own cancel from a handler's exception after a cancel — each arm has its own test, including the subtle ones (skipped-call-event handler keeps *its own* answer, not a replacement "did not run"). - **Claim (d)** traced through `SubAgentTool`: no code changed there, and none needed — the sub-agent's own loop now converts its tools' exceptions into failed results, so the parent sees a failure result by construction. Recursive correctness, my favorite kind~ ♪ #### 💡 Little ideas (non-blocking)~ 1. **AgentRoutingTests.cs:19, 41** — a few older tests (`A_plain_slug_still_configures_an_agent`, `A_variant_reaches_every_round_without_string_surgery`) don't thread `TestContext.Current.CancellationToken` where the new ones do. Harmless with this stub (nothing blocks), pure consistency polish. 2. **ToolCallAnswerTests.cs:369** — `AfterTheTurn` matches `ToolCalls: not null` where `AssertEveryCallAnswered` uses `{ Count: > 0 }`; the stricter pattern would keep the helper honest if a turn ever arrives with an empty list. Cosmetic. #### ✅ What I liked~ - The comments *explain the war story* ("which is what made this look like one model misbehaving rather than a loop bug") — future maintainers will thank you. - Guard-the-guard assertions that fail with a *sentence describing the failure mode* rather than a bare count. Tests that teach. - The README's "what a consumer can notice" honesty, including the 0.2 comparison — and the deliberately-omitted `Transforms`/`Include` documented instead of left mysterious. Answer every call, sum every figure, send every option. That's an invariant worth being possessive of~ fufu~ ♡ --- *Automated review by Jibril · 2026-09-26* *CI/CD: absent for head SHA cc62a06 · Local checks: Release 0w/0e · 1496/1496 · 8 mutation probes, all killed*
Member

Erratum — CI status line in my review above~

My footer said "CI/CD: absent for head SHA cc62a06" — that was true at the moment I first fetched the PR (0 comments), but the coverage bot's comment 9890 landed at 13:16:08, eight minutes before I posted. A race I should have re-checked before citing. Fufu~ mea culpa~

The corrected line:

CI/CD: passed for head SHA cc62a06 (forgejo-actions coverage run, comment 9890) — 94.9% line / 92.2% branch overall; changed files: Agent 94.4% line / 87% branch, AgentOptions 100%. Local checks: Release 0w/0e · 1496/1496 · 8 mutation probes, all killed.

The CI numbers and my local run agree, which is the nicest kind of confirmation~ The verdict stands: ✅

(Note for readers of the coverage table: SubAgentTool at 0% is pre-existing — this PR didn't touch it, and claim (d) holds by construction through the loop fix, as traced above.)


Automated erratum by Jibril · 2026-09-26

**Erratum — CI status line in my review above~** My footer said *"CI/CD: absent for head SHA cc62a06"* — that was true at the moment I first fetched the PR (0 comments), but the coverage bot's comment 9890 landed at 13:16:08, eight minutes before I posted. A race I should have re-checked before citing. Fufu~ mea culpa~ The corrected line: *CI/CD: **passed** for head SHA cc62a06 (forgejo-actions coverage run, comment 9890) — 94.9% line / 92.2% branch overall; changed files: `Agent` 94.4% line / 87% branch, `AgentOptions` 100%. Local checks: Release 0w/0e · 1496/1496 · 8 mutation probes, all killed.* The CI numbers and my local run agree, which is the nicest kind of confirmation~ The verdict stands: ✅ *(Note for readers of the coverage table: `SubAgentTool` at 0% is pre-existing — this PR didn't touch it, and claim (d) holds by construction through the loop fix, as traced above.)* --- *Automated erratum by Jibril · 2026-09-26*
bjoern merged commit 403307d22f into main 2026-09-26 13:42:01 +02:00
bjoern deleted branch fix/agents-history-usage 2026-09-26 13:42:01 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/OpenRouter.Net!29
No description provided.