feat: a tool call written as text is answered with a nudge, not taken as the answer #38

Merged
bjoern merged 1 commit from feat/leaked-tool-calls into main 2026-10-24 00:08:00 +02:00
Member

This is the second hardening step of the library track Shikigami's runtime needs: "L5 — Hardening" in its PROJECT_PLAN, and ADR 0021's "a GLM model leaked tool-call markup as text (detect, nudge, at most two retries)". It adds recovery from tool calls written as text: some models, through some providers, put the tool-call markup they were trained on into the reply's text when their template or the provider fails to turn it into a call. As you chose, the prefill fallback is left out until something prefills. It is opt-in: with LeakedToolCalls unset, nothing changes.

What's in

AgentOptions.LeakedToolCalls (LeakedToolCallRecovery, record)

  • MaxRetries (2 by default; 0 only reports; a negative value throws).
  • Nudge, a user message telling the model to call tools through the interface; a blank one throws.
  • Detector (Func<AssistantMessage, string?>), LeakedToolCallDetector.Find by default; null throws.

The loop

  • Where it acts: a reply that calls no tool, in an agent that has tools, is checked once its round has ended. When markup is found:
    • a LeakedToolCallEvent { Markup, Leak, Retried } reports it, kept in Events;
    • the reply stays in the history, the nudge follows as a user message, and the model is asked again in a new round, counted against MaxIterations.
  • Limits: the retries are per run.
  • Last round: a leak in the last round allowed so far is the answer, as is a leak once the retries are spent. "Allowed so far" means MaxIterations plus any rounds OnMaxRoundsReached granted; the hook is not asked for more on a leak's behalf. The run completes with that reply, and the event says it was not retried.
  • Kept on purpose: the reply and the nudge both stay. Then the next round's prefix is the one its reasoning and signatures were produced over, and a later run knows what happened.
  • OnRoundBoundary is asked after a nudge round too, and its messages follow the nudge. Its docs say so.

LeakedToolCallDetector.Find

  • Markers:
    • Hermes, Qwen and GLM: <tool_call>, <tool_calls>;
    • Kimi: <|tool_call_begin|>, <|tool_calls_section_begin|>;
    • DeepSeek: the full-width <|tool▁calls▁begin|> and <|tool▁call▁begin|>;
    • Llama: <function=, <|python_tag|>;
    • Mistral: [TOOL_CALLS];
    • Claude-style XML: <function_calls>, <invoke name=.
  • Code is skipped as Markdown reads it, by a small linear scanner rather than a regex. Fenced blocks are a line of three or more backticks or tildes at any indent, closed by at least the same run or running to the end, with CRLF and backtick info strings handled. Code spans are whole backtick runs, closed by the next run of the same length within a paragraph; a blank line, a fence, a list item, a heading, a quote or a thematic break ends the paragraph. So a reply that shows or explains markup is not a leak, and neither is prose that names a tool.
  • What it returns: the markup as the model wrote it, from the earliest marker on, at most 200 characters.
  • Why not a regex: the review's second pass found the first regex mishandled CRLF fences, split backtick runs and spans pairing into fences, and that a long reply of unclosed backtick runs took quadratic time.

Docs: a README section, "Tool calls written as text"; the Observability list, Highlights, and the Upgrading intro (which now also names L5a); and the boundary hook's docs.

Not in this step

  • The prefill fallback, by your choice.
  • L3a and L3b: cache breakpoints and cache fields. They wait for the live cache check next month.
  • Indented code blocks (four spaces after a blank line) aren't skipped. Fences are what models write.

Verification

  • The whole solution passes 1794/1794, in local time and under TZ=UTC: core 1445, Agents 342 (+67), Imaging 7. The build has 0 warnings.

  • LeakedToolCallTests (21 tests, most of them theories):

    • each dialect is found from its marker on, after prose and before trailing whitespace;
    • prose and code that only show markup are not a leak: single, double and four-backtick spans and fences, a backtick fence not closed by tildes, a shorter run inside a longer fence, fences in list items indented by spaces, by a tab and nested, and blank lines that are a lone NBSP;
    • markup after closed code still counts: CRLF fences, mid-sentence tildes and backticks, "ls" at the start of a line, an indented tilde fence, a lone backtick, two tildes;
    • a backtick before the leak never pairs with one inside it (an empty span, an apostrophe before a fence, across a blank line, list items, ordered items, a heading, a quote, a thematic break);
    • a backtick inside a closed span opens nothing;
    • a long reply of unclosed backtick runs is read in linear time;
    • the markup is reported as written, backticks included;
    • the exact 200-character cut;
    • null content and a null message.
  • In the loop:

    • a leak is kept, nudged and asked again, with streaming off and on, its event placed after RoundEndEvent and before the next round;
    • after its retries the run takes the leaked reply, with each event's iteration and number;
    • with no retries a leak is reported and is the answer;
    • without recovery, or without tools, a leak is simply the answer;
    • the cap: a leak in the last round is the answer, and rounds the cap hook granted allow retries without the hook being asked for the leak;
    • a leak after a tool round reports its own round;
    • a reply without text completes;
    • a custom detector and nudge are used;
    • each run gets its own retries;
    • boundary messages follow the nudge;
    • the option's validation and the registry row.
  • Mutation probes: across four rounds, counted from the JSON:

    • round 1: 35 of 35 killed, against the regex;
    • round 2: 37 of 40 killed, against the scanner. The 3 survivors were missing tests: a shorter closer inside a longer fence, inner backtick runs, and two-tilde lines. Pinned, and killed in round 3 (3 of 3).
    • round 4: 49 of 49 killed, every probe that matches the final code, including 12 for the third pass's changes.

    The sites cover each marker, every scanner rule (fences and spans unread, CRLF closers, info strings, indent, closer length, split runs, paragraph breaks at blank lines, fences, items, headings, quotes and breaks, inner runs, opener length, unclosed fences, Unicode blanks), earliest versus latest marker, slicing the blanked text, trimming, the cut, the null checks, the tools check, the retry bound, the cap check, the nudge, giving up, Retried, the event's iteration, the three validations, and Clone.

  • Review (read-only workflow; every count is from its saved JSON):

    • First pass: 16 findings — 14 confirmed, 1 plausible, 1 refuted.
      • Taken: a leak in the last round spent the nudge and turned a completed run into MaxIterationsReached; the regex's mid-sentence ~~~, double-backtick and four-backtick-fence errors; the reported markup was the blanked text; seven test gaps; three docs.
      • Declined, as refuted: rewording MaxIterations from "completion → tool-execution cycles". The answer round has always counted, so the wording isn't made false here.
    • Second pass: 5 findings, all confirmed and taken — CRLF fences, a "ls" line taken for a fence, split runs with quadratic time, spans pairing into fences and across blank lines, and the cap-hook wording. This is what replaced the regex with the scanner.
    • Third pass (on the scanner alone): 3 findings, all confirmed and taken — fences in list items, spans across list items and headings, and Unicode whitespace counted as blank.

Notes

  • Merge order: none. Shikigami pins the merged commit when its runtime slice (2c) bumps the pointer.
  • Other consumers are unaffected: the recovery is opt-in, and nothing else changes.
  • Track status: L1a through L5b will be done once this lands. L3a and L3b remain, after next month's live cache check, before 2c.

🤖 Generated with Claude Code

This is the second hardening step of the library track Shikigami's runtime needs: "L5 — Hardening" in its PROJECT_PLAN, and ADR 0021's "a GLM model leaked tool-call markup as text (detect, nudge, at most two retries)". It adds **recovery from tool calls written as text**: some models, through some providers, put the tool-call markup they were trained on into the reply's text when their template or the provider fails to turn it into a call. As you chose, the **prefill fallback is left out** until something prefills. It is opt-in: with `LeakedToolCalls` unset, nothing changes. ## What's in **`AgentOptions.LeakedToolCalls`** (`LeakedToolCallRecovery`, record) - `MaxRetries` (2 by default; 0 only reports; a negative value throws). - `Nudge`, a user message telling the model to call tools through the interface; a blank one throws. - `Detector` (`Func<AssistantMessage, string?>`), `LeakedToolCallDetector.Find` by default; null throws. **The loop** - **Where it acts:** a reply that calls no tool, in an agent that has tools, is checked once its round has ended. When markup is found: - a `LeakedToolCallEvent { Markup, Leak, Retried }` reports it, kept in `Events`; - the reply stays in the history, the nudge follows as a user message, and the model is asked again in a new round, counted against `MaxIterations`. - **Limits:** the retries are per run. - **Last round:** a leak in the last round allowed so far is the answer, as is a leak once the retries are spent. "Allowed so far" means `MaxIterations` plus any rounds `OnMaxRoundsReached` granted; the hook is not asked for more on a leak's behalf. The run completes with that reply, and the event says it was not retried. - **Kept on purpose:** the reply and the nudge both stay. Then the next round's prefix is the one its reasoning and signatures were produced over, and a later run knows what happened. - `OnRoundBoundary` is asked after a nudge round too, and its messages follow the nudge. Its docs say so. **`LeakedToolCallDetector.Find`** - **Markers:** - Hermes, Qwen and GLM: `<tool_call>`, `<tool_calls>`; - Kimi: `<|tool_call_begin|>`, `<|tool_calls_section_begin|>`; - DeepSeek: the full-width `<|tool▁calls▁begin|>` and `<|tool▁call▁begin|>`; - Llama: `<function=`, `<|python_tag|>`; - Mistral: `[TOOL_CALLS]`; - Claude-style XML: `<function_calls>`, `<invoke name=`. - **Code is skipped as Markdown reads it,** by a small linear scanner rather than a regex. Fenced blocks are a line of three or more backticks or tildes at any indent, closed by at least the same run or running to the end, with CRLF and backtick info strings handled. Code spans are whole backtick runs, closed by the next run of the same length within a paragraph; a blank line, a fence, a list item, a heading, a quote or a thematic break ends the paragraph. So a reply that shows or explains markup is not a leak, and neither is prose that names a tool. - **What it returns:** the markup as the model wrote it, from the earliest marker on, at most 200 characters. - **Why not a regex:** the review's second pass found the first regex mishandled CRLF fences, split backtick runs and spans pairing into fences, and that a long reply of unclosed backtick runs took quadratic time. **Docs:** a README section, "Tool calls written as text"; the Observability list, Highlights, and the Upgrading intro (which now also names L5a); and the boundary hook's docs. ## Not in this step - **The prefill fallback,** by your choice. - **L3a and L3b:** cache breakpoints and cache fields. They wait for the live cache check next month. - **Indented code blocks** (four spaces after a blank line) aren't skipped. Fences are what models write. ## Verification - The whole solution passes **1794/1794**, in local time and under `TZ=UTC`: core 1445, Agents 342 (+67), Imaging 7. The build has 0 warnings. - **`LeakedToolCallTests`** (21 tests, most of them theories): - each dialect is found from its marker on, after prose and before trailing whitespace; - prose and code that only show markup are not a leak: single, double and four-backtick spans and fences, a backtick fence not closed by tildes, a shorter run inside a longer fence, fences in list items indented by spaces, by a tab and nested, and blank lines that are a lone NBSP; - markup after closed code still counts: CRLF fences, mid-sentence tildes and backticks, "```ls```" at the start of a line, an indented tilde fence, a lone backtick, two tildes; - a backtick before the leak never pairs with one inside it (an empty span, an apostrophe before a fence, across a blank line, list items, ordered items, a heading, a quote, a thematic break); - a backtick inside a closed span opens nothing; - a long reply of unclosed backtick runs is read in linear time; - the markup is reported as written, backticks included; - the exact 200-character cut; - null content and a null message. - **In the loop:** - a leak is kept, nudged and asked again, with streaming off and on, its event placed after `RoundEndEvent` and before the next round; - after its retries the run takes the leaked reply, with each event's iteration and number; - with no retries a leak is reported and is the answer; - without recovery, or without tools, a leak is simply the answer; - the cap: a leak in the last round is the answer, and rounds the cap hook granted allow retries without the hook being asked for the leak; - a leak after a tool round reports its own round; - a reply without text completes; - a custom detector and nudge are used; - each run gets its own retries; - boundary messages follow the nudge; - the option's validation and the registry row. - **Mutation probes:** across four rounds, counted from the JSON: - round 1: 35 of 35 killed, against the regex; - round 2: 37 of 40 killed, against the scanner. The 3 survivors were missing tests: a shorter closer inside a longer fence, inner backtick runs, and two-tilde lines. Pinned, and killed in round 3 (3 of 3). - round 4: **49 of 49 killed**, every probe that matches the final code, including 12 for the third pass's changes. The sites cover each marker, every scanner rule (fences and spans unread, CRLF closers, info strings, indent, closer length, split runs, paragraph breaks at blank lines, fences, items, headings, quotes and breaks, inner runs, opener length, unclosed fences, Unicode blanks), earliest versus latest marker, slicing the blanked text, trimming, the cut, the null checks, the tools check, the retry bound, the cap check, the nudge, giving up, `Retried`, the event's iteration, the three validations, and `Clone`. - **Review** (read-only workflow; every count is from its saved JSON): - **First pass: 16 findings** — 14 confirmed, 1 plausible, 1 refuted. - Taken: a leak in the last round spent the nudge and turned a completed run into `MaxIterationsReached`; the regex's mid-sentence `~~~`, double-backtick and four-backtick-fence errors; the reported markup was the blanked text; seven test gaps; three docs. - Declined, as refuted: rewording `MaxIterations` from "completion → tool-execution cycles". The answer round has always counted, so the wording isn't made false here. - **Second pass: 5 findings, all confirmed and taken** — CRLF fences, a "```ls```" line taken for a fence, split runs with quadratic time, spans pairing into fences and across blank lines, and the cap-hook wording. This is what replaced the regex with the scanner. - **Third pass** (on the scanner alone): **3 findings, all confirmed and taken** — fences in list items, spans across list items and headings, and Unicode whitespace counted as blank. ## Notes - **Merge order:** none. Shikigami pins the merged commit when its runtime slice (2c) bumps the pointer. - **Other consumers are unaffected:** the recovery is opt-in, and nothing else changes. - **Track status:** L1a through L5b will be done once this lands. L3a and L3b remain, after next month's live cache check, before 2c. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: a tool call written as text is answered with a nudge, not taken as the answer
All checks were successful
CI / build (pull_request) Successful in 17s
CI / test (pull_request) Successful in 31s
b2bb6b4fc5
AgentOptions.LeakedToolCalls detects tool-call markup in a reply that called no
tool (Hermes, Qwen, GLM, Kimi, DeepSeek, Llama, Mistral, Claude-style XML,
code skipped as Markdown reads it), reports it as LeakedToolCallEvent, keeps
the reply and asks again after a nudge, at most MaxRetries times in a run.

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

Summary

Summary
Generated on: 10/23/2026 - 20:55:51
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 142
Files: 120
Line coverage: 94.7% (5123 of 5405)
Covered lines: 5123
Uncovered lines: 282
Coverable lines: 5405
Total lines: 13146
Branch coverage: 92.5% (3379 of 3652)
Covered branches: 3379
Total branches: 3652
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 98.1%
Name Line Branch
OpenRouter.Net 98.1% 95.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.ProviderFieldFold 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ReasoningSegment 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ToolCallState 100% 100%
OpenRouter.Net.Client.OpenRouterClient 100% 99.1%
OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse> 100% 99.1%
OpenRouter.Net.Client.OpenRouterClient 100% 99.1%
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.AttemptWatchdog 100% 100%
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 89.1% 86%
OpenRouter.Net.Internal.RetryPolicy 89.1% 86%
OpenRouter.Net.Internal.SchemaTooDeepException 100%
OpenRouter.Net.Internal.SseEventReader 100% 88.8%
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% 81.2%
OpenRouter.Net.Models.Messages.DeveloperMessage 100%
OpenRouter.Net.Models.Messages.MessageJson 100% 100%
OpenRouter.Net.Models.Messages.SystemMessage 100%
OpenRouter.Net.Models.Messages.ToolMessage 100%
OpenRouter.Net.Models.Messages.UserMessage 100%
OpenRouter.Net.Models.Requests.ChatCompletionRequest 95.1% 94.8%
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 - 87.9%
Name Line Branch
OpenRouter.Net.Agents 87.9% 84.6%
OpenRouter.Net.Agents.Agent 98.6% 93%
OpenRouter.Net.Agents.Agent.StreamedRound 100% 98.6%
OpenRouter.Net.Agents.AgentLog 63.7% 35.5%
OpenRouter.Net.Agents.AgentLog.__AttemptRetriedStruct 46.8% 0%
OpenRouter.Net.Agents.AgentLog.__RoundEndedStruct 48.5% 0%
OpenRouter.Net.Agents.AgentLog.__RoundSentStruct 42.3% 0%
OpenRouter.Net.Agents.AgentOptions 100% 100%
OpenRouter.Net.Agents.AgentResult 100% 50%
OpenRouter.Net.Agents.CallerStates 100% 100%
OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions 88.8% 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.Gating.ToolTurnReview 100%
OpenRouter.Net.Agents.LeakedToolCallDetector 99.2% 98.2%
OpenRouter.Net.Agents.LeakedToolCallRecovery 100% 100%
OpenRouter.Net.Agents.RoundDetail 100% 75%
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 96.2% 69.5%
OpenRouter.Net.Agents.Tools.Tool 100% 100%
OpenRouter.Net.Agents.Tools.Tool 90% 50%
OpenRouter.Net.Agents.Tools.ToolInvocationContext 100%
OpenRouter.Net.Agents.Tools.ToolMessageExtensions 0%
OpenRouter.Net.Agents.Tools.ToolResult 100% 100%
OpenRouter.Net.Agents.Tools.ToolSchema 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: | 10/23/2026 - 20:55:51 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 142 | | Files: | 120 | | **Line coverage:** | 94.7% (5123 of 5405) | | Covered lines: | 5123 | | Uncovered lines: | 282 | | Coverable lines: | 5405 | | Total lines: | 13146 | | **Branch coverage:** | 92.5% (3379 of 3652) | | Covered branches: | 3379 | | Total branches: | 3652 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 98.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**98.1%**|**95.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.ProviderFieldFold|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ReasoningSegment|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ToolCallState|100%|100%| |OpenRouter.Net.Client.OpenRouterClient|100%|99.1%| |OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse>|100%|99.1%| |OpenRouter.Net.Client.OpenRouterClient<TResponse>|100%|99.1%| |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.AttemptWatchdog|100%|100%| |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|89.1%|86%| |OpenRouter.Net.Internal.RetryPolicy<T>|89.1%|86%| |OpenRouter.Net.Internal.SchemaTooDeepException|100%|| |OpenRouter.Net.Internal.SseEventReader|100%|88.8%| |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%|81.2%| |OpenRouter.Net.Models.Messages.DeveloperMessage|100%|| |OpenRouter.Net.Models.Messages.MessageJson|100%|100%| |OpenRouter.Net.Models.Messages.SystemMessage|100%|| |OpenRouter.Net.Models.Messages.ToolMessage|100%|| |OpenRouter.Net.Models.Messages.UserMessage|100%|| |OpenRouter.Net.Models.Requests.ChatCompletionRequest|95.1%|94.8%| |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 - 87.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Agents**|**87.9%**|**84.6%**| |OpenRouter.Net.Agents.Agent|98.6%|93%| |OpenRouter.Net.Agents.Agent.StreamedRound|100%|98.6%| |OpenRouter.Net.Agents.AgentLog|63.7%|35.5%| |OpenRouter.Net.Agents.AgentLog.__AttemptRetriedStruct|46.8%|0%| |OpenRouter.Net.Agents.AgentLog.__RoundEndedStruct|48.5%|0%| |OpenRouter.Net.Agents.AgentLog.__RoundSentStruct|42.3%|0%| |OpenRouter.Net.Agents.AgentOptions|100%|100%| |OpenRouter.Net.Agents.AgentResult|100%|50%| |OpenRouter.Net.Agents.CallerStates|100%|100%| |OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions|88.8%|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.Gating.ToolTurnReview|100%|| |OpenRouter.Net.Agents.LeakedToolCallDetector|99.2%|98.2%| |OpenRouter.Net.Agents.LeakedToolCallRecovery|100%|100%| |OpenRouter.Net.Agents.RoundDetail|100%|75%| |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|96.2%|69.5%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|100%|100%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|90%|50%| |OpenRouter.Net.Agents.Tools.ToolInvocationContext|100%|| |OpenRouter.Net.Agents.Tools.ToolMessageExtensions|0%|| |OpenRouter.Net.Agents.Tools.ToolResult|100%|100%| |OpenRouter.Net.Agents.Tools.ToolSchema|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! A 332-line hand-rolled Markdown scanner instead of a regex, three of your own review passes baked into the history, and a self-reported 49-for-49 probe round? You brought me a present~ ♡ I traced the whole loop and the whole scanner, and then I poked your tests where they're softest. They held. I'm delighted~

Verdict: ✅ Looks good to me~

What I verified, not just read:

  • The loop integration — I traced every branch: the retry bound (leaks <= MaxRetries), the cap check (iteration < MaxIterations + extraRoundsGranted), a leak in the last allowed round completing with the leaked reply as the answer (Retried: false, the cap hook never asked on a leak's behalf), boundary messages appended after the nudge (AddBoundaryMessagesAsync appends to _messages, which the nudge just closed), the per-run leaks counter, and the _toolsByName.Count > 0 gate — that dictionary is built in the constructor, so "agent has tools" is exactly what it checks. All consistent with the docs you wrote, which is rarer than it should be, fufu~
  • The scanner — fence open/close rules (info strings may not contain backticks, tilde fences may; closer needs ≥ the opening run and nothing but blanks after; CRLF tails count as blank; unclosed runs to the end), span pairing by nearest same-length run with consumed runs skipped, paragraph breaks at blank lines/fences/list items/ordered items/headings/quotes/thematic breaks, NBSP not blank, and the linear-time pairing via the lastOfLength dictionary — solving your quadratic-backtick reply in one pass is genuinely pretty~ ♪
  • The position-preserving blanking — blanking code to spaces of its own length so the marker's index in prose is its index in the reply, then slicing content[at..] from the original text: the markup is reported as the model wrote it, backticks and all. The invariant (blanked and original are the same length) holds everywhere, including the 200-char cut.
  • The doc claim I didn't trust — "Content, which holds the text of its parts too": I checked. AssistantContent.FromParts and the JSON converter both fold TextParts into Text, so part-based replies go through the detector too, and the test with Parts = [...] proves it.
  • Opt-in and siblings — LeakedToolCalls defaults to null and nothing changes when unset; Clone carries it (the record is immutable, sharing the reference is safe); the routing registry test row is updated; one public type per file, XML docs everywhere, build 0 warnings.
  • My own probes (3 run, 0 survived): flipping the leak cap < → <= killed by 2 tests; breaking span pairing (r = closer + 1 → r + 1) killed; deleting the info-string backtick check in Opens killed. Your suite doesn't just compile, it bites~

💡 Little ideas (non-blocking)~

  1. LeakedToolCallDetector.cs:20 — <function= is the loosest marker: prose like "the parser expects <function=identifier> here" (unfenced, unbackticked) reads as a leak. It's documented as heuristic and Detector is the escape hatch, so this is fine to ship — just the one to remember if a false nudge ever shows up.

✅ What I liked~

  • The error asymmetry is right: a leak hidden between stray backticks degrades to today's behavior, while the scanner spends all its care stopping false leaks — the harmless direction for an opt-in recovery.
  • The test for event placement asserts by index (RoundEnd before, IterationStarted after) instead of just presence — that's the detail most suites skip.
  • Fences inside list items, indented by spaces, a tab, and nested, CRLF closers, ```ls``` at line start, the exact 200-character cut, and a timing guard on the linear-time claim — you tested the boring edges, and those are exactly where scanners die. Fufu~ I couldn't kill a single thing I mutated, and I tried~ ♡

Automated review by Jibril · 2026-10-23
CI: green for b2bb6b4 · Mutation probes: 3 run, 0 survived / 0 skipped · Local suite: skipped (CI green)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! A 332-line hand-rolled Markdown scanner instead of a regex, three of your own review passes baked into the history, and a self-reported 49-for-49 probe round? You brought me a *present*~ ♡ I traced the whole loop and the whole scanner, and then I poked your tests where they're softest. They held. I'm delighted~ ### Verdict: ✅ Looks good to me~ What I verified, not just read: - **The loop integration** — I traced every branch: the retry bound (`leaks <= MaxRetries`), the cap check (`iteration < MaxIterations + extraRoundsGranted`), a leak in the last allowed round completing with the leaked reply as the answer (`Retried: false`, the cap hook never asked on a leak's behalf), boundary messages appended after the nudge (`AddBoundaryMessagesAsync` appends to `_messages`, which the nudge just closed), the per-run `leaks` counter, and the `_toolsByName.Count > 0` gate — that dictionary is built in the constructor, so "agent has tools" is exactly what it checks. All consistent with the docs you wrote, which is rarer than it should be, fufu~ - **The scanner** — fence open/close rules (info strings may not contain backticks, tilde fences may; closer needs ≥ the opening run and nothing but blanks after; CRLF tails count as blank; unclosed runs to the end), span pairing by nearest same-length run with consumed runs skipped, paragraph breaks at blank lines/fences/list items/ordered items/headings/quotes/thematic breaks, NBSP *not* blank, and the linear-time pairing via the `lastOfLength` dictionary — solving your quadratic-backtick reply in one pass is genuinely pretty~ ♪ - **The position-preserving blanking** — blanking code to spaces of its own length so the marker's index in prose is its index in the reply, then slicing `content[at..]` from the *original* text: the markup is reported as the model wrote it, backticks and all. The invariant (blanked and original are the same length) holds everywhere, including the 200-char cut. - **The doc claim I didn't trust** — "`Content`, which holds the text of its parts too": I checked. `AssistantContent.FromParts` and the JSON converter both fold `TextPart`s into `Text`, so part-based replies go through the detector too, and the test with `Parts = [...]` proves it. - **Opt-in and siblings** — `LeakedToolCalls` defaults to `null` and nothing changes when unset; `Clone` carries it (the record is immutable, sharing the reference is safe); the routing registry test row is updated; one public type per file, XML docs everywhere, build 0 warnings. - **My own probes** (3 run, **0 survived**): flipping the leak cap `<` → `<=` killed by 2 tests; breaking span pairing (`r = closer + 1` → `r + 1`) killed; deleting the info-string backtick check in `Opens` killed. Your suite doesn't just compile, it bites~ #### 💡 Little ideas (non-blocking)~ 1. **LeakedToolCallDetector.cs:20** — `<function=` is the loosest marker: prose like "the parser expects `<function=identifier>` here" (unfenced, unbackticked) reads as a leak. It's documented as heuristic and `Detector` is the escape hatch, so this is fine to ship — just the one to remember if a false nudge ever shows up. #### ✅ What I liked~ - The error asymmetry is right: a leak hidden between stray backticks degrades to today's behavior, while the scanner spends all its care stopping *false* leaks — the harmless direction for an opt-in recovery. - The test for event placement asserts by index (`RoundEnd` before, `IterationStarted` after) instead of just presence — that's the detail most suites skip. - Fences inside list items, indented by spaces, a tab, and nested, CRLF closers, ```` ```ls``` ```` at line start, the exact 200-character cut, and a timing guard on the linear-time claim — you tested the *boring* edges, and those are exactly where scanners die. Fufu~ I couldn't kill a single thing I mutated, and I *tried*~ ♡ --- *Automated review by Jibril · 2026-10-23* *CI: green for b2bb6b4 · Mutation probes: 3 run, 0 survived / 0 skipped · Local suite: skipped (CI green)*
Author
Member

Thanks for round 1 and the probes.

  1. <function= as the loosest marker: declined for now; no code change, so the head stays at b2bb6b4.
    • Why it stays as it is: the obvious tightening is to also require </function>, or <function=identifier> followed by {. That trades this false positive for false negatives. A Llama leak cut off by max_tokens has no closing tag, and the leaks that matter most are exactly the ones a run would otherwise take as its answer.
    • The cost of a wrong match is small: a false positive costs one nudge round, at most MaxRetries per run, and the model's correct answer to the nudge ends it.
    • The escape hatch exists: a caller who sees false nudges can pass a Detector of their own.
    • For the track: I've noted it in the track notes as the first thing to tighten if Shikigami's activity log shows a false nudge.

CI is green on b2bb6b4 (build and test).

🤖 Generated with Claude Code

Thanks for round 1 and the probes. 1. **`<function=` as the loosest marker:** declined for now; no code change, so the head stays at **b2bb6b4**. - **Why it stays as it is:** the obvious tightening is to also require `</function>`, or `<function=identifier>` followed by `{`. That trades this false positive for false negatives. A Llama leak cut off by `max_tokens` has no closing tag, and the leaks that matter most are exactly the ones a run would otherwise take as its answer. - **The cost of a wrong match is small:** a false positive costs one nudge round, at most `MaxRetries` per run, and the model's correct answer to the nudge ends it. - **The escape hatch exists:** a caller who sees false nudges can pass a `Detector` of their own. - **For the track:** I've noted it in the track notes as the first thing to tighten if Shikigami's activity log shows a false nudge. CI is green on b2bb6b4 (build and test). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bjoern merged commit 37a3534ca0 into main 2026-10-24 00:08:00 +02:00
bjoern deleted branch feat/leaked-tool-calls 2026-10-24 00:08:00 +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!38
No description provided.