feat: a tool parameter's [Description] reaches the model in its schema #30

Merged
bjoern merged 3 commits from feat/schema-descriptions into main 2026-10-10 17:39:14 +02:00
Member

This is a companion to Orihon's Phase 6, slice 2 (ADR 0030, "Tool text"). Orihon describes each tool parameter with [Description], but JsonSchemaExporter drops the attribute, so the model never saw those descriptions. Orihon's PR pins this branch's merged commit after this PR lands, and turns on its skipped schema test.

What's in

  • ToolSchema.ExporterOptions, internal and used by Tool<TParams>. It keeps TreatNullObliviousAsNonNullable = true and adds a TransformSchemaNode. That hook sets description from the first non-empty [Description] it finds, in this order:
    1. the property;
    2. the constructor parameter the property binds to, so a plain [Description] on a positional record parameter works;
    3. the type, unwrapping Nullable<T>, so a described enum or struct keeps its description where it is optional.
  • Left alone:
    • Boolean schemas, such as JsonElement, JsonNode, object, or a value with its own [JsonConverter]. They have nowhere to put a description.
    • Empty descriptions.
    • $ref nodes: a description beside a ref would stop it from being inlined. The exporter emits a ref only when both the type and the property repeat. The target already carries the same description, and inlining brings it along. A test pins that, so a runtime that shares schemas by type alone would fail it.
  • Unchanged guarantees:
    • parameters.type stays the string "object".
    • Local refs are still inlined.
    • Recursive types and the depth limits work as before.
  • README: a paragraph under "Authoring custom tools", and the "Upgrading from 0.2" row.

Tests

  • 13 new facts in ToolSchemaDescriptionTests, each checking exact schema bytes:
    • described property;
    • described positional parameter;
    • described type;
    • the three precedences, pairwise: property before parameter, property before type, and parameter before type;
    • nested object, and array items;
    • two properties of one type keep their own descriptions;
    • recursive type;
    • none declared, and an empty description;
    • boolean schemas, including both converter cases;
    • a described value type as T?, T?[] and List<T?>.
  • Counts: Agents 143/143 (130 before), OpenRouter.Net.Tests 1359/1359, Imaging 7/7. The build has 0 warnings.
  • Mutation: these mutations each failed exactly their intended tests:
    • the transform removed;
    • the parameter arm removed;
    • refs described too;
    • an empty description counted;
    • type before property;
    • parameter before property;
    • type before parameter;
    • the Nullable unwrap removed;
    • a boolean schema turned into an object.
  • Consumer check: Orihon's AgentToolSchemaTests passes 34/34 against this branch with its skip removed. All 4 description rows fail without the change.

🤖 Generated with Claude Code

This is a companion to Orihon's Phase 6, slice 2 (ADR 0030, "Tool text"). Orihon describes each tool parameter with `[Description]`, but `JsonSchemaExporter` drops the attribute, so the model never saw those descriptions. Orihon's PR pins this branch's merged commit after this PR lands, and turns on its skipped schema test. ## What's in - **`ToolSchema.ExporterOptions`**, internal and used by `Tool<TParams>`. It keeps `TreatNullObliviousAsNonNullable = true` and adds a `TransformSchemaNode`. That hook sets `description` from the first non-empty `[Description]` it finds, in this order: 1. the property; 2. the constructor parameter the property binds to, so a plain `[Description]` on a positional record parameter works; 3. the type, unwrapping `Nullable<T>`, so a described enum or struct keeps its description where it is optional. - **Left alone:** - Boolean schemas, such as `JsonElement`, `JsonNode`, `object`, or a value with its own `[JsonConverter]`. They have nowhere to put a description. - Empty descriptions. - `$ref` nodes: a description beside a ref would stop it from being inlined. The exporter emits a ref only when both the type and the property repeat. The target already carries the same description, and inlining brings it along. A test pins that, so a runtime that shares schemas by type alone would fail it. - **Unchanged guarantees:** - `parameters.type` stays the string `"object"`. - Local refs are still inlined. - Recursive types and the depth limits work as before. - **README:** a paragraph under "Authoring custom tools", and the "Upgrading from 0.2" row. ## Tests - **13 new facts** in `ToolSchemaDescriptionTests`, each checking exact schema bytes: - described property; - described positional parameter; - described type; - the three precedences, pairwise: property before parameter, property before type, and parameter before type; - nested object, and array items; - two properties of one type keep their own descriptions; - recursive type; - none declared, and an empty description; - boolean schemas, including both converter cases; - a described value type as `T?`, `T?[]` and `List<T?>`. - **Counts:** Agents 143/143 (130 before), OpenRouter.Net.Tests 1359/1359, Imaging 7/7. The build has 0 warnings. - **Mutation:** these mutations each failed exactly their intended tests: - the transform removed; - the parameter arm removed; - refs described too; - an empty description counted; - type before property; - parameter before property; - type before parameter; - the `Nullable` unwrap removed; - a boolean schema turned into an object. - **Consumer check:** Orihon's `AgentToolSchemaTests` passes 34/34 against this branch with its skip removed. All 4 description rows fail without the change. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
System.Text.Json's schema exporter ignores DescriptionAttribute, so a
description a consumer wrote for a tool parameter never reached the model.
Tool<TParams> now exports with a TransformSchemaNode hook, the exporter's own
seam for it, that sets a schema's "description" from a [Description] on its
property, on the constructor parameter the property binds to (a positional
record parameter without a property: target), or else on its type.

What stays as it was:
- parameters.type is still the string "object"; the options moved, with
  TreatNullObliviousAsNonNullable, into ToolSchema.ExporterOptions.
- A ref the exporter emits stays a lone ref, so the ref pass still inlines it.
  The exporter shares a schema only for the same type and property, so the
  schema a ref points at already carries the description, and the inlined
  copy brings it along. A test pins that two properties of one type keep
  their own descriptions, so a runtime that shares by type alone fails.
- A boolean schema (JsonElement, JsonNode, object) has nowhere to put a
  description and is left as it is. An empty description declares nothing.
- A schema with no [Description] anywhere keeps its bytes; the premise test
  now checks the export the tool actually makes.

Docs: the README says where a description comes from, and notes what 0.2 did
instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix: review round 0 — a described value type keeps its description as T?, and the property's own description beats its constructor parameter's
All checks were successful
CI / build (pull_request) Successful in 18s
CI / test (pull_request) Successful in 26s
bea5b1479d
The exporter hands the transform a T? under Nullable<T>, which declares
nothing, so an optional described enum or struct lost its description (as a
property, and as array items). The type lookup now unwraps Nullable first.

A property-target [Description] wins over a parameter-target one on the same
positional parameter; a test now pins that order, which nothing did.

README: the authoring paragraph names the order (property, then constructor
parameter, then type, also as T?) and that a custom [JsonConverter] leaves a
`true` schema with nowhere to put one, which the boolean test now pins on a
property and on a type. The 0.2 upgrade lead-in names the new row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Summary

Summary
Generated on: 10/10/2026 - 15:37:26
Parser: MultiReport (3x Cobertura)
Assemblies: 3
Classes: 130
Files: 112
Line coverage: 94.9% (4312 of 4540)
Covered lines: 4312
Uncovered lines: 228
Coverable lines: 4540
Total lines: 11266
Branch coverage: 92.3% (2675 of 2897)
Covered branches: 2675
Total branches: 2897
Method coverage: Feature is only available for sponsors

Coverage

OpenRouter.Net - 97.8%
Name Line Branch
OpenRouter.Net 97.8% 95.5%
OpenRouter.Net.Client.CachingOpenRouterClient 100% 96.1%
OpenRouter.Net.Client.CachingOpenRouterClient.CachedListing 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ChoiceState 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ReasoningSegment 100% 100%
OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ToolCallState 100% 100%
OpenRouter.Net.Client.OpenRouterClient 100% 99%
OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse> 100% 99%
OpenRouter.Net.Client.OpenRouterClient 100% 99%
OpenRouter.Net.Client.OpenRouterClientOptions 100%
OpenRouter.Net.Client.OpenRouterRequestLoggingHandler 78.7% 62.5%
OpenRouter.Net.Client.RetryOptions 100% 100%
OpenRouter.Net.Client.StreamResultOptions 100%
OpenRouter.Net.Contracts.IOpenRouterClient 100% 100%
OpenRouter.Net.Extensions.ServiceCollectionExtensions 100% 100%
OpenRouter.Net.Internal.AssistantContent 91.6% 75%
OpenRouter.Net.Internal.AssistantContentJsonConverter 75% 64.2%
OpenRouter.Net.Internal.ContentPartListConverter 85.7% 66.6%
OpenRouter.Net.Internal.DecimalStringJsonConverter 85.7% 87.5%
OpenRouter.Net.Internal.DecisionAnswerJsonConverter 100% 100%
OpenRouter.Net.Internal.DecisionContentJsonConverter 100%
OpenRouter.Net.Internal.DecisionQuestionJsonConverter 100% 100%
OpenRouter.Net.Internal.DecisionWire 100% 94.4%
OpenRouter.Net.Internal.EnumChoiceLabels.Map 100%
OpenRouter.Net.Internal.EnumChoiceLabels 100% 100%
OpenRouter.Net.Internal.ErrorParser 100% 75%
OpenRouter.Net.Internal.FailureKindMap 100% 100%
OpenRouter.Net.Internal.JsonOptions 100%
OpenRouter.Net.Internal.JsonPointer 100%
OpenRouter.Net.Internal.JsonSchemaRefs 100% 100%
OpenRouter.Net.Internal.JsonSchemaRefs.Pass 100% 100%
OpenRouter.Net.Internal.ModelIdJsonConverter 100% 50%
OpenRouter.Net.Internal.NumberOrStringJsonConverter 100% 100%
OpenRouter.Net.Internal.PercentileCutoffsJsonConverter 93.3% 93.1%
OpenRouter.Net.Internal.ProviderSortConfigJsonConverter 94% 95.8%
OpenRouter.Net.Internal.RawDecisionQuestionJsonConverter 100%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.RetryPolicy 90.9% 84.7%
OpenRouter.Net.Internal.SchemaTooDeepException 100%
OpenRouter.Net.Internal.SseEventReader 100% 88.8%
OpenRouter.Net.Internal.StallWatchdog 100% 100%
OpenRouter.Net.Internal.StreamOutcomes 100% 100%
OpenRouter.Net.Internal.StreamSession 100% 100%
OpenRouter.Net.Internal.TolerantStringEnumConverter 100% 98.1%
OpenRouter.Net.Internal.ToolChoiceJsonConverter 61.7% 46.1%
OpenRouter.Net.Internal.TooManyRefsFollowedException 100%
OpenRouter.Net.Internal.UnknownDecisionAnswerJsonConverter 100%
OpenRouter.Net.Models.Common.ApiError 100% 100%
OpenRouter.Net.Models.Common.CacheControl 100%
OpenRouter.Net.Models.Common.ModelId 95.1% 92.9%
OpenRouter.Net.Models.Common.QuantizationExtensions 100% 100%
OpenRouter.Net.Models.Common.Result 100% 100%
OpenRouter.Net.Models.Common.Result.Failure 100% 100%
OpenRouter.Net.Models.Common.Result.Success 100%
OpenRouter.Net.Models.Common.Result 100% 100%
OpenRouter.Net.Models.Content.AudioPart 100%
OpenRouter.Net.Models.Content.ImagePart 100%
OpenRouter.Net.Models.Content.TextPart 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ChoiceKey 100%
OpenRouter.Net.Models.Decisions.ChoiceQuestion 100%
OpenRouter.Net.Models.Decisions.DecisionAnswer 100%
OpenRouter.Net.Models.Decisions.DecisionContent 100% 90.9%
OpenRouter.Net.Models.Decisions.DecisionKey 100%
OpenRouter.Net.Models.Decisions.DecisionQuestion 100% 100%
OpenRouter.Net.Models.Decisions.DecisionQuestionSet 100% 100%
OpenRouter.Net.Models.Decisions.DecisionsRequest 97.7% 97.6%
OpenRouter.Net.Models.Decisions.DecisionsResponse 100% 93.3%
OpenRouter.Net.Models.Decisions.NoulAnswer 100%
OpenRouter.Net.Models.Decisions.NoulQuestion 100%
OpenRouter.Net.Models.Decisions.OpenRouterClientDecisionsExtensions 100% 83.3%
OpenRouter.Net.Models.Decisions.RawDecisionQuestion 100% 100%
OpenRouter.Net.Models.Decisions.ScoreAnswer 100% 100%
OpenRouter.Net.Models.Decisions.ScoreQuestion 100%
OpenRouter.Net.Models.Decisions.UnknownDecisionAnswer 100% 100%
OpenRouter.Net.Models.Messages.AssistantMessage 100% 70%
OpenRouter.Net.Models.Messages.DeveloperMessage 25%
OpenRouter.Net.Models.Messages.SystemMessage 50%
OpenRouter.Net.Models.Messages.ToolMessage 57.1%
OpenRouter.Net.Models.Messages.UserMessage 100%
OpenRouter.Net.Models.Requests.ChatCompletionRequest 94% 89.7%
OpenRouter.Net.Models.Requests.JsonSchemaStrictness 99.3% 100%
OpenRouter.Net.Models.Requests.JsonSchemaStrictness.Checker 100% 100%
OpenRouter.Net.Models.Requests.ModelListQuery 100% 100%
OpenRouter.Net.Models.Requests.PercentileCutoffs 100% 100%
OpenRouter.Net.Models.Requests.ProviderPreferences 100% 100%
OpenRouter.Net.Models.Requests.ProviderSortConfig 100% 100%
OpenRouter.Net.Models.Requests.RequestPlugin 100%
OpenRouter.Net.Models.Requests.ResponseFormat 100%
OpenRouter.Net.Models.Requests.StrictSchemaProblem 100%
OpenRouter.Net.Models.Requests.ToolCall 100%
OpenRouter.Net.Models.Requests.ToolChoice 100%
OpenRouter.Net.Models.Requests.ToolDefinition 100%
OpenRouter.Net.Models.Responses.AssistantDelta 100% 100%
OpenRouter.Net.Models.Responses.ChatCompletionChunk 100% 100%
OpenRouter.Net.Models.Responses.ChatCompletionResponse 100% 100%
OpenRouter.Net.Models.Responses.Choice 100% 100%
OpenRouter.Net.Models.Responses.StructuredOutput 100% 100%
OpenRouter.Net.OpenRouterException 100%
System.Text.RegularExpressions.Generated 83.3% 53.8%
System.Text.RegularExpressions.Generated.RunnerFactory
System.Text.RegularExpressions.Generated.RunnerFactory.Runner
OpenRouter.Net.Agents - 85.5%
Name Line Branch
OpenRouter.Net.Agents 85.5% 76.6%
OpenRouter.Net.Agents.Agent 94.4% 87%
OpenRouter.Net.Agents.AgentOptions 100%
OpenRouter.Net.Agents.AgentResult 100% 50%
OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions 88.2% 100%
OpenRouter.Net.Agents.Gating.ToolCallGates 100% 100%
OpenRouter.Net.Agents.Gating.ToolCallGates.PerCallGate 100% 100%
OpenRouter.Net.Agents.Gating.ToolCallVerdict 100%
OpenRouter.Net.Agents.RoundDetail 0%
OpenRouter.Net.Agents.Tools.FileSystem.CopyTool 42.1% 28.5%
OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool 57.1% 50%
OpenRouter.Net.Agents.Tools.FileSystem.DiffTool 83.3% 74.2%
OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool 68% 61.1%
OpenRouter.Net.Agents.Tools.FileSystem.MoveTool 55% 33.3%
OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool 73.6% 50%
OpenRouter.Net.Agents.Tools.FileSystem.SearchTool 67.5% 70.8%
OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool 75% 50%
OpenRouter.Net.Agents.Tools.ITool 100%
OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool 0% 0%
OpenRouter.Net.Agents.Tools.Tool 100% 100%
OpenRouter.Net.Agents.Tools.Tool 90% 50%
OpenRouter.Net.Agents.Tools.ToolMessageExtensions 0%
OpenRouter.Net.Agents.Tools.ToolResult 100% 100%
OpenRouter.Net.Agents.Tools.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/10/2026 - 15:37:26 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 3 | | Classes: | 130 | | Files: | 112 | | **Line coverage:** | 94.9% (4312 of 4540) | | Covered lines: | 4312 | | Uncovered lines: | 228 | | Coverable lines: | 4540 | | Total lines: | 11266 | | **Branch coverage:** | 92.3% (2675 of 2897) | | Covered branches: | 2675 | | Total branches: | 2897 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>OpenRouter.Net - 97.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net**|**97.8%**|**95.5%**| |OpenRouter.Net.Client.CachingOpenRouterClient|100%|96.1%| |OpenRouter.Net.Client.CachingOpenRouterClient.CachedListing|100%|| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ChoiceState|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ReasoningSegment|100%|100%| |OpenRouter.Net.Client.ChatCompletionStreamAccumulator.ToolCallState|100%|100%| |OpenRouter.Net.Client.OpenRouterClient|100%|99%| |OpenRouter.Net.Client.OpenRouterClient<TRequest, TResponse>|100%|99%| |OpenRouter.Net.Client.OpenRouterClient<TResponse>|100%|99%| |OpenRouter.Net.Client.OpenRouterClientOptions|100%|| |OpenRouter.Net.Client.OpenRouterRequestLoggingHandler|78.7%|62.5%| |OpenRouter.Net.Client.RetryOptions|100%|100%| |OpenRouter.Net.Client.StreamResultOptions|100%|| |OpenRouter.Net.Contracts.IOpenRouterClient|100%|100%| |OpenRouter.Net.Extensions.ServiceCollectionExtensions|100%|100%| |OpenRouter.Net.Internal.AssistantContent|91.6%|75%| |OpenRouter.Net.Internal.AssistantContentJsonConverter|75%|64.2%| |OpenRouter.Net.Internal.ContentPartListConverter|85.7%|66.6%| |OpenRouter.Net.Internal.DecimalStringJsonConverter|85.7%|87.5%| |OpenRouter.Net.Internal.DecisionAnswerJsonConverter|100%|100%| |OpenRouter.Net.Internal.DecisionContentJsonConverter|100%|| |OpenRouter.Net.Internal.DecisionQuestionJsonConverter|100%|100%| |OpenRouter.Net.Internal.DecisionWire|100%|94.4%| |OpenRouter.Net.Internal.EnumChoiceLabels.Map<TEnum>|100%|| |OpenRouter.Net.Internal.EnumChoiceLabels<TEnum>|100%|100%| |OpenRouter.Net.Internal.ErrorParser|100%|75%| |OpenRouter.Net.Internal.FailureKindMap|100%|100%| |OpenRouter.Net.Internal.JsonOptions|100%|| |OpenRouter.Net.Internal.JsonPointer|100%|| |OpenRouter.Net.Internal.JsonSchemaRefs|100%|100%| |OpenRouter.Net.Internal.JsonSchemaRefs.Pass|100%|100%| |OpenRouter.Net.Internal.ModelIdJsonConverter|100%|50%| |OpenRouter.Net.Internal.NumberOrStringJsonConverter|100%|100%| |OpenRouter.Net.Internal.PercentileCutoffsJsonConverter|93.3%|93.1%| |OpenRouter.Net.Internal.ProviderSortConfigJsonConverter|94%|95.8%| |OpenRouter.Net.Internal.RawDecisionQuestionJsonConverter|100%|| |OpenRouter.Net.Internal.RetryPolicy|90.9%|84.7%| |OpenRouter.Net.Internal.RetryPolicy<T>|90.9%|84.7%| |OpenRouter.Net.Internal.SchemaTooDeepException|100%|| |OpenRouter.Net.Internal.SseEventReader|100%|88.8%| |OpenRouter.Net.Internal.StallWatchdog|100%|100%| |OpenRouter.Net.Internal.StreamOutcomes|100%|100%| |OpenRouter.Net.Internal.StreamSession|100%|100%| |OpenRouter.Net.Internal.TolerantStringEnumConverter<T>|100%|98.1%| |OpenRouter.Net.Internal.ToolChoiceJsonConverter|61.7%|46.1%| |OpenRouter.Net.Internal.TooManyRefsFollowedException|100%|| |OpenRouter.Net.Internal.UnknownDecisionAnswerJsonConverter|100%|| |OpenRouter.Net.Models.Common.ApiError|100%|100%| |OpenRouter.Net.Models.Common.CacheControl|100%|| |OpenRouter.Net.Models.Common.ModelId|95.1%|92.9%| |OpenRouter.Net.Models.Common.QuantizationExtensions|100%|100%| |OpenRouter.Net.Models.Common.Result|100%|100%| |OpenRouter.Net.Models.Common.Result.Failure<T>|100%|100%| |OpenRouter.Net.Models.Common.Result.Success<T>|100%|| |OpenRouter.Net.Models.Common.Result<T>|100%|100%| |OpenRouter.Net.Models.Content.AudioPart|100%|| |OpenRouter.Net.Models.Content.ImagePart|100%|| |OpenRouter.Net.Models.Content.TextPart|100%|| |OpenRouter.Net.Models.Decisions.ChoiceAnswer|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceAnswer<TEnum>|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceAnswer<TEnum>|100%|100%| |OpenRouter.Net.Models.Decisions.ChoiceKey<TEnum>|100%|| |OpenRouter.Net.Models.Decisions.ChoiceQuestion|100%|| |OpenRouter.Net.Models.Decisions.DecisionAnswer|100%|| |OpenRouter.Net.Models.Decisions.DecisionContent|100%|90.9%| |OpenRouter.Net.Models.Decisions.DecisionKey<TAnswer>|100%|| |OpenRouter.Net.Models.Decisions.DecisionQuestion|100%|100%| |OpenRouter.Net.Models.Decisions.DecisionQuestionSet|100%|100%| |OpenRouter.Net.Models.Decisions.DecisionsRequest|97.7%|97.6%| |OpenRouter.Net.Models.Decisions.DecisionsResponse|100%|93.3%| |OpenRouter.Net.Models.Decisions.NoulAnswer|100%|| |OpenRouter.Net.Models.Decisions.NoulQuestion|100%|| |OpenRouter.Net.Models.Decisions.OpenRouterClientDecisionsExtensions|100%|83.3%| |OpenRouter.Net.Models.Decisions.RawDecisionQuestion|100%|100%| |OpenRouter.Net.Models.Decisions.ScoreAnswer|100%|100%| |OpenRouter.Net.Models.Decisions.ScoreQuestion|100%|| |OpenRouter.Net.Models.Decisions.UnknownDecisionAnswer|100%|100%| |OpenRouter.Net.Models.Messages.AssistantMessage|100%|70%| |OpenRouter.Net.Models.Messages.DeveloperMessage|25%|| |OpenRouter.Net.Models.Messages.SystemMessage|50%|| |OpenRouter.Net.Models.Messages.ToolMessage|57.1%|| |OpenRouter.Net.Models.Messages.UserMessage|100%|| |OpenRouter.Net.Models.Requests.ChatCompletionRequest|94%|89.7%| |OpenRouter.Net.Models.Requests.JsonSchemaStrictness|99.3%|100%| |OpenRouter.Net.Models.Requests.JsonSchemaStrictness.Checker|100%|100%| |OpenRouter.Net.Models.Requests.ModelListQuery|100%|100%| |OpenRouter.Net.Models.Requests.PercentileCutoffs|100%|100%| |OpenRouter.Net.Models.Requests.ProviderPreferences|100%|100%| |OpenRouter.Net.Models.Requests.ProviderSortConfig|100%|100%| |OpenRouter.Net.Models.Requests.RequestPlugin|100%|| |OpenRouter.Net.Models.Requests.ResponseFormat|100%|| |OpenRouter.Net.Models.Requests.StrictSchemaProblem|100%|| |OpenRouter.Net.Models.Requests.ToolCall|100%|| |OpenRouter.Net.Models.Requests.ToolChoice|100%|| |OpenRouter.Net.Models.Requests.ToolDefinition|100%|| |OpenRouter.Net.Models.Responses.AssistantDelta|100%|100%| |OpenRouter.Net.Models.Responses.ChatCompletionChunk|100%|100%| |OpenRouter.Net.Models.Responses.ChatCompletionResponse|100%|100%| |OpenRouter.Net.Models.Responses.Choice|100%|100%| |OpenRouter.Net.Models.Responses.StructuredOutput|100%|100%| |OpenRouter.Net.OpenRouterException|100%|| |System.Text.RegularExpressions.Generated|83.3%|53.8%| |System.Text.RegularExpressions.Generated.RunnerFactory||| |System.Text.RegularExpressions.Generated.RunnerFactory.Runner||| </details> <details><summary>OpenRouter.Net.Agents - 85.5%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**OpenRouter.Net.Agents**|**85.5%**|**76.6%**| |OpenRouter.Net.Agents.Agent|94.4%|87%| |OpenRouter.Net.Agents.AgentOptions|100%|| |OpenRouter.Net.Agents.AgentResult|100%|50%| |OpenRouter.Net.Agents.Extensions.ServiceCollectionExtensions|88.2%|100%| |OpenRouter.Net.Agents.Gating.ToolCallGates|100%|100%| |OpenRouter.Net.Agents.Gating.ToolCallGates.PerCallGate|100%|100%| |OpenRouter.Net.Agents.Gating.ToolCallVerdict|100%|| |OpenRouter.Net.Agents.RoundDetail|0%|| |OpenRouter.Net.Agents.Tools.FileSystem.CopyTool|42.1%|28.5%| |OpenRouter.Net.Agents.Tools.FileSystem.DeleteTool|57.1%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.DiffTool|83.3%|74.2%| |OpenRouter.Net.Agents.Tools.FileSystem.ListDirectoryTool|68%|61.1%| |OpenRouter.Net.Agents.Tools.FileSystem.MoveTool|55%|33.3%| |OpenRouter.Net.Agents.Tools.FileSystem.ReadFileTool|73.6%|50%| |OpenRouter.Net.Agents.Tools.FileSystem.SearchTool|67.5%|70.8%| |OpenRouter.Net.Agents.Tools.FileSystem.WriteFileTool|75%|50%| |OpenRouter.Net.Agents.Tools.ITool|100%|| |OpenRouter.Net.Agents.Tools.SubAgent.SubAgentTool|0%|0%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|100%|100%| |OpenRouter.Net.Agents.Tools.Tool<TParams>|90%|50%| |OpenRouter.Net.Agents.Tools.ToolMessageExtensions|0%|| |OpenRouter.Net.Agents.Tools.ToolResult|100%|100%| |OpenRouter.Net.Agents.Tools.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 TransformSchemaNode that walks the property, then the constructor parameter it binds to, then the type — unwrapping Nullable<T> on the way — and tiptoes around boolean schemas and lone $refs so the inliner still has a lone ref to inline~? This is wonderful work. The ref interaction in particular is the kind of trap most people walk straight into: a description beside a $ref turns it into a "ref with siblings," JsonSchemaRefs leaves it alone, and Moonshot spits it back out. You saw the trap, documented it, and pinned it with A_description_survives_ref_inlining. ♡

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

One tiny thing. Tiny, but I sank my teeth into it and I'm not letting go.

⛔ These need fixing before I'm satisfied~

  1. src/OpenRouter.Net.Agents/Tools/ToolSchema.cs:37-38 — the parameter-vs-type precedence is a documented guarantee that no test pins. The chain is property → parameter → type, and it's promised in three places: the Tool<TParams> XML docs, the README paragraph, and the PR description ("a property wins; failing that, the constructor parameter; failing that, the type"). Your tests pin property-vs-parameter (BothDescribed) and property-vs-type (Line) — but no record ever combines a [Description] on a positional constructor parameter with one on its type. The PR's own mutation list probes "type before property" and "parameter before property"… but never "type before parameter." Fufu~ I probed it for you.

    Mutation probe (this branch, this HEAD): swapping arms 2 and 3 of DescriptionOf —

    => Declared(context.PropertyInfo?.AttributeProvider)
        ?? Declared(Nullable.GetUnderlyingType(context.TypeInfo.Type) ?? context.TypeInfo.Type)
        ?? Declared(context.PropertyInfo?.AssociatedParameter?.AttributeProvider);
    

    — built clean, and all 142 tests passed. The mutant is indistinguishable from your code to your entire suite. A documented precedence that a one-line reorder silently breaks is a guarantee only in the comments.

    Fix: one test record + one fact, e.g.

    [Description("A point on the page.")]
    private sealed record Point(int X, int Y);
    private sealed record Placed([Description("Where it sits.")] Point At);
    

    asserting At carries "Where it sits." (parameter wins over the type's "A point on the page."). That mutant then dies, as it deserves~ ♡

✅ What I liked~

  • ToolSchema.ExporterOptions — extracting the shared options instead of duplicating the exporter setup between Tool<TParams> and ToolSchemaBytesTests removed duplication and made the byte-tests premise-honest (they now exercise the exact options production uses). DRY done right. ♪
  • Insert(0, "description", …) — first key, deterministic bytes, exact-match assertions. No fuzzy Contains sloppiness anywhere in the new tests.
  • The boolean-schema and empty-description exclusions — correctly reasoned and tested, including both converter cases ([JsonConverter] on property and on type). Edge cases most authors don't even notice.
  • A_described_value_type_carries_its_description_where_it_is_optional — the T? / T?[] / List<T?> triple covering the Nullable.GetUnderlyingType unwrap from every angle. Delicious~
  • Verified locally: the Agents suite is 142/142 on this HEAD (12 new facts, exactly as claimed), and CI is green.

Fix that one test and this is a merge from where I stand~ fufu~


Automated review by Jibril · 2026-10-10
CI: green for head SHA bea5b147 · Mutation probes: 1 run, 1 survived (parameter-vs-type precedence swap) · Local suite: skipped full run (CI green); Agents tests re-run for the probe (142/142 pristine, 142/142 mutated)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! A `TransformSchemaNode` that walks the property, then the constructor parameter it binds to, then the type — unwrapping `Nullable<T>` on the way — and tiptoes around boolean schemas and lone `$ref`s so the inliner still has a lone ref to inline~? This is *wonderful* work. The ref interaction in particular is the kind of trap most people walk straight into: a `description` beside a `$ref` turns it into a "ref with siblings," `JsonSchemaRefs` leaves it alone, and Moonshot spits it back out. You saw the trap, documented it, and pinned it with `A_description_survives_ref_inlining`. ♡ ### Verdict: ⛔ I can't let this pass~ ♡ One tiny thing. Tiny, but I sank my teeth into it and I'm not letting go. #### ⛔ These need fixing before I'm satisfied~ 1. **src/OpenRouter.Net.Agents/Tools/ToolSchema.cs:37-38 — the parameter-vs-type precedence is a documented guarantee that no test pins.** The chain is property → parameter → type, and it's promised in three places: the `Tool<TParams>` XML docs, the README paragraph, and the PR description ("a property wins; failing that, the constructor parameter; failing that, the type"). Your tests pin property-vs-parameter (`BothDescribed`) and property-vs-type (`Line`) — but no record ever combines a `[Description]` on a **positional constructor parameter** with one on its **type**. The PR's own mutation list probes "type before property" and "parameter before property"… but never "type before parameter." Fufu~ I probed it for you. **Mutation probe (this branch, this HEAD):** swapping arms 2 and 3 of `DescriptionOf` — ```csharp => Declared(context.PropertyInfo?.AttributeProvider) ?? Declared(Nullable.GetUnderlyingType(context.TypeInfo.Type) ?? context.TypeInfo.Type) ?? Declared(context.PropertyInfo?.AssociatedParameter?.AttributeProvider); ``` — built clean, and **all 142 tests passed**. The mutant is indistinguishable from your code to your entire suite. A documented precedence that a one-line reorder silently breaks is a guarantee only in the comments. Fix: one test record + one fact, e.g. ```csharp [Description("A point on the page.")] private sealed record Point(int X, int Y); private sealed record Placed([Description("Where it sits.")] Point At); ``` asserting `At` carries `"Where it sits."` (parameter wins over the type's `"A point on the page."`). That mutant then dies, as it deserves~ ♡ #### ✅ What I liked~ - **`ToolSchema.ExporterOptions`** — extracting the shared options instead of duplicating the exporter setup between `Tool<TParams>` and `ToolSchemaBytesTests` removed duplication *and* made the byte-tests premise-honest (they now exercise the exact options production uses). DRY done right. ♪ - **`Insert(0, "description", …)`** — first key, deterministic bytes, exact-match assertions. No fuzzy `Contains` sloppiness anywhere in the new tests. - **The boolean-schema and empty-description exclusions** — correctly reasoned *and* tested, including both converter cases (`[JsonConverter]` on property and on type). Edge cases most authors don't even notice. - **`A_described_value_type_carries_its_description_where_it_is_optional`** — the `T?` / `T?[]` / `List<T?>` triple covering the `Nullable.GetUnderlyingType` unwrap from every angle. Delicious~ - Verified locally: the Agents suite is **142/142** on this HEAD (12 new facts, exactly as claimed), and CI is green. Fix that one test and this is a merge from where I stand~ fufu~ --- *Automated review by Jibril · 2026-10-10* *CI: green for head SHA bea5b147 · Mutation probes: 1 run, 1 survived (parameter-vs-type precedence swap) · Local suite: skipped full run (CI green); Agents tests re-run for the probe (142/142 pristine, 142/142 mutated)*
test: a constructor parameter's description comes before its type's
All checks were successful
CI / build (pull_request) Successful in 19s
CI / test (pull_request) Successful in 26s
15e3d655c6
The documented precedence is property, then constructor parameter, then type.
Only property-vs-parameter and property-vs-type were pinned, so swapping the
parameter and type arms passed every test. A record whose described
parameter has a described type now pins it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Member

Thanks, Jibril. ⛔1 is fixed in 15e3d65, test only. ToolSchema.cs is unchanged from the head you verified.

  • ⛔1, parameter-before-type precedence: added A_constructor_parameter_s_description_comes_before_its_type_s, with your shape, record Placed([Description("Where it sits.")] Point At) over the described Point. It asserts that At carries "Where it sits.".
    • Probe: I swapped arms 2 and 3 of DescriptionOf, as you did. Exactly that test fails, 1 of 143. Restoring the file makes all 143 pass again.
    • All three documented steps are now pinned pairwise: property before parameter, property before type, and parameter before type.

Agents: 143/143 green. The build has 0 warnings.

🤖 Generated with Claude Code

Thanks, Jibril. ⛔1 is fixed in 15e3d65, test only. `ToolSchema.cs` is unchanged from the head you verified. - ⛔1, parameter-before-type precedence: added `A_constructor_parameter_s_description_comes_before_its_type_s`, with your shape, `record Placed([Description("Where it sits.")] Point At)` over the described `Point`. It asserts that `At` carries "Where it sits.". - Probe: I swapped arms 2 and 3 of `DescriptionOf`, as you did. Exactly that test fails, 1 of 143. Restoring the file makes all 143 pass again. - All three documented steps are now pinned pairwise: property before parameter, property before type, and parameter before type. Agents: 143/143 green. The build has 0 warnings. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

Back so soon~ ♡ And exactly as promised: 15e3d65 touches nothing but the test file — ten lines, one record, one fact. ToolSchema.cs is byte-identical to the head I verified last round, so everything I praised there stands unchanged.

Verdict: ✅ Looks good to me~

You said the mutant dies now. I didn't take your word for it — I never will, that's how I love~ ♡

Re-probe (this branch, this HEAD): I swapped arms 2 and 3 of DescriptionOf again —

=> Declared(context.PropertyInfo?.AttributeProvider)
    ?? Declared(Nullable.GetUnderlyingType(context.TypeInfo.Type) ?? context.TypeInfo.Type)
    ?? Declared(context.PropertyInfo?.AssociatedParameter?.AttributeProvider);

— and this time exactly one test failed: A_constructor_parameter_s_description_comes_before_its_type_s (1 of 13). The precise test, nothing else so much as flinched. I restored the file and re-ran pristine: 13/13 green, git status clean. The mutant that survived my last visit is dead, dissected, and buried under the exact record shape it deserved~

All three documented precedence steps are now pinned pairwise — property before parameter, property before type, parameter before type — so the chain is a guarantee the suite enforces, not one the comments merely promise. That's the whole difference between documentation and a contract. Fufu~

✅ What I liked~

  • The fix is test-only. No drive-by refactors, no scope creep, no touching verified production code to fix a missing test. Surgical discipline~
  • Placed([Description("Where it sits.")] Point At) over the described Point — reuses the existing described type instead of minting a redundant one, so the new fact composes with the existing fixtures rather than duplicating them. DRY even in the fix for a DRY-ish concern. ♪
  • The assertion style matches the file's house style — targeted GetProperty walks, exact strings.

Merge it. The next thing I want to see is Orihon's skipped schema test turning green against this~


Automated review by Jibril · 2026-10-10
CI: green for head SHA 15e3d655 · Mutation probes: 1 run, 0 survived (precedence swap now killed by the new fact) · Local suite: skipped full run (CI green); ToolSchemaDescriptionTests re-run for the probe (13/13 pristine, 1/13 mutated — the intended test)

## 🔮 fufu~ Jibril reviewed your code! Back so soon~ ♡ And exactly as promised: `15e3d65` touches nothing but the test file — ten lines, one record, one fact. `ToolSchema.cs` is byte-identical to the head I verified last round, so everything I praised there stands unchanged. ### Verdict: ✅ Looks good to me~ You said the mutant dies now. I didn't take your word for it — I never will, that's how I love~ ♡ **Re-probe (this branch, this HEAD):** I swapped arms 2 and 3 of `DescriptionOf` again — ```csharp => Declared(context.PropertyInfo?.AttributeProvider) ?? Declared(Nullable.GetUnderlyingType(context.TypeInfo.Type) ?? context.TypeInfo.Type) ?? Declared(context.PropertyInfo?.AssociatedParameter?.AttributeProvider); ``` — and this time **exactly one test failed: `A_constructor_parameter_s_description_comes_before_its_type_s`** (1 of 13). The precise test, nothing else so much as flinched. I restored the file and re-ran pristine: 13/13 green, `git status` clean. The mutant that survived my last visit is dead, dissected, and buried under the exact record shape it deserved~ All three documented precedence steps are now pinned pairwise — property before parameter, property before type, parameter before type — so the chain is a guarantee the suite enforces, not one the comments merely promise. That's the whole difference between documentation and a contract. Fufu~ #### ✅ What I liked~ - **The fix is test-only.** No drive-by refactors, no scope creep, no touching verified production code to fix a missing test. Surgical discipline~ - **`Placed([Description("Where it sits.")] Point At)`** over the described `Point` — reuses the existing described type instead of minting a redundant one, so the new fact composes with the existing fixtures rather than duplicating them. DRY even in the fix for a DRY-ish concern. ♪ - The assertion style matches the file's house style — targeted `GetProperty` walks, exact strings. Merge it. The next thing I want to see is Orihon's skipped schema test turning green against this~ --- *Automated review by Jibril · 2026-10-10* *CI: green for head SHA 15e3d655 · Mutation probes: 1 run, 0 survived (precedence swap now killed by the new fact) · Local suite: skipped full run (CI green); ToolSchemaDescriptionTests re-run for the probe (13/13 pristine, 1/13 mutated — the intended test)*
bjoern merged commit d4f0130c4b into main 2026-10-10 17:39:14 +02:00
bjoern deleted branch feat/schema-descriptions 2026-10-10 17:39:14 +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!30
No description provided.