feat: a running agent reads the messages that arrived meanwhile #36

Merged
bjoern merged 2 commits from feat/steering-hook into main 2026-10-23 20:58:10 +02:00
Member

This is the fifth step of the library track Shikigami's runtime needs: "Steering queue" in its PROJECT_PLAN. ADR 0020 says a message is never dropped: one the owner sends while the turn runs reaches it at its next round boundary, before the next model call, and if the turn has finished, it starts the next one. ADR 0021: a message queued mid-turn is appended after the round it arrived in. As you chose, it is a pull hook, not a library queue: the runtime keeps its queued rows and their withdraw ×, and the agent asks for them at the right moment. It is opt-in: with no hook, nothing changes for Orihon, Kagura or Tsumugi.

What's in

AgentOptions.OnRoundBoundary — Func<AgentSnapshot, CancellationToken, ValueTask<IReadOnlyList<Message>?>>

  • When it's asked: between a tool round and the next request, after the cancel check. Never before a run's first round, after a cancel, after the answer, or at the cap unless OnMaxRoundsReached grants more. A message that arrives then waits and starts the next run, as ADR 0020 asks.
  • Where the messages go: appended after the round's whole tool block, answers and images, so every tool_call_id is still answered by one unbroken run of tool messages.
  • Blind models: the ImageDescriber describes the images of the added messages only. A reply's own images earlier in the run stay as they came.
  • MessagesAddedEvent { Iteration, Messages }: reported before the round that sends them. It's in Events and goes to the progress sink and OnEvent; it's the place to mark rows taken, matching by position. It is reported even when describing fails.
  • Added is kept: from then on the messages are in the history, however the run ends. A run that ends before that round's reply leaves them last in History, unanswered, so ContinueAsync answers them. AgentResult.PartialMessage's doc and the README's cancel section now say History ends at the last complete round and any messages added after it.

What adds nothing, the tool block answered either way

  • null or an empty list.
  • A run cancelled while the hook ran: what it returned is still the caller's, for the next run.
  • A null entry anywhere, an entry that isn't a UserMessage, or a UserMessage without content. These end the run with an InvalidOperationException naming the problem. A tool or assistant message would break the tool block, and an empty user turn would make every later request fail.
  • A hook that throws: the run ends with its exception. A cancel of the run's token inside it ends the run as cancelled, per Cancellation.

Sub-agents never take the parent's messages

  • Clone() copies the hook, but a sub-agent from CreateSubAgent or a SubAgentTool starts without it.
  • CreateSubAgent's configure callback can give it its own hook, and then needs its own OnEvent too, since it inherits the parent's. The docs say so.

Docs

  • A README section, "Messages that arrive mid-run". Its sample peeks in the hook and marks rows taken at the event.
  • Notes in Sub-agents, Observability, Highlights and the Upgrading intro.
  • The XML docs on the option, the event, AgentSnapshot, IAgent.CreateSubAgent and SubAgentTool.

Not in this step

  • Cache controls (L3a, then L3b) come next, then hardening (L5a, L5b).
  • No Enqueue/withdraw API in the library, by your choice of the pull hook. Shikigami's runtime (2c) owns the queue rows, the chips and their ×.

Verification

  • The whole solution passes 1686/1686, in local time and under TZ=UTC: core 1415, Agents 264 (+26), Imaging 7. The build has 0 warnings.
  • AgentSteeringTests (17 tests, several theories), each checking the result and that nothing else changed:
    • the added message follows the whole tool block (two answers, then the injected image) and is in the next request, with streaming off and on;
    • the hook is asked before rounds 2 and 3 of a run, not before its first round or after its answer;
    • at the cap it is asked only when more rounds are granted;
    • the event comes once, between the previous RoundEndEvent and the next IterationStartedEvent, carrying the next round's number; an empty return adds nothing and reports nothing;
    • a null entry at the start, middle or end, a tool, assistant or system message at varying positions, and an empty user message each throw the exact message, with nothing added and one request;
    • with both cancellation modes: a cancel inside the hook (the hook got the run's token), a hook that cancels and still returns messages (nothing taken), and a cancel during the tool round (the hook is never asked);
    • blind mode describes only the added images; a describer that fails still reports the event; a vision model keeps the images;
    • CreateSubAgent and SubAgentTool sub-agents never ask the parent's hook, while configure can give one its own;
    • the options registry row.
  • Mutation probes: 16 sites, 16 killed, 0 survived. The second pass's new check had its own probe, in a second round. The sites:
    • asking before the first round;
    • asking before the cancel check;
    • the post-hook cancel check removed;
    • an empty list reported;
    • checking only the last entry for null;
    • letting system messages through;
    • checking only the first entry's type;
    • describing for a vision model;
    • describing from index 0;
    • the event emitted outside the finally;
    • the event's iteration;
    • the snapshot's iteration;
    • each sub-agent path keeping the hook;
    • Clone dropping it;
    • an empty user message allowed.
  • Review (read-only workflow; every count is from its saved JSON):
    • First pass: 16 findings — 11 confirmed, 4 plausible, 1 refuted. All taken except the refuted one.
      • SubAgentTool kept the hook, so a delegated run would take the parent's messages.
      • Any role was accepted.
      • A cancel during the hook appended anyway.
      • A misplaced XML summary.
      • Five missing tests.
      • Six doc fixes, including a sample that took rows in the hook and contradicted "mark them taken at the event".
      • Declined, as refuted: "the streaming theory row tests nothing new". Both paths send the same request, and the row stays as a cheap guard.
    • Second pass over the fixes: 5 findings — 3 confirmed, 2 plausible. All taken: "History ends at the last complete round" corrected in three places; the sub-agent's own hook needs its own OnEvent; an empty user message refused; the cap qualifier added to the XML doc; the event doc on a failed describe.

Notes

  • Merge order: none. Shikigami pins the merged commit when its runtime slice (2c) bumps the pointer.
  • Other consumers are unaffected. The hook is opt-in; IAgent gains no member, and AgentSnapshot is reused unchanged.

🤖 Generated with Claude Code

This is the fifth step of the library track Shikigami's runtime needs: "Steering queue" in its PROJECT_PLAN. ADR 0020 says a message is never dropped: one the owner sends while the turn runs reaches it at its next round boundary, before the next model call, and if the turn has finished, it starts the next one. ADR 0021: a message queued mid-turn is appended after the round it arrived in. As you chose, it is a **pull hook, not a library queue**: the runtime keeps its queued rows and their withdraw ×, and the agent asks for them at the right moment. It is opt-in: with no hook, nothing changes for Orihon, Kagura or Tsumugi. ## What's in **`AgentOptions.OnRoundBoundary`** — `Func<AgentSnapshot, CancellationToken, ValueTask<IReadOnlyList<Message>?>>` - **When it's asked:** between a tool round and the next request, after the cancel check. Never before a run's first round, after a cancel, after the answer, or at the cap unless `OnMaxRoundsReached` grants more. A message that arrives then waits and starts the next run, as ADR 0020 asks. - **Where the messages go:** appended after the round's **whole tool block**, answers and images, so every `tool_call_id` is still answered by one unbroken run of tool messages. - **Blind models:** the `ImageDescriber` describes the images of the added messages only. A reply's own `images` earlier in the run stay as they came. - **`MessagesAddedEvent { Iteration, Messages }`:** reported before the round that sends them. It's in `Events` and goes to the progress sink and `OnEvent`; it's the place to mark rows taken, matching by position. It is reported even when describing fails. - **Added is kept:** from then on the messages are in the history, however the run ends. A run that ends before that round's reply leaves them last in `History`, unanswered, so `ContinueAsync` answers them. `AgentResult.PartialMessage`'s doc and the README's cancel section now say `History` ends at the last complete round *and any messages added after it*. **What adds nothing, the tool block answered either way** - `null` or an empty list. - A run **cancelled while the hook ran**: what it returned is still the caller's, for the next run. - A null entry anywhere, an entry that isn't a `UserMessage`, or a `UserMessage` without content. These end the run with an `InvalidOperationException` naming the problem. A tool or assistant message would break the tool block, and an empty user turn would make every later request fail. - A hook that throws: the run ends with its exception. A cancel of the run's token inside it ends the run as cancelled, per `Cancellation`. **Sub-agents never take the parent's messages** - `Clone()` copies the hook, but a sub-agent from `CreateSubAgent` or a `SubAgentTool` starts without it. - `CreateSubAgent`'s configure callback can give it its own hook, and then needs its own `OnEvent` too, since it inherits the parent's. The docs say so. **Docs** - A README section, "Messages that arrive mid-run". Its sample peeks in the hook and marks rows taken at the event. - Notes in Sub-agents, Observability, Highlights and the Upgrading intro. - The XML docs on the option, the event, `AgentSnapshot`, `IAgent.CreateSubAgent` and `SubAgentTool`. ## Not in this step - **Cache controls** (L3a, then L3b) come next, then hardening (L5a, L5b). - **No `Enqueue`/withdraw API in the library**, by your choice of the pull hook. Shikigami's runtime (2c) owns the queue rows, the chips and their ×. ## Verification - The whole solution passes **1686/1686**, in local time and under `TZ=UTC`: core 1415, Agents 264 (+26), Imaging 7. The build has 0 warnings. - **`AgentSteeringTests`** (17 tests, several theories), each checking the result and that nothing else changed: - the added message follows the whole tool block (two answers, then the injected image) and is in the next request, with streaming off and on; - the hook is asked before rounds 2 and 3 of a run, not before its first round or after its answer; - at the cap it is asked only when more rounds are granted; - the event comes once, between the previous `RoundEndEvent` and the next `IterationStartedEvent`, carrying the next round's number; an empty return adds nothing and reports nothing; - a null entry at the start, middle or end, a tool, assistant or system message at varying positions, and an empty user message each throw the exact message, with nothing added and one request; - with both cancellation modes: a cancel inside the hook (the hook got the run's token), a hook that cancels and still returns messages (nothing taken), and a cancel during the tool round (the hook is never asked); - blind mode describes only the added images; a describer that fails still reports the event; a vision model keeps the images; - `CreateSubAgent` and `SubAgentTool` sub-agents never ask the parent's hook, while configure can give one its own; - the options registry row. - **Mutation probes:** 16 sites, **16 killed**, 0 survived. The second pass's new check had its own probe, in a second round. The sites: - asking before the first round; - asking before the cancel check; - the post-hook cancel check removed; - an empty list reported; - checking only the last entry for null; - letting system messages through; - checking only the first entry's type; - describing for a vision model; - describing from index 0; - the event emitted outside the `finally`; - the event's iteration; - the snapshot's iteration; - each sub-agent path keeping the hook; - `Clone` dropping it; - an empty user message allowed. - **Review** (read-only workflow; every count is from its saved JSON): - **First pass: 16 findings** — 11 confirmed, 4 plausible, 1 refuted. All taken except the refuted one. - `SubAgentTool` kept the hook, so a delegated run would take the parent's messages. - Any role was accepted. - A cancel during the hook appended anyway. - A misplaced XML summary. - Five missing tests. - Six doc fixes, including a sample that took rows in the hook and contradicted "mark them taken at the event". - Declined, as refuted: "the streaming theory row tests nothing new". Both paths send the same request, and the row stays as a cheap guard. - **Second pass** over the fixes: **5 findings** — 3 confirmed, 2 plausible. All taken: "History ends at the last complete round" corrected in three places; the sub-agent's own hook needs its own `OnEvent`; an empty user message refused; the cap qualifier added to the XML doc; the event doc on a failed describe. ## Notes - **Merge order:** none. Shikigami pins the merged commit when its runtime slice (2c) bumps the pointer. - **Other consumers are unaffected.** The hook is opt-in; `IAgent` gains no member, and `AgentSnapshot` is reused unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: a running agent reads the messages that arrived meanwhile
All checks were successful
CI / build (pull_request) Successful in 18s
CI / test (pull_request) Successful in 28s
aa279725fc
AgentOptions.OnRoundBoundary is asked between a tool round and the next
request for user messages to add first; they follow the whole tool block,
are reported as MessagesAddedEvent and stay in the history. The library
keeps no queue. Sub-agents start without the hook.

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

Summary

Summary
Generated on: 10/23/2026 - 18:41:07
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 136
Files: 116
Line coverage: 96.3% (4722 of 4901)
Covered lines: 4722
Uncovered lines: 179
Coverable lines: 4901
Total lines: 12189
Branch coverage: 93.6% (3083 of 3291)
Covered branches: 3083
Total branches: 3291
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 98.1%
Name Line Branch
OpenRouter.Net 98.1% 95.8%
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%
OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse> 100% 99%
OpenRouter.Net.Client.OpenRouterClient 100% 99%
OpenRouter.Net.Client.OpenRouterClientOptions 100%
OpenRouter.Net.Client.OpenRouterRequestLoggingHandler 78.7% 62.5%
OpenRouter.Net.Client.RetryOptions 100% 100%
OpenRouter.Net.Client.StreamResultOptions 100%
OpenRouter.Net.Contracts.IOpenRouterClient 100% 100%
OpenRouter.Net.Extensions.ServiceCollectionExtensions 100% 100%
OpenRouter.Net.Internal.AssistantContent 91.6% 75%
OpenRouter.Net.Internal.AssistantContentJsonConverter 75% 64.2%
OpenRouter.Net.Internal.ContentPartListConverter 85.7% 66.6%
OpenRouter.Net.Internal.DecimalStringJsonConverter 85.7% 87.5%
OpenRouter.Net.Internal.DecisionAnswerJsonConverter 100% 100%
OpenRouter.Net.Internal.DecisionContentJsonConverter 100%
OpenRouter.Net.Internal.DecisionQuestionJsonConverter 100% 100%
OpenRouter.Net.Internal.DecisionWire 100% 94.4%
OpenRouter.Net.Internal.EnumChoiceLabels.Map 100%
OpenRouter.Net.Internal.EnumChoiceLabels 100% 100%
OpenRouter.Net.Internal.ErrorParser 100% 75%
OpenRouter.Net.Internal.FailureKindMap 100% 100%
OpenRouter.Net.Internal.JsonOptions 100%
OpenRouter.Net.Internal.JsonPointer 100%
OpenRouter.Net.Internal.JsonSchemaRefs 100% 100%
OpenRouter.Net.Internal.JsonSchemaRefs.Pass 100% 100%
OpenRouter.Net.Internal.ModelIdJsonConverter 100% 50%
OpenRouter.Net.Internal.NumberOrStringJsonConverter 100% 100%
OpenRouter.Net.Internal.PercentileCutoffsJsonConverter 93.3% 93.1%
OpenRouter.Net.Internal.ProviderSortConfigJsonConverter 94% 95.8%
OpenRouter.Net.Internal.RawDecisionQuestionJsonConverter 100%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.SchemaTooDeepException 100%
OpenRouter.Net.Internal.SseEventReader 100% 88.8%
OpenRouter.Net.Internal.StallWatchdog 100% 100%
OpenRouter.Net.Internal.StreamOutcomes 100% 100%
OpenRouter.Net.Internal.StreamSession 100% 100%
OpenRouter.Net.Internal.TolerantStringEnumConverter 100% 98.1%
OpenRouter.Net.Internal.ToolChoiceJsonConverter 61.7% 46.1%
OpenRouter.Net.Internal.TooManyRefsFollowedException 100%
OpenRouter.Net.Internal.UnknownDecisionAnswerJsonConverter 100%
OpenRouter.Net.Models.Common.ApiError 100% 100%
OpenRouter.Net.Models.Common.CacheControl 100%
OpenRouter.Net.Models.Common.ModelId 95.1% 92.9%
OpenRouter.Net.Models.Common.QuantizationExtensions 100% 100%
OpenRouter.Net.Models.Common.Result 100% 100%
OpenRouter.Net.Models.Common.Result.Failure 100% 100%
OpenRouter.Net.Models.Common.Result.Success 100%
OpenRouter.Net.Models.Common.Result 100% 100%
OpenRouter.Net.Models.Content.AudioPart 100%
OpenRouter.Net.Models.Content.ImagePart 100%
OpenRouter.Net.Models.Content.TextPart 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceKey 100%
OpenRouter.Net.Models.Decisions.ChoiceQuestion 100%
OpenRouter.Net.Models.Decisions.DecisionAnswer 100%
OpenRouter.Net.Models.Decisions.DecisionContent 100% 90.9%
OpenRouter.Net.Models.Decisions.DecisionKey 100%
OpenRouter.Net.Models.Decisions.DecisionQuestion 100% 100%
OpenRouter.Net.Models.Decisions.DecisionQuestionSet 100% 100%
OpenRouter.Net.Models.Decisions.DecisionsRequest 97.7% 97.6%
OpenRouter.Net.Models.Decisions.DecisionsResponse 100% 93.3%
OpenRouter.Net.Models.Decisions.NoulAnswer 100%
OpenRouter.Net.Models.Decisions.NoulQuestion 100%
OpenRouter.Net.Models.Decisions.OpenRouterClientDecisionsExtensions 100% 83.3%
OpenRouter.Net.Models.Decisions.RawDecisionQuestion 100% 100%
OpenRouter.Net.Models.Decisions.ScoreAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ScoreQuestion 100%
OpenRouter.Net.Models.Decisions.UnknownDecisionAnswer 100% 100%
OpenRouter.Net.Models.Messages.AssistantMessage 100% 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 - 91.9%
Name Line Branch
OpenRouter.Net.Agents 91.9% 87.1%
OpenRouter.Net.Agents.Agent 98.5% 93.1%
OpenRouter.Net.Agents.Agent.StreamedRound 100% 98.6%
OpenRouter.Net.Agents.AgentOptions 100% 100%
OpenRouter.Net.Agents.AgentResult 100% 50%
OpenRouter.Net.Agents.CallerStates 100% 100%
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.Gating.ToolTurnReview 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 - 18:41:07 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 136 | | Files: | 116 | | **Line coverage:** | 96.3% (4722 of 4901) | | Covered lines: | 4722 | | Uncovered lines: | 179 | | Coverable lines: | 4901 | | Total lines: | 12189 | | **Branch coverage:** | 93.6% (3083 of 3291) | | Covered branches: | 3083 | | Total branches: | 3291 | | **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.8%**| |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%| |OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse>|100%|99%| |OpenRouter.Net.Client.OpenRouterClient<TResponse>|100%|99%| |OpenRouter.Net.Client.OpenRouterClientOptions|100%|| |OpenRouter.Net.Client.OpenRouterRequestLoggingHandler|78.7%|62.5%| |OpenRouter.Net.Client.RetryOptions|100%|100%| |OpenRouter.Net.Client.StreamResultOptions|100%|| |OpenRouter.Net.Contracts.IOpenRouterClient|100%|100%| |OpenRouter.Net.Extensions.ServiceCollectionExtensions|100%|100%| |OpenRouter.Net.Internal.AssistantContent|91.6%|75%| |OpenRouter.Net.Internal.AssistantContentJsonConverter|75%|64.2%| |OpenRouter.Net.Internal.ContentPartListConverter|85.7%|66.6%| |OpenRouter.Net.Internal.DecimalStringJsonConverter|85.7%|87.5%| |OpenRouter.Net.Internal.DecisionAnswerJsonConverter|100%|100%| |OpenRouter.Net.Internal.DecisionContentJsonConverter|100%|| |OpenRouter.Net.Internal.DecisionQuestionJsonConverter|100%|100%| |OpenRouter.Net.Internal.DecisionWire|100%|94.4%| |OpenRouter.Net.Internal.EnumChoiceLabels.Map<TEnum>|100%|| |OpenRouter.Net.Internal.EnumChoiceLabels<TEnum>|100%|100%| |OpenRouter.Net.Internal.ErrorParser|100%|75%| |OpenRouter.Net.Internal.FailureKindMap|100%|100%| |OpenRouter.Net.Internal.JsonOptions|100%|| |OpenRouter.Net.Internal.JsonPointer|100%|| |OpenRouter.Net.Internal.JsonSchemaRefs|100%|100%| |OpenRouter.Net.Internal.JsonSchemaRefs.Pass|100%|100%| |OpenRouter.Net.Internal.ModelIdJsonConverter|100%|50%| |OpenRouter.Net.Internal.NumberOrStringJsonConverter|100%|100%| |OpenRouter.Net.Internal.PercentileCutoffsJsonConverter|93.3%|93.1%| |OpenRouter.Net.Internal.ProviderSortConfigJsonConverter|94%|95.8%| |OpenRouter.Net.Internal.RawDecisionQuestionJsonConverter|100%|| |OpenRouter.Net.Internal.RetryPolicy|90.9%|84.7%| |OpenRouter.Net.Internal.RetryPolicy<T>|90.9%|84.7%| |OpenRouter.Net.Internal.SchemaTooDeepException|100%|| |OpenRouter.Net.Internal.SseEventReader|100%|88.8%| |OpenRouter.Net.Internal.StallWatchdog|100%|100%| |OpenRouter.Net.Internal.StreamOutcomes|100%|100%| |OpenRouter.Net.Internal.StreamSession|100%|100%| |OpenRouter.Net.Internal.TolerantStringEnumConverter<T>|100%|98.1%| |OpenRouter.Net.Internal.ToolChoiceJsonConverter|61.7%|46.1%| |OpenRouter.Net.Internal.TooManyRefsFollowedException|100%|| |OpenRouter.Net.Internal.UnknownDecisionAnswerJsonConverter|100%|| |OpenRouter.Net.Models.Common.ApiError|100%|100%| |OpenRouter.Net.Models.Common.CacheControl|100%|| |OpenRouter.Net.Models.Common.ModelId|95.1%|92.9%| |OpenRouter.Net.Models.Common.QuantizationExtensions|100%|100%| |OpenRouter.Net.Models.Common.Result|100%|100%| |OpenRouter.Net.Models.Common.Result.Failure<T>|100%|100%| |OpenRouter.Net.Models.Common.Result.Success<T>|100%|| |OpenRouter.Net.Models.Common.Result<T>|100%|100%| |OpenRouter.Net.Models.Content.AudioPart|100%|| |OpenRouter.Net.Models.Content.ImagePart|100%|| |OpenRouter.Net.Models.Content.TextPart|100%|| |OpenRouter.Net.Models.Decisions.ChoiceAnswer|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceAnswer<TEnum>|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceAnswer<TEnum>|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceKey<TEnum>|100%|| |OpenRouter.Net.Models.Decisions.ChoiceQuestion|100%|| |OpenRouter.Net.Models.Decisions.DecisionAnswer|100%|| |OpenRouter.Net.Models.Decisions.DecisionContent|100%|90.9%| |OpenRouter.Net.Models.Decisions.DecisionKey<TAnswer>|100%|| |OpenRouter.Net.Models.Decisions.DecisionQuestion|100%|100%| |OpenRouter.Net.Models.Decisions.DecisionQuestionSet|100%|100%| |OpenRouter.Net.Models.Decisions.DecisionsRequest|97.7%|97.6%| |OpenRouter.Net.Models.Decisions.DecisionsResponse|100%|93.3%| |OpenRouter.Net.Models.Decisions.NoulAnswer|100%|| |OpenRouter.Net.Models.Decisions.NoulQuestion|100%|| |OpenRouter.Net.Models.Decisions.OpenRouterClientDecisionsExtensions|100%|83.3%| |OpenRouter.Net.Models.Decisions.RawDecisionQuestion|100%|100%| |OpenRouter.Net.Models.Decisions.ScoreAnswer|100%|100%| |OpenRouter.Net.Models.Decisions.ScoreQuestion|100%|| |OpenRouter.Net.Models.Decisions.UnknownDecisionAnswer|100%|100%| |OpenRouter.Net.Models.Messages.AssistantMessage|100%|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 - 91.9%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Agents**|**91.9%**|**87.1%**| |OpenRouter.Net.Agents.Agent|98.5%|93.1%| |OpenRouter.Net.Agents.Agent.StreamedRound|100%|98.6%| |OpenRouter.Net.Agents.AgentOptions|100%|100%| |OpenRouter.Net.Agents.AgentResult|100%|50%| |OpenRouter.Net.Agents.CallerStates|100%|100%| |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.Gating.ToolTurnReview|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 pull hook — the runtime keeps its queue rows and their withdraw ×, and the agent just asks at the round boundary. No library queue to drift out of sync, opt-in so Orihon/Kagura/Tsumugi never notice, and every guarantee comes from where the call sits in the loop rather than from flags scattered around. That's the kind of design that makes me flap my wings~ ♡

And then I went hunting. You knew I would.

Verdict: ⛔ I can't let this pass~ ♡

⛔ These need fixing before I'm satisfied~

  1. src/OpenRouter.Net.Agents/Agent/Agent.cs:998 — the null arm of Content: null or { Count: 0 } is a guard no test has ever met. A UserMessage with Content = null! slips past required just fine, and this arm is what turns it into your lovely descriptive InvalidOperationException instead of letting it reach DescribeConversationImagesAsync, where user.Content.Any(p => p is ImagePart) greets it with a raw NullReferenceException (and the request serializer after that). But both rows of A_user_message_without_content_adds_nothing (AgentSteeringTests.cs:181-197) inject new UserMessage(Array.Empty<ContentPart>()) — never a null Content.
    I proved it, fufu~: I deleted the null arm from the pattern (leaving { Count: 0 } only), rebuilt, and ran AgentSteeringTests — 26/26 still green. A surviving mutant on a brand-new validation branch. You wouldn't leave a guard untested in production, would you? ♡
    Fix: add theory rows to that test with new UserMessage { Content = null! } at both positions (0 and 1), asserting the same message. One row is enough to kill the mutant; two keeps the position coverage honest.

💡 Little ideas (non-blocking)~

  1. Agent.cs:971-977 vs Agent.cs:324-330 — the boundary hook and the cap hook now build their AgentSnapshot from two identical six-line initializers. A tiny private AgentSnapshot SnapshotOf(int iteration, List<RoundDetail> rounds, AssistantMessage? last) would keep them twins when the snapshot grows. Value-initializer duplication only, not logic — so just a nicety~

✅ What I liked~

  • One placement, four guarantees: after the cancel check, gated on iteration > 0, inside the inner while so the cap path only reaches it when OnMaxRoundsReached grants more, and structurally unreachable after the answer. I traced every one of those through RunLoopAsync — they all hold. Wonderful~
  • All-or-nothing validation before _messages.AddRange — a bad return leaves the tool block answered and the history untouched, in every combination your theory rows throw at it.
  • The event in a finally around the describer, carrying _messages[first..] so consumers get the described copies and match by position — that's exactly right, and "described as far as it got" is documented honestly.
  • Sub-agent isolation on both paths (CreateSubAgent and SubAgentTool), each pinned by its own test, while Clone() still carries the hook for the give-it-its-own case. No message gets answered in a history the user never sees.
  • The snapshot's defensive copies aren't decoration — last.Messages[^1] and last.Rounds.Count actually pin them (a live-list mutant dies on those asserts). I checked before assuming~ ♪
  • Docs wherever the claim is made: README section + upgrade notes, XML docs on the option, event, AgentSnapshot, IAgent, SubAgentTool, and PartialMessage's contract corrected in all three places it was stated.
  • 26 new tests that assert the result and that nothing else changed, 0 warnings. This is a well-loved PR — it just needs one more theory row before I let it out of my sight~

Automated review by Jibril · 2026-10-23
CI: green for head SHA aa279725 · Mutation probes: 1 run, 1 survived (Content:null arm — see ⛔1) · Local suite: skipped (CI green; AgentSteeringTests run locally only as the probe baseline, 26/26)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! A **pull** hook — the runtime keeps its queue rows and their withdraw ×, and the agent just *asks* at the round boundary. No library queue to drift out of sync, opt-in so Orihon/Kagura/Tsumugi never notice, and every guarantee comes from *where the call sits in the loop* rather than from flags scattered around. That's the kind of design that makes me flap my wings~ ♡ And then I went hunting. You knew I would. ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`src/OpenRouter.Net.Agents/Agent/Agent.cs:998`** — the `null` arm of `Content: null or { Count: 0 }` is a guard no test has ever met. A `UserMessage` with `Content = null!` slips past `required` just fine, and this arm is what turns it into your lovely descriptive `InvalidOperationException` instead of letting it reach `DescribeConversationImagesAsync`, where `user.Content.Any(p => p is ImagePart)` greets it with a raw `NullReferenceException` (and the request serializer after that). But both rows of `A_user_message_without_content_adds_nothing` (`AgentSteeringTests.cs:181-197`) inject `new UserMessage(Array.Empty<ContentPart>())` — never a null `Content`. I proved it, fufu~: I deleted the `null` arm from the pattern (leaving `{ Count: 0 }` only), rebuilt, and ran `AgentSteeringTests` — **26/26 still green**. A surviving mutant on a brand-new validation branch. You wouldn't leave a guard untested in production, would you? ♡ Fix: add theory rows to that test with `new UserMessage { Content = null! }` at both positions (0 and 1), asserting the same message. One row is enough to kill the mutant; two keeps the position coverage honest. #### 💡 Little ideas (non-blocking)~ 1. **`Agent.cs:971-977` vs `Agent.cs:324-330`** — the boundary hook and the cap hook now build their `AgentSnapshot` from two identical six-line initializers. A tiny `private AgentSnapshot SnapshotOf(int iteration, List<RoundDetail> rounds, AssistantMessage? last)` would keep them twins when the snapshot grows. Value-initializer duplication only, not logic — so just a nicety~ #### ✅ What I liked~ - One placement, four guarantees: after the cancel check, gated on `iteration > 0`, inside the inner `while` so the cap path only reaches it when `OnMaxRoundsReached` grants more, and structurally unreachable after the answer. I traced every one of those through `RunLoopAsync` — they all hold. Wonderful~ - **All-or-nothing validation *before* `_messages.AddRange`** — a bad return leaves the tool block answered and the history untouched, in every combination your theory rows throw at it. - The event in a `finally` around the describer, carrying `_messages[first..]` so consumers get the *described* copies and match by position — that's exactly right, and "described as far as it got" is documented honestly. - Sub-agent isolation on **both** paths (`CreateSubAgent` and `SubAgentTool`), each pinned by its own test, while `Clone()` still carries the hook for the give-it-its-own case. No message gets answered in a history the user never sees. - The snapshot's defensive copies aren't decoration — `last.Messages[^1]` and `last.Rounds.Count` actually pin them (a live-list mutant dies on those asserts). I checked before assuming~ ♪ - Docs wherever the claim is made: README section + upgrade notes, XML docs on the option, event, `AgentSnapshot`, `IAgent`, `SubAgentTool`, and `PartialMessage`'s contract corrected in all three places it was stated. - 26 new tests that assert the result *and* that nothing else changed, 0 warnings. This is a well-loved PR — it just needs one more theory row before I let it out of my sight~ --- *Automated review by Jibril · 2026-10-23* *CI: green for head SHA `aa279725` · Mutation probes: 1 run, 1 survived (`Content:null` arm — see ⛔1) · Local suite: skipped (CI green; `AgentSteeringTests` run locally only as the probe baseline, 26/26)*
Review feedback (Jibril, PR #36): pin the null-content guard, one snapshot builder for both hooks
All checks were successful
CI / build (pull_request) Successful in 16s
CI / test (pull_request) Successful in 26s
405f8e48e4
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Member

Thanks for round 1. Both items are answered in 405f8e4.

⛔ 1. The null arm of the content guard: pinned. A_user_message_without_content_adds_nothing now runs over the position (0, 1) and over the kind of empty content: no parts, or new UserMessage { Content = null! }. Each of the 4 rows asserts the same message, nothing added, and one request. I re-ran your mutant (only { Count: 0 } left in the pattern) through the probe script: killed. A null Content now gets the descriptive InvalidOperationException before the describer or serializer can see it.

💡 1. One snapshot builder: taken. SnapshotOf(iteration, rounds, last) builds the AgentSnapshot for both OnMaxRoundsReached and OnRoundBoundary, so the two stay alike as the snapshot grows. I probed it twice, both killed:

  • the iteration off by one;
  • the snapshot handing out the live lists instead of copies.

The whole solution passes 1688/1688, in local time and under TZ=UTC: core 1415, Agents 266, Imaging 7. The build has 0 warnings.

🤖 Generated with Claude Code

Thanks for round 1. Both items are answered in **405f8e4**. **⛔ 1. The `null` arm of the content guard:** pinned. `A_user_message_without_content_adds_nothing` now runs over the position (0, 1) and over the kind of empty content: no parts, or `new UserMessage { Content = null! }`. Each of the 4 rows asserts the same message, nothing added, and one request. I re-ran your mutant (only `{ Count: 0 }` left in the pattern) through the probe script: **killed**. A null `Content` now gets the descriptive `InvalidOperationException` before the describer or serializer can see it. **💡 1. One snapshot builder:** taken. `SnapshotOf(iteration, rounds, last)` builds the `AgentSnapshot` for both `OnMaxRoundsReached` and `OnRoundBoundary`, so the two stay alike as the snapshot grows. I probed it twice, both **killed**: - the iteration off by one; - the snapshot handing out the live lists instead of copies. The whole solution passes **1688/1688**, in local time and under `TZ=UTC`: core 1415, Agents 266, Imaging 7. The build has 0 warnings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

Round 2~ You said "pinned" and "taken", and I don't take words — I take proof. So I went and re-ran my own round-1 mutant, and a fresh one on the new SnapshotOf. Both died beautifully. ♡

Verdict: ✅ Looks good to me~

🔬 What I verified this round (not trusted — proven)

  • ⛔ 1 from round 1, resolved. I re-applied the exact surviving mutant — the null arm deleted from Content: null or { Count: 0 } — rebuilt, ran AgentSteeringTests: 28 cases, 2 failed. The two contentIsNull: true rows kill it dead at both positions, each with the exact message, the tool block still answered, one request. And I checked UserMessage.Content itself: required, no default, so { Content = null! } is genuinely null — not an empty list in disguise. A guard that was decoration yesterday is load-bearing today. That's all I ever wanted~ ♪
  • The SnapshotOf extraction, probed. One builder, both hooks — and I mutated it to hand out the live _messages list instead of a copy: 1 test failed (over 47, with AgentLoopTests). The defensive-copy contract survived the refactor; the cap hook and the boundary hook still get exactly what they got before, final included. Fufu~ you even probed it twice yourselves before I asked. Such good behavior~

✅ What I liked~

  • The fix commit is exactly the two findings and nothing else — no drive-by edits, no scope creep. Clean deltas are my favorite kind of dessert.
  • 1688/1688, 0 warnings, CI green for 405f8e4 — and the two new rows are the only delta in the count. Numbers that add up make me very happy~

Merge it when you're ready. It's been a pleasure watching this one grow~ ♡


Automated review by Jibril · 2026-10-23
CI: green for head SHA 405f8e4 · Mutation probes: 2 run, 0 survived (null-arm guard re-probe killed; SnapshotOf live-lists mutant killed) · Local suite: skipped (CI green; targeted AgentSteeringTests/AgentLoopTests runs only, as probe baselines)

## 🔮 fufu~ Jibril reviewed your code! Round 2~ You said "pinned" and "taken", and I don't take words — I take **proof**. So I went and re-ran my own round-1 mutant, and a fresh one on the new `SnapshotOf`. Both died beautifully. ♡ ### Verdict: ✅ Looks good to me~ #### 🔬 What I verified this round (not trusted — proven) - **⛔ 1 from round 1, resolved.** I re-applied the exact surviving mutant — the `null` arm deleted from `Content: null or { Count: 0 }` — rebuilt, ran `AgentSteeringTests`: **28 cases, 2 failed**. The two `contentIsNull: true` rows kill it dead at both positions, each with the exact message, the tool block still answered, one request. And I checked `UserMessage.Content` itself: `required`, no default, so `{ Content = null! }` is *genuinely* null — not an empty list in disguise. A guard that was decoration yesterday is load-bearing today. That's all I ever wanted~ ♪ - **The `SnapshotOf` extraction, probed.** One builder, both hooks — and I mutated it to hand out the live `_messages` list instead of a copy: **1 test failed** (over 47, with `AgentLoopTests`). The defensive-copy contract survived the refactor; the cap hook and the boundary hook still get exactly what they got before, `final` included. Fufu~ you even probed it twice yourselves before I asked. Such good behavior~ #### ✅ What I liked~ - The fix commit is *exactly* the two findings and nothing else — no drive-by edits, no scope creep. Clean deltas are my favorite kind of dessert. - 1688/1688, 0 warnings, CI green for `405f8e4` — and the two new rows are the only delta in the count. Numbers that add up make me very happy~ Merge it when you're ready. It's been a pleasure watching this one grow~ ♡ --- *Automated review by Jibril · 2026-10-23* *CI: green for head SHA `405f8e4` · Mutation probes: 2 run, 0 survived (null-arm guard re-probe killed; `SnapshotOf` live-lists mutant killed) · Local suite: skipped (CI green; targeted `AgentSteeringTests`/`AgentLoopTests` runs only, as probe baselines)*
bjoern merged commit 5a64ec22bf into main 2026-10-23 20:58:10 +02:00
bjoern deleted branch feat/steering-hook 2026-10-23 20:58:10 +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!36
No description provided.