Penghou.Nuwa.Extensions.AI
1.0.0
dotnet add package Penghou.Nuwa.Extensions.AI --version 1.0.0
NuGet\Install-Package Penghou.Nuwa.Extensions.AI -Version 1.0.0
<PackageReference Include="Penghou.Nuwa.Extensions.AI" Version="1.0.0" />
<PackageVersion Include="Penghou.Nuwa.Extensions.AI" Version="1.0.0" />
<PackageReference Include="Penghou.Nuwa.Extensions.AI" />
paket add Penghou.Nuwa.Extensions.AI --version 1.0.0
#r "nuget: Penghou.Nuwa.Extensions.AI, 1.0.0"
#:package Penghou.Nuwa.Extensions.AI@1.0.0
#addin nuget:?package=Penghou.Nuwa.Extensions.AI&version=1.0.0
#tool nuget:?package=Penghou.Nuwa.Extensions.AI&version=1.0.0
Penghou.Nuwa
Penghou.Nuwa is a schema-aware JSON repair and structured-output recovery
pipeline for .NET, designed around malformed output observed from real
language models. It does more than fix text that fails to parse: it recovers
valid-but-wrong-shaped JSON, double-serialized fields, schema mismatches,
optional-null rejections, Markdown fences, truncated structures, and malformed
tool arguments into a System.Text.Json document — instead of failing.
That includes the classic failure modes — unescaped quotes inside file contents, truncated tool-call arguments, Markdown fences, template literals, single quotes, Python literals, and missing brackets — as well as valid JSON that a strict consumer rejects.
Install
dotnet add package Penghou.Nuwa
or pin the version explicitly:
<PackageReference Include="Penghou.Nuwa" Version="1.0.0" />
Targets net8.0, net9.0, and net10.0. For Microsoft.Extensions.AI
pipeline integration (OpenAI, Anthropic, Azure OpenAI, Ollama, Semantic
Kernel), also install Penghou.Nuwa.Extensions.AI — see the section below.
Quick start
The one-shot helper builds a default pipeline for each call — fine for occasional use:
using Penghou.Nuwa;
using var result = await JsonRepair.RepairAsync(
"""{"files":[{"path":"Program.cs","content": using System; var message = "hello"; }]}""");
if (result.Succeeded)
{
var root = result.GetRootOrThrow();
Console.WriteLine(result.RepairedText);
Console.WriteLine(result.WasRepaired); // true
}
For repeated calls, build a pipeline once and reuse it:
var pipeline = JsonRepairPipeline.Create();
// Or with configuration:
var pipeline = JsonRepairPipeline.Create(options =>
{
options.RemoveTextRepair<PseudoCSharpVerbatimStringRepairStrategy>();
options.DisableSalvageFallback();
options.Limits = new JsonRepairLimits
{
MaxInputLength = 1_000_000,
MaxOutputLength = 2_000_000,
MaxDepth = 128,
MaxCorrections = 10_000
};
});
Dependency injection
using Microsoft.Extensions.DependencyInjection;
using Penghou.Nuwa;
using Penghou.Nuwa.Extensions;
var services = new ServiceCollection();
services.AddLogging();
services.AddJsonRepair(); // or AddJsonRepair(options => ...)
using var provider = services.BuildServiceProvider();
var pipeline = provider.GetRequiredService<IJsonRepairPipeline>();
using var result = await pipeline.RepairAsync(input);
if (result.Succeeded)
{
Console.WriteLine(result.GetRepairedTextOrThrow());
}
Schema-guided repair
Pass the JSON Schema of the expected shape (for example the tool-arguments schema) to enable schema-aware recovery and the node-repair phase:
var expectation = JsonSchemaExpectation.FromSchemaJson(schemaJson);
using var result = await pipeline.RepairAsync(input, expectation);
if (result.IsRepairAccepted)
{
var files = result.GetRootOrThrow()["files"];
}
else if (result.Succeeded)
{
// Syntax recovery succeeded, but the expected structural shape did not.
Console.WriteLine(string.Join(Environment.NewLine, result.ShapeErrors));
}
No JSON Schema handy? Derive the expectation directly from the CLR type the payload should deserialize into — property kinds (string/number/boolean/ object/array) and required flags are read via reflection, so repair gets the same shape guidance with zero schema plumbing:
var expectation = JsonSchemaExpectation.FromType<FilePatchArguments>();
// Match how the payload is actually serialized (e.g. camelCase):
var camelCase = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var expectation = JsonSchemaExpectation.FromType<FilePatchArguments>(camelCase);
FromType honors [JsonPropertyName], [JsonIgnore], and [JsonRequired]
attributes, maps enums to strings, and recurses into nested objects, arrays,
and dictionaries. Schema-guided repair is best-effort when no expectation is
passed at all — text repair and tolerant recovery still run.
Schema handling is repair-only: FromSchemaJson/FromSchemaNode extract
the shape facts the recovery pipeline needs (types, properties, required,
items, nullability) and normalize common constructs — local $ref/$defs
pointers are inlined, oneOf/anyOf/allOf/enum are reduced to a
canonical type form, and recursive references are cut so repair terminates.
This is intentionally not a JSON Schema dialect converter or validator for
model-facing output; do not feed a JsonSchemaExpectation.Schema back to an
LLM or use it as the authoritative contract for a remote API.
Succeeded means Nuwa recovered syntactically valid JSON.
IsRepairAccepted additionally requires the result to match the types and
required structural shape Nuwa uses during recovery. It does not mean a
host successfully deserialized a CLR type, mapped a tool call, or passed domain
validation. Baize and the consuming application must report those later gates
separately. Use a dedicated JSON Schema validator when authoritative dialect
validation is required.
Text repair candidates are speculative until parsing accepts their lineage. Nuwa preserves the original input as an independent tolerant/salvage path, so a lossy extraction cannot hide a better recovery. Multiple concatenated top-level objects are refused as ambiguous rather than silently selecting the first. Node repairs run as a bounded speculative chain: an intermediate step may temporarily increase shape errors, but the chain commits atomically only when a selected candidate is no worse than the original. Otherwise every node change is rolled back.
The default node chain can therefore repair double-encoded arrays whose item types also need deterministic coercion:
using var result = await pipeline.RepairAsync(
"""{"files":"[1, 2]"}""",
JsonSchemaExpectation.FromSchemaJson(
"""{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"type":"string"}}}}"""));
// {"files":["1","2"]}
// expansion and scalar-to-string both receive Succeeded only after the
// complete chain reaches the expected shape.
Only JSON numbers and booleans are coerced to strings, using their
deterministic token spelling. Nulls, objects, arrays, and existing strings are
never stringified. This establishes Nuwa-level structural compatibility; it
does not establish that values such as "1" are meaningful filenames.
New in 1.0: stable contract, clean pipeline
1.0 graduates the 0.6 feature set with no behavior changes:
- The public API is locked by Roslyn public API analyzer baselines
(
PublicAPI.Shipped.txtin each library project); accidental additions, removals, or signature changes fail the build. - Build requirements met:
net8.0/net9.0/net10.0multi-targeting, package validation, deterministic builds, and a vulnerability-free restore (SourceLink 10.0.401). - Trim- and Native AOT-compatible (
IsAotCompatible), with a dedicated Native AOT smoke run in CI. The repair path itself is reflection-free; the convenience APIs that activate strategies or derive schemas by type are annotated[RequiresUnreferencedCode]. See below.
Native AOT and trimming
Nuwa performs no runtime reflection or code generation while repairing. The
convenience APIs that discover strategies or schemas by type —
JsonRepairPipeline.Create, AddJsonRepair, and
JsonSchemaExpectation.FromType — are annotated
[RequiresUnreferencedCode]/[RequiresDynamicCode] because they activate
types at runtime. For ahead-of-time compiled apps, build the pipeline from
strategy instances and supply the schema explicitly:
using Microsoft.Extensions.Logging.Abstractions;
using Penghou.Nuwa;
using Penghou.Nuwa.Strategies;
var pipeline = new JsonRepairPipeline(
textRepairs:
[
new MarkdownJsonFenceRepairStrategy(),
new UnicodeDelimiterNormalizationStrategy(),
new PseudoCSharpVerbatimStringRepairStrategy(),
new PseudoJavaScriptTemplateStringRepairStrategy(),
],
salvageRepairs: [new SalvageRepairStrategy()],
nodeRepairs:
[
new SchemaGuidedOptionalNullRemovalStrategy(),
new SchemaGuidedJsonStringExpansionStrategy(),
new SchemaGuidedScalarToStringCoercionStrategy(),
],
logger: NullLogger<JsonRepairPipeline>.Instance,
limits: JsonRepairLimits.Default,
allowTruncationSalvage: true);
var expectation = JsonSchemaExpectation.FromSchemaJson(schemaJson);
using var result = await pipeline.RepairAsync(input, expectation);
The same applies to the Penghou.Nuwa.Extensions.AI middleware: pass an
existing pipeline via UseJsonRepair(client, pipeline, options) rather than
the configure-callback overloads. System.Text.Json.Nodes-based repair
never needs reflection-based serialization, and tool-call argument
round-tripping in the middleware uses JsonNode directly.
New in 0.6: truncation salvage, payload extraction, coercions, confidence, streaming
Truncation-aware partial salvage
Generations cut off mid-payload no longer fail outright. Every complete property or element is kept and the torn tail is dropped (recorded as a correction):
using var result = await pipeline.RepairAsync("""{"count": 42, "note": """);
// Succeeded == true; Root == {"count": 42}
// result.TolerantRecovery.Corrections notes the dropped property.
Disable with options.AllowTruncationSalvage = false when partial payloads
are worse than failures.
Payload extraction
Models routinely wrap JSON in prose, XML elements, or emit several objects back to back. Default text strategies now handle these before parsing:
"Here is the JSON: {...}"— prose prefix/suffix stripping (prose-wrapper-extraction)<answer>{...}</answer>and CDATA bodies (xml-wrapped-extraction){"first": 1}{"second": 2}— concatenated values keep the first object (concatenated-json)- Fences tagged with arbitrary payload labels (
```tool_call) are unwrapped; programming-language fences (csharp,python, ...) remain untouched.
Unicode delimiters are normalized too: curly quotes used as string bounds,
full-width CJK brackets/colons/commas, BOM and zero-width characters
(unicode-delimiter-normalization).
Schema-guided coercions (opt-in)
When the wire schema is authoritative, typed coercion can rescue common near-misses:
var pipeline = JsonRepairPipeline.Create(options =>
options.EnableSchemaCoercions());
// {"count": "42"} -> {"count": 42} string-to-number
// {"flag": "True"} -> {"flag": true} string-to-boolean
// {"tags": "red"} -> {"tags": ["red"]} item-compatible array wrap
// {"status": "Actve"} -> {"status": "Active"} enum fuzzy match (distance <= 2)
// extra properties -> removed when additionalProperties:false
Off by default; repairs stay structurally conservative unless you opt in.
Scalar-to-array wrapping validates the proposed element against the complete
items schema first. A deterministic string-to-number or string-to-boolean
conversion may be applied atomically; incompatible values remain unchanged.
Missing required properties can also be reconciled from a uniquely matching unknown property name:
var pipeline = JsonRepairPipeline.Create(options =>
options.EnableRequiredPropertyReconciliation());
// {"qurey":"weather"} -> {"query":"weather"}
This policy is opt-in and conservative: the target must be required and missing, the source must be unknown, the name match must be uniquely best, and the value must satisfy the target schema directly or through a certified lossless coercion. Existing properties are never overwritten, and ambiguous union branches are left unchanged.
For contracts with distinctive nested shapes or enum values, a broader policy can reconcile unrelated property names:
var pipeline = JsonRepairPipeline.Create(options =>
options.EnableStructuralPropertyReconciliation());
This remains separately opt-in. It requires exactly one compatible missing required target, refuses primitive type compatibility by itself, and applies the mapping only when supported schema-shape errors decrease. Repair reports include privacy-safe evidence such as the path, reason, uniqueness decision, and before/after error counts; property values are not included.
Repair confidence
Every result carries a deterministic heuristic 0–1 score derived from its own diagnostics — unchanged valid JSON scores 1.0, each mutation reduces it, and lossy salvage or a shape mismatch reduce it sharply:
if (result.IsConfident(0.8))
{
// gate downstream execution like any constrained extractor would
}
JsonRepairNotification.Confidence surfaces the same value to the
Microsoft.Extensions.AI middleware audit.
Streaming repair
Repair a payload as chunks arrive. Stable-prefix deltas give consumers a live preview; one completed event carries the authoritative result:
await foreach (var streamEvent in pipeline.RepairStreamAsync(modelChunks))
{
if (streamEvent is JsonRepairStreamDelta delta)
RenderPreview(delta.Offset, delta.Text); // verbatim input slice
else if (streamEvent is JsonRepairStreamCompleted completed)
Use(completed.Result); // full repair outcome
}
Deltas never appear inside open strings and keep a holdback margin from the tail so pending punctuation repairs cannot invalidate them — treat them as preview only.
Microsoft.Extensions.AI integration
The companion package Penghou.Nuwa.Extensions.AI drops Nuwa repair into
any Microsoft.Extensions.AI chat-client pipeline — OpenAI, Anthropic, Azure
OpenAI, Ollama, Semantic Kernel, and anything else that exposes an
IChatClient. It is a small middleware that repairs JSON after the connector
has done its work, so you get the fixes without forking provider SDKs.
<PackageReference Include="Penghou.Nuwa.Extensions.AI" Version="1.0.0" />
Two things get repaired, transparently:
- Tool-call arguments. The OpenAI/Anthropic connector already parses
arguments eagerly. When the model emits valid-but-wrong-shaped JSON (a
field double-serialized as a string, an optional
nulla strict schema rejects, a wrong property kind), the middleware re-serializes the parsed arguments and runs the schema-guided node-repair phase against the matching tool's schema, then swaps in the corrected arguments before your tool invocation code sees them. When a provider preserves the raw arguments text on the call content, that text is repaired too. - Structured-output text. Assistant
TextContentis repaired whenChatOptions.ResponseFormatrequests JSON, so truncated or fenced JSON is recovered before it reaches your deserializer. When the response format has a schema, that schema guides repair. JSON-looking ordinary chat is unchanged unlessRepairJsonLookingTextWithoutResponseFormatis enabled.
Quick start
Wrap any IChatClient. The UseJsonRepair extension is available both on
IChatClient and on the pipeline builder:
using Microsoft.Extensions.AI;
using OpenAI;
using Penghou.Nuwa.Extensions.AI;
OpenAIClient openAi = new(apiKey);
IChatClient inner = openAi.AsIChatClient("gpt-4o");
// Wrap directly...
IChatClient client = inner.UseJsonRepair();
// ...or as a pipeline stage, so repair runs on its way through:
IChatClient pipeline = new ChatClientBuilder(inner)
.UseFunctionInvocation()
.UseJsonRepair()
.Build();
ChatResponse response = await pipeline.GetResponseAsync(
"List the files in src/ and print them as JSON.",
new ChatOptions { Tools = [tool], ResponseFormat = new ChatResponseFormatJson() });
With Anthropic, wrap the same way:
AnthropicClient anthropic = new(apiKey);
IChatClient client = anthropic.AsIChatClient("claude-3-5-sonnet-latest").UseJsonRepair();
Tool schemas are read from ChatOptions.Tools automatically — the middleware
matches a function call to the AIFunctionDeclaration with the same name and
uses its JsonSchema. You can override that with
JsonRepairChatClientOptions.FunctionCallExpectationResolver, or point repair
at a CLR type with JsonSchemaExpectation.FromType<T>():
IChatClient client = inner.UseJsonRepair(
new JsonRepairChatClientOptions
{
FunctionCallExpectationResolver = call =>
JsonSchemaExpectation.FromType<FilePatchArguments>()
});
Configure the underlying Nuwa pipeline just like the core package:
IChatClient client = inner.UseJsonRepair(options =>
{
options.RemoveTextRepair<PseudoCSharpVerbatimStringRepairStrategy>();
options.DisableSalvageFallback();
});
Notes
- Streaming responses repair completed tool-call arguments (the accumulated update a connector emits at the end of a call). Streaming text is not repaired because fragments split JSON mid-token; use the non-streaming path for structured-output text.
- The middleware only rewrites JSON that is actually repaired
(
JsonRepairResult.WasRepaired) — already-valid arguments and prose text pass through untouched. - Set
JsonRepairChatClientOptions.RepairCompletedto surface the compact repair audit to telemetry or a UI. Payload text is not included. - Genuinely malformed tool arguments are recoverable only when the provider keeps the raw text on the call content; OpenAI's connector does not, so for that specific case the existing parse-failure behavior is preserved.
How it works
Repair runs through up to four stages:
- Text-repair strategies — targeted rewrites of malformed text that
cannot be a tree yet: Markdown JSON fences (including arbitrary payload
tags), prose wrappers, XML/CDATA elements, concatenated values, Unicode
delimiter normalization, C# verbatim strings (
@"..."), and JavaScript template literals (`...`). Stops as soon as the text parses. - Tolerant syntax-tree recovery — a handwritten parser that builds a
JsonNodewhile using container state, bounded lookahead, and the schema at the current path to recover punctuation (missing commas, closers, quotes, unquoted keys) without inventing semantic values. Truncated generations are salvaged by keeping every completed property/element. - Self-contained text salvage — a lossy fallback that runs only when recovery fails: strips comments, normalizes Python literals, converts single-quoted strings, quotes unquoted keys, and completes unclosed containers. No external JSON-repair dependency.
- Schema-guided node strategies — fixes that survive as valid but
wrong-shaped JSON: expanding a field that arrived as a JSON string back
into an array or object, removing optional
nullvalues that a strict schema rejects, and — when coercions are enabled — array wrapping, string-to-number/boolean conversion, enum fuzzy matching, and unknown-property pruning for strict contracts.
Every result carries a per-strategy audit. Each configured strategy is reported exactly once, in order, with its status and an optional note:
var original = result.OriginalText; // the exact input you passed in
var repaired = result.RepairedText; // best-effort output (valid JSON when Succeeded)
foreach (var report in result.TextRepairs)
{
Console.WriteLine(
$"{report.Name}: {report.Status}" +
(report.Note is null ? "" : $" ({report.Note})"));
}
var winner = result.SucceededBy; // strategy in an accepted Nuwa repair lineage, if any
var recovery = result.TolerantRecovery; // token-level corrections, when tolerant parsing ran
Resource management and cancellation
JsonRepairResult wraps a JsonDocument and is IDisposable — dispose it to
free the underlying buffer. Root and RepairedText are independent of the
document and stay valid after disposal:
using var result = await pipeline.RepairAsync(input);
// result.Root / result.RepairedText remain usable after the using block.
Repair is cooperative and accepts a CancellationToken. For a hard timeout,
cancel with a linked token:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var result = await pipeline.RepairAsync(
input,
expectation,
cts.Token);
Customization
Configure the strategy lists with JsonRepairOptions, through either
AddJsonRepair or JsonRepairPipeline.Create:
services.AddJsonRepair(options =>
{
// Insert a custom strategy after a default one.
options.InsertTextRepairAfter<MarkdownJsonFenceRepairStrategy, MyFenceStrategy>();
// Turn off the lossy fallback phase entirely.
options.DisableSalvageFallback();
// Replace the node strategies.
options.ClearNodeRepairs();
options.AddNodeRepair<MyNodeStrategy>();
});
Implement the ITextRepair / INodeRepair contracts. Return
RepairOutcome.NotApplicable to decline, Repaired with the repaired
text/tree to apply, or Failed. Note carries optional diagnostic detail:
public sealed class MyFenceStrategy : ITextRepair
{
public string Name => "my-fence";
public ValueTask<TextRepairAttempt> RepairAsync(
string input,
CancellationToken cancellationToken = default)
{
if (!input.StartsWith("[BEGIN]"))
{
return new(new TextRepairAttempt(
RepairOutcome.NotApplicable,
Repaired: null));
}
return new(new TextRepairAttempt(
RepairOutcome.Repaired,
input.Replace("[BEGIN]", "").Replace("[END]", "")));
}
}
Strategies can depend on injected services (including ILogger<T>); register
them by type through JsonRepairOptions and they are resolved from the
container.
Node strategies run against the parsed tree and use the expectation to detect
valid but wrong-shaped JSON. Return the replacement node with
RepairOutcome.Repaired:
public sealed class MyNodeStrategy : INodeRepair
{
public string Name => "my-node";
public ValueTask<NodeRepairAttempt> RepairAsync(
JsonNode node,
JsonSchemaExpectation expectation,
CancellationToken cancellationToken = default)
{
if (node["files"] is JsonValue value &&
value.TryGetValue<string>(out var text))
{
var array = JsonNode.Parse(text) as JsonArray;
if (array is not null)
{
node["files"] = array;
return new(new NodeRepairAttempt(
RepairOutcome.Repaired,
node));
}
}
return new(new NodeRepairAttempt(
RepairOutcome.NotApplicable,
null));
}
}
Strategies may be registered by type, instance, or factory. Instance and factory registration avoids reflection and supports dependencies captured by the host while preserving explicit strategy order:
var pipeline = JsonRepairPipeline.Create(options =>
{
options.AddNodeRepair(new MyNodeStrategy());
});
// A factory is useful when construction captures host-owned dependencies:
var otherPipeline = JsonRepairPipeline.Create(options =>
options.AddNodeRepair(() => new MyNodeStrategy()));
The existing AddTextRepair<T>(), AddSalvageRepair<T>(), and
AddNodeRepair<T>() APIs remain available. Constructor-based creation is now
the compatibility fallback rather than the only standalone construction path.
Roadmap
See the roadmap for shipped foundations, upcoming usability work, architecture improvements, and later public API design.
Feedback and attribution
Penghou.Nuwa began as the repair pipeline of the Solo autonomous code generation project. Its recovery parser is schema-aware and empirically tuned against real model output.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.AI (>= 10.8.3)
- Penghou.Nuwa (>= 1.0.0)
-
net8.0
- Microsoft.Extensions.AI (>= 10.8.3)
- Penghou.Nuwa (>= 1.0.0)
-
net9.0
- Microsoft.Extensions.AI (>= 10.8.3)
- Penghou.Nuwa (>= 1.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.