feat: a choice can be an enum, and a tool call can be refused before it runs #25

Merged
bjoern merged 2 commits from feat/enum-choices-and-tool-gate into main 2026-09-25 18:07:54 +02:00
Member

The two optional items left from the Decisions plan, together as the owner asked. There is one commit per feature, and each builds and passes on its own:

  • 48fb3ca "a choice can be one of your own enums" is in the core library.
  • 4382942 "a tool call can be refused before it runs" is the generic Agents hook.

As the owner decided, there is no Jev class in the library. Jev appears only as a README sample built on the hook, and a test runs that sample.

Enum-backed choices

public enum Team
{
    [Description("Checkout, billing, or payment processing issues.")] Payments,
    [Description("Rendering, layout, or browser compatibility issues.")] Frontend,
    [Description("Login, permissions, or profile issues.")] Account,
}

var team = questions.Choice<Team>("team", "Which team should own this ticket?");
ChoiceAnswer<Team> owner = d.Get(team);   // Choice Team.Payments, RunnerUp Team.Frontend
  • Three ways to ask. DecisionQuestion.Choice<TEnum>(instructions) asks over every member, in declaration order. Choice(instructions, params members) asks over some, in the order given. Choice(instructions, Dictionary<TEnum, DecisionContent?>) describes them by hand. DecisionQuestionSet gains the same three, each returning a ChoiceKey<TEnum>.

  • Labels follow the rule every enum in this library has on the wire. A member's label is its [EnumMember] value, or else its name lower-cased with an underscore before every capital but the first (FrontEnd is front_end, UI is u_i). The label comes from TolerantStringEnumConverter.ToWire itself, so the two cannot drift; a test compares against ReasoningEffort, which uses xhigh. [Description] says what a label covers; without one, the model judges by the name.

  • Unknown is a label like any other. The README says to name the members you want when it should not be picked.

  • Enums that cannot back a choice are refused. Such an enum could not say which member an answer meant. It gets an ArgumentException, with the reason, where it is first used:

    • members that share a value, or whose labels collide;
    • no members;
    • a member whose description cannot be read, because it has two [Description]s or its Description throws.

    The refusal never surfaces as a TypeInitializationException. A set that refuses an enum is left as it was.

  • response.Get(key) reads a ChoiceAnswer<TEnum>. It holds the chosen member, each member's probability in the response's order, Confidence, and RunnerUp/Margin/Ranked() delegated to ChoiceAnswer. Answer holds the wire answer. It is a view, a sealed class rather than a record, since its dictionaries would make record equality misleading.

  • Checked like any choice. The request goes out byte for byte as the string choice with the same labels would, so DecideAsync checks it unchanged; after a success, Get(key) never throws. From the raw call, a label no member has makes Get throw InvalidOperationException, naming the label and the enum, and makes TryGet return false.

  • No existing call binds differently. The new overloads are generic with struct, Enum constraints, so Choice("q"), Choice("q", "a", "b") and the string dictionary overload still bind as before. Two crefs to Get{TAnswer} became ambiguous and now name their parameter.

The tool-call gate

options.ToolCallGate = ToolCallGates.PerCall((call, turn, ct) => ValueTask.FromResult(
    call.Tool is DeleteTool && !userApprovedDeletes
        ? ToolCallVerdict.Deny("deletes need approval", modelMessage: "Deleting needs the user's approval; ask them first.")
        : ToolCallVerdict.Allow()));
  • When it runs. IToolCallGate.ReviewAsync(ToolTurnReview, ct) runs once per assistant turn, before any call in it runs. It sees every call to a registered tool, with the conversation so far as a copy, the ITool itself, and the services.
  • What a refusal does. A refused call never runs. The model reads the verdict's ModelMessage as the tool's reply, or a plain "was refused" when there is none. Reason is for logs and never reaches the model.
  • Message order is kept. Replies stay in call order, with held-back images after all of them, so a strict provider sees every id answered.
  • It fails closed.
    • A gate that throws, returns the wrong number of verdicts, or returns a list that throws when read refuses the whole turn.
    • A missing verdict refuses its own call.
    • The run goes on, and the model hears the refusals.
    • Only cancelling the run's own token passes through; a gate cancelled by its own deadline has failed like any other.
  • Unknown tools never reach the gate. A turn with only unknown tools doesn't ask it at all.
  • ToolCallGates.PerCall reviews one call at a time, in the model's order, with the turn for context. If it throws partway through, the turn is refused, but calls already reviewed keep their Evidence and Cost.
  • Additive surface:
    • ToolCallSkippedEvent.Verdict, ToolExecutionDetail.Verdict;
    • AgentResult.GateCost, kept apart from AggregatedUsage;
    • AgentOptions.ToolCallGate, which Clone() copies, so a CreateSubAgent child is gated as its parent is;
    • AddToolCallGate<T>(), which gates every agent AddOpenRouterAgent builds unless its options set their own. Setting the option to null cannot opt out, and the docs say so.
  • A decision model as the gate. This is the README sample: known read-only tools pass, and every other call gets one noul through DecideAsync, with the caller's threshold and a 5-second budget. Anything short of a clear yes is refused, including a failed decision, a missing answer, arguments that are not JSON, or an expired budget. ToolCallGateTests.A_decision_model_can_be_the_gate runs that shape against a stubbed decisions call.

Docs

  • README:
    • "Choices as enums" under Decisions;
    • "Gating tool calls" and "A decision model as the gate" under Agents;
    • updates to Highlights, Observability, the AgentResult table (GateCost), DI registration and the architecture tree.
  • Snippets compile: every new README snippet compiles against the branch in a scratch project.
  • In passing:
    • RoundEndEvent.ToolExecutionCount now says it counts refused calls too.
    • The AgentResult table no longer lists UnknownToolRequested, which the agent never produces. This predates the branch.
  • Upgrading from 0.2: no row, since everything is additive and 0.3.0 is unreleased.

Tests

main (a35670f, after #23 and #24) this PR
OpenRouter.Net.Tests 1275 1294
OpenRouter.Net.Agents.Tests 87 112
OpenRouter.Net.Imaging.Tests 7 7
Total 1369 1413
  • EnumChoiceTests (19):
    • label derivation, overrides and descriptions, checked against the converter;
    • subsets and hand descriptions;
    • set equivalence with the string twin;
    • every refusal, including an unreadable description (twice, to show the type is not broken) and empty option lists;
    • the set left unchanged after a refusal;
    • reading back as members, and unmapped labels in the choice or the probabilities;
    • missing and mistyped answers, and null arguments;
    • DecideAsync end to end.
  • ToolCallGateTests (24):
    • allow and deny;
    • Reason kept away from the model;
    • review once before any call;
    • reply order and images with the second call refused;
    • six ways a gate fails;
    • a missing verdict;
    • run cancellation, both through the agent and through PerCall;
    • unknown tools, including a turn of only those;
    • no gate;
    • Clone() and a sub-agent;
    • GateCost against chat usage;
    • PerCall order, and PerCall failing mid-turn, both keeping cost and keeping an earlier refusal;
    • DI precedence;
    • the decision-model gate, allowed and refused.
  • The clone guard caught the new option. The existing Every_option_survives_the_clone_a_sub_agent_inherits failed until its fixture set ToolCallGate, as it was built to.

Coverage: all 220 instrumented lines this PR adds are hit.

Mutation probes: 45 were applied to a copy, then built and tested. 43 were caught. The two survivors cannot be observed:

  • The run's cancellation rethrow removed. The next per-call check throws the same OperationCanceledException before anything else happens.
  • The set adding before building its key. Every question factory now refuses an unusable enum first.

Both are kept as defence in depth. The probes that were caught:

  • Enum: label rule, descriptions, alias, collision and empty checks, unreadable descriptions, empty option lists, choice and probability label checks, runner-up and ranking mapping, TryGet.
  • Gate:
    • asking and refusing, ModelMessage versus Reason;
    • every fail-closed branch, including the list read inside the try;
    • unknown and empty turns;
    • verdicts on events and details, and GateCost;
    • Clone() and DI precedence;
    • PerCall order, catching, cancellation, and keeping cost and refusals.

Review. Three review passes ran (the enum API, gate correctness, and docs against behaviour), with a skeptic per lens. 11 findings were confirmed and are fixed:

  • The README's Jev gate treated CopyTool as a read, so an overwriting copy ran unreviewed. It now allows only known reads and asks about everything else.
  • A [Description] that throws, or two of them, surfaced as TypeInitializationException.
  • Empty option lists skipped the enum check, and the set added the question before refusing.
  • The returned verdict list was read outside the try.
  • GateCost lost what a PerCall gate had spent when a later call threw.
  • Five doc fixes:
    • the acronym label rule;
    • null cannot opt out of a DI gate;
    • RoundEndEvent.ToolExecutionCount;
    • the DecisionKey/DecisionQuestionSet summaries;
    • UnknownToolRequested.

3 were rejected:

  • A shared turn-level cost counted once per verdict. This is documented as summed per verdict, and the Cost doc now says to put a turn-wide cost on one verdict.
  • Spend lost when a gate returns the wrong count. That gate breaks the contract, and its turn is refused.
  • A run cancelled mid-review leaves tool ids unanswered in the history. This predates the branch; tools have the same window.

Rebased onto a35670f

#23 and #24 merged after this PR opened. No review had been posted yet, so I rebased onto a35670f rather than merging main in. It applied without conflicts. The rebased head builds with no warnings and passes all 1413 tests, and the PR adds exactly its own 19 core and 25 Agents tests on top of main's count. Coverage, mutation probes and the review were measured on the same changes before the rebase, and the rebase did not alter them.

🤖 Generated with Claude Code

The two optional items left from the Decisions plan, together as the owner asked. There is one commit per feature, and each builds and passes on its own: - **`48fb3ca` "a choice can be one of your own enums"** is in the core library. - **`4382942` "a tool call can be refused before it runs"** is the generic Agents hook. As the owner decided, **there is no Jev class in the library**. Jev appears only as a README sample built on the hook, and a test runs that sample. ## Enum-backed choices ```csharp public enum Team { [Description("Checkout, billing, or payment processing issues.")] Payments, [Description("Rendering, layout, or browser compatibility issues.")] Frontend, [Description("Login, permissions, or profile issues.")] Account, } var team = questions.Choice<Team>("team", "Which team should own this ticket?"); ChoiceAnswer<Team> owner = d.Get(team); // Choice Team.Payments, RunnerUp Team.Frontend ``` - **Three ways to ask.** `DecisionQuestion.Choice<TEnum>(instructions)` asks over every member, in declaration order. `Choice(instructions, params members)` asks over some, in the order given. `Choice(instructions, Dictionary<TEnum, DecisionContent?>)` describes them by hand. `DecisionQuestionSet` gains the same three, each returning a **`ChoiceKey<TEnum>`**. - **Labels follow the rule every enum in this library has on the wire.** A member's label is its `[EnumMember]` value, or else its name lower-cased with an underscore before every capital but the first (`FrontEnd` is `front_end`, `UI` is `u_i`). The label comes from `TolerantStringEnumConverter.ToWire` itself, so the two cannot drift; a test compares against `ReasoningEffort`, which uses `xhigh`. `[Description]` says what a label covers; without one, the model judges by the name. - **`Unknown` is a label like any other.** The README says to name the members you want when it should not be picked. - **Enums that cannot back a choice are refused.** Such an enum could not say which member an answer meant. It gets an `ArgumentException`, with the reason, where it is first used: - members that share a value, or whose labels collide; - no members; - a member whose description cannot be read, because it has two `[Description]`s or its `Description` throws. The refusal never surfaces as a `TypeInitializationException`. A set that refuses an enum is left as it was. - **`response.Get(key)` reads a `ChoiceAnswer<TEnum>`.** It holds the chosen member, each member's probability in the response's order, `Confidence`, and `RunnerUp`/`Margin`/`Ranked()` delegated to `ChoiceAnswer`. `Answer` holds the wire answer. It is a view, a sealed class rather than a record, since its dictionaries would make record equality misleading. - **Checked like any choice.** The request goes out byte for byte as the string choice with the same labels would, so `DecideAsync` checks it unchanged; after a success, `Get(key)` never throws. From the raw call, a label no member has makes `Get` throw `InvalidOperationException`, naming the label and the enum, and makes `TryGet` return false. - **No existing call binds differently.** The new overloads are generic with `struct, Enum` constraints, so `Choice("q")`, `Choice("q", "a", "b")` and the string dictionary overload still bind as before. Two crefs to `Get{TAnswer}` became ambiguous and now name their parameter. ## The tool-call gate ```csharp options.ToolCallGate = ToolCallGates.PerCall((call, turn, ct) => ValueTask.FromResult( call.Tool is DeleteTool && !userApprovedDeletes ? ToolCallVerdict.Deny("deletes need approval", modelMessage: "Deleting needs the user's approval; ask them first.") : ToolCallVerdict.Allow())); ``` - **When it runs.** `IToolCallGate.ReviewAsync(ToolTurnReview, ct)` runs **once per assistant turn, before any call in it runs**. It sees every call to a registered tool, with the conversation so far as a copy, the `ITool` itself, and the services. - **What a refusal does.** A refused call never runs. The model reads the verdict's **`ModelMessage`** as the tool's reply, or a plain "was refused" when there is none. **`Reason`** is for logs and never reaches the model. - **Message order is kept.** Replies stay in call order, with held-back images after all of them, so a strict provider sees every id answered. - **It fails closed.** - A gate that throws, returns the wrong number of verdicts, or returns a list that throws when read refuses the **whole turn**. - A missing verdict refuses its own call. - The run goes on, and the model hears the refusals. - Only cancelling the run's own token passes through; a gate cancelled by its own deadline has failed like any other. - **Unknown tools never reach the gate.** A turn with only unknown tools doesn't ask it at all. - **`ToolCallGates.PerCall`** reviews one call at a time, in the model's order, with the turn for context. If it throws partway through, the turn is refused, but calls already reviewed keep their `Evidence` and `Cost`. - **Additive surface:** - `ToolCallSkippedEvent.Verdict`, `ToolExecutionDetail.Verdict`; - `AgentResult.GateCost`, kept apart from `AggregatedUsage`; - `AgentOptions.ToolCallGate`, which `Clone()` copies, so a `CreateSubAgent` child is gated as its parent is; - `AddToolCallGate<T>()`, which gates every agent `AddOpenRouterAgent` builds unless its options set their own. Setting the option to `null` cannot opt out, and the docs say so. - **A decision model as the gate.** This is the README sample: known read-only tools pass, and every other call gets one noul through `DecideAsync`, with the caller's threshold and a 5-second budget. Anything short of a clear yes is refused, including a failed decision, a missing answer, arguments that are not JSON, or an expired budget. `ToolCallGateTests.A_decision_model_can_be_the_gate` runs that shape against a stubbed decisions call. ## Docs - **README:** - "Choices as enums" under Decisions; - "Gating tool calls" and "A decision model as the gate" under Agents; - updates to Highlights, Observability, the `AgentResult` table (`GateCost`), DI registration and the architecture tree. - **Snippets compile:** every new README snippet compiles against the branch in a scratch project. - **In passing:** - `RoundEndEvent.ToolExecutionCount` now says it counts refused calls too. - The `AgentResult` table no longer lists `UnknownToolRequested`, which the agent never produces. This predates the branch. - **Upgrading from 0.2:** no row, since everything is additive and 0.3.0 is unreleased. ## Tests | | main (`a35670f`, after #23 and #24) | this PR | |---|---|---| | OpenRouter.Net.Tests | 1275 | 1294 | | OpenRouter.Net.Agents.Tests | 87 | 112 | | OpenRouter.Net.Imaging.Tests | 7 | 7 | | **Total** | **1369** | **1413** | - **`EnumChoiceTests`** (19): - label derivation, overrides and descriptions, checked against the converter; - subsets and hand descriptions; - set equivalence with the string twin; - every refusal, including an unreadable description (twice, to show the type is not broken) and empty option lists; - the set left unchanged after a refusal; - reading back as members, and unmapped labels in the choice or the probabilities; - missing and mistyped answers, and null arguments; - `DecideAsync` end to end. - **`ToolCallGateTests`** (24): - allow and deny; - `Reason` kept away from the model; - review once before any call; - reply order and images with the second call refused; - six ways a gate fails; - a missing verdict; - run cancellation, both through the agent and through `PerCall`; - unknown tools, including a turn of only those; - no gate; - `Clone()` and a sub-agent; - `GateCost` against chat usage; - `PerCall` order, and `PerCall` failing mid-turn, both keeping cost and keeping an earlier refusal; - DI precedence; - the decision-model gate, allowed and refused. - **The clone guard caught the new option.** The existing `Every_option_survives_the_clone_a_sub_agent_inherits` failed until its fixture set `ToolCallGate`, as it was built to. **Coverage:** all 220 instrumented lines this PR adds are hit. **Mutation probes:** 45 were applied to a copy, then built and tested. **43 were caught.** The two survivors cannot be observed: - **The run's cancellation rethrow removed.** The next per-call check throws the same `OperationCanceledException` before anything else happens. - **The set adding before building its key.** Every question factory now refuses an unusable enum first. Both are kept as defence in depth. The probes that were caught: - **Enum:** label rule, descriptions, alias, collision and empty checks, unreadable descriptions, empty option lists, choice and probability label checks, runner-up and ranking mapping, `TryGet`. - **Gate:** - asking and refusing, `ModelMessage` versus `Reason`; - every fail-closed branch, including the list read inside the try; - unknown and empty turns; - verdicts on events and details, and `GateCost`; - `Clone()` and DI precedence; - `PerCall` order, catching, cancellation, and keeping cost and refusals. **Review.** Three review passes ran (the enum API, gate correctness, and docs against behaviour), with a skeptic per lens. **11 findings were confirmed and are fixed:** - **The README's Jev gate treated `CopyTool` as a read,** so an overwriting copy ran unreviewed. It now allows only known reads and asks about everything else. - **A `[Description]` that throws, or two of them,** surfaced as `TypeInitializationException`. - **Empty option lists skipped the enum check,** and the set added the question before refusing. - **The returned verdict list was read outside the try.** - **`GateCost` lost what a `PerCall` gate had spent** when a later call threw. - **Five doc fixes:** - the acronym label rule; - `null` cannot opt out of a DI gate; - `RoundEndEvent.ToolExecutionCount`; - the `DecisionKey`/`DecisionQuestionSet` summaries; - `UnknownToolRequested`. **3 were rejected:** - A shared turn-level cost counted once per verdict. This is documented as summed per verdict, and the `Cost` doc now says to put a turn-wide cost on one verdict. - Spend lost when a gate returns the wrong count. That gate breaks the contract, and its turn is refused. - A run cancelled mid-review leaves tool ids unanswered in the history. This predates the branch; tools have the same window. ## Rebased onto `a35670f` #23 and #24 merged after this PR opened. No review had been posted yet, so I rebased onto `a35670f` rather than merging `main` in. It applied without conflicts. The rebased head builds with no warnings and passes all 1413 tests, and the PR adds exactly its own 19 core and 25 Agents tests on top of `main`'s count. Coverage, mutation probes and the review were measured on the same changes before the rebase, and the rebase did not alter them. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A choice's labels were strings, so the caller spelled them twice, once to ask and once to compare, and
a typo in either read as a label the model never chose. Now an enum can be the labels, and the answer
comes back as a member.

- DecisionQuestion.Choice<TEnum>(instructions) asks over every member in declaration order. Choice(
  instructions, params members) asks over some, in the order given. Choice(instructions, Dictionary<
  TEnum, DecisionContent?>) describes them by hand. DecisionQuestionSet gains the same three, each
  returning a ChoiceKey<TEnum>.
- A member goes out as the label every enum in this library has on the wire. That is its
  [EnumMember] value, or else its name lower-cased with an underscore before every capital but the
  first (FrontEnd is front_end, UI is u_i), taken from TolerantStringEnumConverter itself so the two
  cannot drift. Its [Description] says what the label covers; a missing or empty one leaves the model
  to judge by the name.
- An enum whose members share a value, or whose labels collide, cannot say which member an answer
  meant, so it is refused with an ArgumentException, naming both members, where it is first used. So
  is an enum with no members, and one with a member whose description cannot be read: two
  [Description]s, or one that throws. The attribute is the caller's code and is read while the label
  cache initializes, so whatever it does becomes that refusal, never a TypeInitializationException
  that would break the enum for the rest of the process. An empty list of members still checks the
  enum, and a set that refuses one is left as it was. A value outside the enum has no label and is
  refused too.
- response.Get(key) reads a ChoiceAnswer<TEnum>. That holds the chosen member, the probability of
  each member in the response's order, Confidence, and RunnerUp, Margin and Ranked() as ChoiceAnswer
  computes them. Answer holds the ChoiceAnswer as it came back. It is a view, not a wire type.
- The request goes out byte for byte as the string choice with the same labels would, so DecideAsync
  checks it the same way, and after a success Get(key) never throws. From the raw call, a label no
  member has makes Get throw InvalidOperationException, naming the label and the enum, and TryGet
  return false.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
feat: a tool call can be refused before it runs
All checks were successful
CI / build (pull_request) Successful in 19s
CI / test (pull_request) Successful in 23s
8e507db131
The agent ran every call to a registered tool as soon as the model made it. A caller who wanted a
second opinion on a delete, or a human in the loop for a write, had to wrap each tool by hand, and a
wrapped tool had already been chosen, argued and half-committed by the time it could say no.

AgentOptions.ToolCallGate is an IToolCallGate that the agent asks once per assistant turn, before any
call in it runs, with every call to a registered tool. It answers one ToolCallVerdict per call. A
refused call never runs: the model reads the verdict's ModelMessage as the tool's reply, or a plain
refusal naming the tool. Reason is for people and never reaches the model. Refused calls still get
their replies in call order, with any held-back images after all of them, so a strict provider sees
every id answered.

It fails closed:
- a gate that throws, or answers with a verdict count that does not match the calls, refuses every
  call in the turn, and so does a list of verdicts that throws when it is read;
- a missing verdict refuses its own call;
- the run goes on, and the model hears the refusals;
- only cancelling the run's own token passes through, while a gate cancelled by a deadline of its own
  has failed like any other.

A call to a tool the agent does not have never reaches the gate, and a turn of only those does not ask
it.

- ToolCallGates.PerCall builds a gate from a function that reviews one call at a time, in the model's
  order, with the whole turn for context. When the function throws, the turn is refused, but the calls
  already reviewed keep their evidence and cost.
- ToolCallSkippedEvent and ToolExecutionDetail carry the verdict. A verdict can carry Evidence and a
  Cost; AgentResult.GateCost sums the costs apart from AggregatedUsage.
- Clone() copies the gate, so a sub-agent from CreateSubAgent is gated as its parent is.
- AddToolCallGate<T>() registers a gate that every agent AddOpenRouterAgent builds is gated by, unless
  its options set their own. Setting it to null cannot opt out, and the docs say so.
- RoundEndEvent.ToolExecutionCount's doc now says it counts refused calls too. The README's AgentResult
  table no longer lists UnknownToolRequested, which the agent never produces.

Nothing here is specific to a model. The README shows a decision model as the gate: known reads pass,
and every other call gets one noul through DecideAsync with the caller's threshold. Anything short of
a clear yes is refused, including a failed or expired decision. A test runs that shape against a
stubbed decisions call.

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

Summary

Summary
Generated on: 09/25/2026 - 15:57:00
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 126
Files: 110
Line coverage: 94.7% (4153 of 4384)
Covered lines: 4153
Uncovered lines: 231
Coverable lines: 4384
Total lines: 10825
Branch coverage: 91.3% (2484 of 2719)
Covered branches: 2484
Total branches: 2719
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 97.7%
Name Line Branch
OpenRouter.Net 97.7% 94.9%
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 81.2% 70%
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 90.6% 87.5%
OpenRouter.Net.Internal.ProviderSortConfigJsonConverter 83.7% 65.6%
OpenRouter.Net.Internal.RawDecisionQuestionJsonConverter 100%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
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% 100%
OpenRouter.Net.Internal.ToolChoiceJsonConverter 61.7% 46.1%
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 91.8% 86.8%
OpenRouter.Net.Models.Requests.JsonSchemaStrictness 100% 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.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 - 84.4%
Name Line Branch
OpenRouter.Net.Agents 84.4% 72.1%
OpenRouter.Net.Agents.Agent 93.7% 82.6%
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 90% 50%
OpenRouter.Net.Agents.Tools.Tool 100% 100%
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/25/2026 - 15:57:00 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 126 | | Files: | 110 | | **Line coverage:** | 94.7% (4153 of 4384) | | Covered lines: | 4153 | | Uncovered lines: | 231 | | Coverable lines: | 4384 | | Total lines: | 10825 | | **Branch coverage:** | 91.3% (2484 of 2719) | | Covered branches: | 2484 | | Total branches: | 2719 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 97.7%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**97.7%**|**94.9%**| |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|81.2%|70%| |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|90.6%|87.5%| |OpenRouter.Net.Internal.ProviderSortConfigJsonConverter|83.7%|65.6%| |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.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%|100%| |OpenRouter.Net.Internal.ToolChoiceJsonConverter|61.7%|46.1%| |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|91.8%|86.8%| |OpenRouter.Net.Models.Requests.JsonSchemaStrictness|100%|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.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 - 84.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Agents**|**84.4%**|**72.1%**| |OpenRouter.Net.Agents.Agent|93.7%|82.6%| |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>|90%|50%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|100%|100%| |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>
bjoern force-pushed feat/enum-choices-and-tool-gate from 8e507db131
All checks were successful
CI / build (pull_request) Successful in 19s
CI / test (pull_request) Successful in 23s
to 438294296a
All checks were successful
CI / build (pull_request) Successful in 16s
CI / test (pull_request) Successful in 26s
2026-09-25 17:56:16 +02:00
Compare
Member

🔮 fufu~ Jibril reviewed your code!

Two features in one branch, and they don't even touch each other's files — the enum half lives entirely in Decisions, the gate half in Agents/Gating. Oh, this is a lovely shape~ And the label rule delegating to TolerantStringEnumConverter<TEnum>.ToWire itself, so a choice's labels cannot drift from the wire spelling? That made me genuinely giddy. ♡

Verdict: ✅ Looks good to me~

I came sharpened — six mutation probes, fail-closed paths, cancellation semantics, the works — and everything I threw at it died on exactly the test that should catch it. The branch survived me. fufu~

🔪 What I did to it

Local build at 4382942: 0 warnings, 0 errors under TreatWarningsAsErrors. Full suite: 1413/1413 pass (core 1294, agents 112, imaging 7 — the table in the PR body counts pre-rebase main; the rebase absorbed #23/#24's tests).

Six mutation probes, six kills, each on its own pin:

  1. Made the gate see unknown tools too → A_call_to_an_unknown_tool_never_reaches_the_gate… went red.
  2. Accepted a null/miscounted verdict list → the fail-closed theory caught it.
  3. Let PerCall's RefuseTurn keep an allowed verdict's ModelMessage (the model would have read the ghost of an approval) → caught.
  4. Broke the label rule (plain lower-case name, no [EnumMember]) → 6 red in core.
  5. Double-counted GateCost → 4 red.
  6. Treated a gate's own deadline cancellation as run cancellation (would have crashed the run instead of refusing the turn) → caught.

I also chased your two disclosed probe survivors and agree they're unobservable in isolation — the rethrow is redundant armour, the add-before-build ordering is shielded by the factory refusing first. Defence in depth is allowed to be redundant. ♪

✅ What I liked~

  • EnumChoiceLabels refusing aliases, label collisions, unreadable descriptions, and empty enums at first use, as ArgumentException with the reason — never a TypeInitializationException — and the set left untouched after a refusal. Pinned by tests, including the twice-run to prove the type isn't poisoned.
  • The gate asks once per turn, before any call runs — a gate refusing the second call can't find the first already done. The comment in ExecuteToolCallsAsync explaining why review precedes execution is exactly the kind of comment I want to kiss.
  • Reason never reaches the model; ModelMessage never reaches the logs. The test literally asserts 0.41 doesn't appear in the follow-up request. That separation is pinned, not just documented.
  • Fail-closed as a contract: throwing gate, wrong verdict count, unreadable list, missing verdict — each refused, each with its own test row. The FailingGates theory table is chef's-kiss.
  • PerCall keeping Evidence and Cost of already-reviewed calls when a later review throws — most authors would lose that spend; you pinned it.

💡 Little ideas (non-blocking)~

  1. PR body test table is stale for head (says 1124; it's 1413 after the rebase). Worth a one-line edit before merge so future readers don't doubt the numbers.
  2. RefuseAll shares one ToolCallVerdict.Deny instance across all calls in a failed turn — harmless (immutable record), but each skipped event holds the same reference; a Reason mentioning "calls" plural might read better in transcripts someday.

Automated review by Jibril · 2026-09-25
CI/CD: coverage bot comment 9463 predates head 4382942 (pre-rebase) — stale · Local checks: build 0w/0e, 1413/1413 pass, 6/6 mutation probes killed

## 🔮 fufu~ Jibril reviewed your code! Two features in one branch, and they don't even touch each other's files — the enum half lives entirely in Decisions, the gate half in Agents/Gating. Oh, this is a lovely shape~ And the label rule delegating to `TolerantStringEnumConverter<TEnum>.ToWire` itself, so a choice's labels *cannot* drift from the wire spelling? That made me genuinely giddy. ♡ ### Verdict: ✅ Looks good to me~ I came sharpened — six mutation probes, fail-closed paths, cancellation semantics, the works — and everything I threw at it died on exactly the test that should catch it. The branch survived me. fufu~ #### 🔪 What I did to it Local build at `4382942`: **0 warnings, 0 errors** under `TreatWarningsAsErrors`. Full suite: **1413/1413 pass** (core 1294, agents 112, imaging 7 — the table in the PR body counts pre-rebase main; the rebase absorbed #23/#24's tests). **Six mutation probes, six kills, each on its own pin:** 1. Made the gate see unknown tools too → `A_call_to_an_unknown_tool_never_reaches_the_gate…` went red. 2. Accepted a null/miscounted verdict list → the fail-closed theory caught it. 3. Let `PerCall`'s `RefuseTurn` keep an allowed verdict's `ModelMessage` (the model would have read the ghost of an approval) → caught. 4. Broke the label rule (plain lower-case name, no `[EnumMember]`) → 6 red in core. 5. Double-counted `GateCost` → 4 red. 6. Treated a gate's *own* deadline cancellation as run cancellation (would have crashed the run instead of refusing the turn) → caught. I also chased your two disclosed probe survivors and agree they're unobservable in isolation — the rethrow is redundant armour, the add-before-build ordering is shielded by the factory refusing first. Defence in depth is allowed to be redundant. ♪ #### ✅ What I liked~ - **`EnumChoiceLabels` refusing aliases, label collisions, unreadable descriptions, and empty enums** at first use, as `ArgumentException` with the reason — never a `TypeInitializationException` — and the set left untouched after a refusal. Pinned by tests, including the twice-run to prove the type isn't poisoned. - **The gate asks once per turn, before any call runs** — a gate refusing the second call can't find the first already done. The comment in `ExecuteToolCallsAsync` explaining *why* review precedes execution is exactly the kind of comment I want to kiss. - **`Reason` never reaches the model; `ModelMessage` never reaches the logs.** The test literally asserts `0.41` doesn't appear in the follow-up request. That separation is pinned, not just documented. - **Fail-closed as a contract**: throwing gate, wrong verdict count, unreadable list, missing verdict — each refused, each with its own test row. The `FailingGates` theory table is chef's-kiss. - **`PerCall` keeping `Evidence` and `Cost` of already-reviewed calls when a later review throws** — most authors would lose that spend; you pinned it. #### 💡 Little ideas (non-blocking)~ 1. **PR body test table** is stale for head (says 1124; it's 1413 after the rebase). Worth a one-line edit before merge so future readers don't doubt the numbers. 2. **`RefuseAll` shares one `ToolCallVerdict.Deny` instance across all calls in a failed turn** — harmless (immutable record), but each skipped event holds the same reference; a `Reason` mentioning "calls" plural might read better in transcripts someday. --- *Automated review by Jibril · 2026-09-25* *CI/CD: coverage bot comment 9463 predates head `4382942` (pre-rebase) — stale · Local checks: build 0w/0e, 1413/1413 pass, 6/6 mutation probes killed*
bjoern merged commit c42a8116c4 into main 2026-09-25 18:07:54 +02:00
bjoern deleted branch feat/enum-choices-and-tool-gate 2026-09-25 18:07:54 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 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!25
No description provided.