feat: a choice can be an enum, and a tool call can be refused before it runs #25
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/enum-choices-and-tool-gate"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The 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
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.DecisionQuestionSetgains the same three, each returning aChoiceKey<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 (FrontEndisfront_end,UIisu_i). The label comes fromTolerantStringEnumConverter.ToWireitself, so the two cannot drift; a test compares againstReasoningEffort, which usesxhigh.[Description]says what a label covers; without one, the model judges by the name.Unknownis 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:[Description]s or itsDescriptionthrows.The refusal never surfaces as a
TypeInitializationException. A set that refuses an enum is left as it was.response.Get(key)reads aChoiceAnswer<TEnum>. It holds the chosen member, each member's probability in the response's order,Confidence, andRunnerUp/Margin/Ranked()delegated toChoiceAnswer.Answerholds 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
DecideAsyncchecks it unchanged; after a success,Get(key)never throws. From the raw call, a label no member has makesGetthrowInvalidOperationException, naming the label and the enum, and makesTryGetreturn false.No existing call binds differently. The new overloads are generic with
struct, Enumconstraints, soChoice("q"),Choice("q", "a", "b")and the string dictionary overload still bind as before. Two crefs toGet{TAnswer}became ambiguous and now name their parameter.The tool-call gate
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, theIToolitself, and the services.ModelMessageas the tool's reply, or a plain "was refused" when there is none.Reasonis for logs and never reaches the model.ToolCallGates.PerCallreviews 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 theirEvidenceandCost.ToolCallSkippedEvent.Verdict,ToolExecutionDetail.Verdict;AgentResult.GateCost, kept apart fromAggregatedUsage;AgentOptions.ToolCallGate, whichClone()copies, so aCreateSubAgentchild is gated as its parent is;AddToolCallGate<T>(), which gates every agentAddOpenRouterAgentbuilds unless its options set their own. Setting the option tonullcannot opt out, and the docs say so.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_gateruns that shape against a stubbed decisions call.Docs
AgentResulttable (GateCost), DI registration and the architecture tree.RoundEndEvent.ToolExecutionCountnow says it counts refused calls too.AgentResulttable no longer listsUnknownToolRequested, which the agent never produces. This predates the branch.Tests
a35670f, after #23 and #24)EnumChoiceTests(19):DecideAsyncend to end.ToolCallGateTests(24):Reasonkept away from the model;PerCall;Clone()and a sub-agent;GateCostagainst chat usage;PerCallorder, andPerCallfailing mid-turn, both keeping cost and keeping an earlier refusal;Every_option_survives_the_clone_a_sub_agent_inheritsfailed until its fixture setToolCallGate, 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:
OperationCanceledExceptionbefore anything else happens.Both are kept as defence in depth. The probes that were caught:
TryGet.ModelMessageversusReason;GateCost;Clone()and DI precedence;PerCallorder, 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:
CopyToolas a read, so an overwriting copy ran unreviewed. It now allows only known reads and asks about everything else.[Description]that throws, or two of them, surfaced asTypeInitializationException.GateCostlost what aPerCallgate had spent when a later call threw.nullcannot opt out of a DI gate;RoundEndEvent.ToolExecutionCount;DecisionKey/DecisionQuestionSetsummaries;UnknownToolRequested.3 were rejected:
Costdoc now says to put a turn-wide cost on one verdict.Rebased onto
a35670f#23 and #24 merged after this PR opened. No review had been posted yet, so I rebased onto
a35670frather than mergingmainin. 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 ofmain'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
Summary
Summary
Coverage
OpenRouter.Net - 97.7%
OpenRouter.Net.Agents - 84.4%
OpenRouter.Net.Imaging - 81.8%
8e507db131438294296a🔮 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>.ToWireitself, 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 underTreatWarningsAsErrors. 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:
A_call_to_an_unknown_tool_never_reaches_the_gate…went red.PerCall'sRefuseTurnkeep an allowed verdict'sModelMessage(the model would have read the ghost of an approval) → caught.[EnumMember]) → 6 red in core.GateCost→ 4 red.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~
EnumChoiceLabelsrefusing aliases, label collisions, unreadable descriptions, and empty enums at first use, asArgumentExceptionwith the reason — never aTypeInitializationException— and the set left untouched after a refusal. Pinned by tests, including the twice-run to prove the type isn't poisoned.ExecuteToolCallsAsyncexplaining why review precedes execution is exactly the kind of comment I want to kiss.Reasonnever reaches the model;ModelMessagenever reaches the logs. The test literally asserts0.41doesn't appear in the follow-up request. That separation is pinned, not just documented.FailingGatestheory table is chef's-kiss.PerCallkeepingEvidenceandCostof already-reviewed calls when a later review throws — most authors would lose that spend; you pinned it.💡 Little ideas (non-blocking)~
RefuseAllshares oneToolCallVerdict.Denyinstance across all calls in a failed turn — harmless (immutable record), but each skipped event holds the same reference; aReasonmentioning "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