Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,21 @@ jobs:
- name: Build .NET solution
run: dotnet build --no-restore --configuration Release

- name: Publish and run .NET Native AOT smoke test
run: |
dotnet publish tests/AgentHostProtocol.AotSmoke/AgentHostProtocol.AotSmoke.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained true \
--output artifacts/aot-smoke
./artifacts/aot-smoke/AgentHostProtocol.AotSmoke

- name: Test .NET solution
run: dotnet test --no-build --configuration Release
run: |
timeout --kill-after=30s 6m \
dotnet tests/AgentHostProtocol.Tests/bin/Release/net8.0/AgentHostProtocol.Tests.dll \
--timeout 5m \
--long-running 30

- name: Pack .NET solution
run: dotnet pack --no-build --configuration Release
1 change: 1 addition & 0 deletions clients/dotnet/AgentHostProtocol.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<Project Path="src/AgentHostProtocol/AgentHostProtocol.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/AgentHostProtocol.AotSmoke/AgentHostProtocol.AotSmoke.csproj" />
<Project Path="tests/AgentHostProtocol.Tests/AgentHostProtocol.Tests.csproj" />
</Folder>
</Solution>
43 changes: 39 additions & 4 deletions clients/dotnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ dotnet add package Microsoft.AgentHostProtocol
| `Microsoft.AgentHostProtocol.Abstractions` | Wire types + reducers' data contracts + the `ITransport` / `IAhpSerializer` interfaces. No I/O, no dependencies. Reference this alone to parse / construct AHP messages or implement a transport. |
| `Microsoft.AgentHostProtocol` | The async `AhpClient`, pure reducers, default System.Text.Json serializer, `ClientWebSocket` transport, and `MultiHostClient`. |

(`Microsoft.AgentHostProtocol` references `.Abstractions` transitively, so most
consumers add the two packages above.)
`Microsoft.AgentHostProtocol` references `.Abstractions` transitively, so most
consumers only add the main package.

## Quickstart

Expand Down Expand Up @@ -63,17 +63,29 @@ services.AddAgentHostProtocol(cfg => cfg.DefaultRequestTimeout = TimeSpan.FromSe

That registers `IAhpSerializer`, `IClientIdStore`, `MultiHostClient`, and an
`IAhpClientFactory` as singletons. Because a client needs a live transport,
resolve the factory and call `ConnectAsync(transport)`:
resolve the factory and call `Connect(transport)`:

```csharp
var factory = provider.GetRequiredService<IAhpClientFactory>();
await using var client = await factory.ConnectAsync(transport);
await using var client = factory.Connect(transport);
```

The `MultiHostClient` singleton is disposed by the container on shutdown. The
`configureClient` options apply to the factory path; `MultiHostClient` hosts are
configured per host via `HostConfig.ClientConfig`.

`ClientConfig.TimeProvider` controls request timeouts, keep-alive scheduling,
reconnect backoff, and host timestamps. It defaults to `TimeProvider.System`;
tests can supply a fake provider to advance these behaviors without wall-clock
delays.

`MultiHostClient.EventsForHost` and `MultiHostClient.Subscriptions` preserve
resource continuity across reconnects. A replay is delivered as
`SubscriptionEventAction` values; when the server must replace replay with fresh
state, each returned resource is delivered as a `SubscriptionEventSnapshot`
before the host reports `Connected`. Consumers should replace that resource's
local reducer state with the snapshot before processing later actions.

## Observability

The client emits OpenTelemetry-native traces and metrics via `System.Diagnostics`
Expand Down Expand Up @@ -119,6 +131,29 @@ implementation can swap the engine or decorate it with JSON-Schema validation
(against the schemas the repository generates under `schema/`) without changing
the client or transport.

### Native AOT and trimming

The `net8.0` assets are trim- and Native AOT-compatible. The generator emits
System.Text.Json metadata for the complete generated protocol model. The
default serializer uses that metadata for JSON-RPC framing, discriminated
unions, snapshots, and wire enums without enabling reflection serialization.
`AhpJsonMetadata.Default` exposes the resolver without making the generated
context's hundreds of implementation-detail properties public API.

Custom values passed through `IAhpSerializer` need their own metadata when
reflection is disabled. Add the application's context to the options before
constructing the serializer; the serializer copies and freezes the options and
adds the AHP context:

```csharp
var options = new JsonSerializerOptions();
options.TypeInfoResolverChain.Add(MyApplicationJsonContext.Default);
var serializer = new SystemTextJsonAhpSerializer(options);
```

CI publishes and runs `tests/AgentHostProtocol.AotSmoke` as a native executable
with `JsonSerializerIsReflectionEnabledByDefault=false`.

## Releasing

1. Bump [`VERSION`](VERSION).
Expand Down
55 changes: 27 additions & 28 deletions clients/dotnet/docs/decisions/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ the default is System.Text.Json and what the seam does and does not decouple.

| Option | Throughput | Allocations | Eager/Lazy | Deps | AOT | Notes |
| --- | --- | --- | --- | --- | --- | --- |
| **System.Text.Json (POCO)** ✅ | Highest | Lowest (`Span<T>`/UTF-8) | Eager | **In-box** (net8) | Source-gen capable | Strict by default; Microsoft's greenfield recommendation. |
| System.Text.Json + source generation | Highest (+startup, +AOT) | Lowest | Eager | In-box | **Best** | An AOT/trimming enhancement for later — but **not** a drop-in `[JsonSerializable]` context: see "Deferred, on purpose" — the runtime-`Type`-keyed union converters do not compose with the source generator, so it requires reshaping the generated unions, not just adding a context. |
| **System.Text.Json + source generation** ✅ | Highest (+startup, +AOT) | Lowest | Eager | **In-box** (net8) | **Best** | Generated metadata covers the complete wire model; custom converters resolve metadata before runtime-type dispatch. |
| System.Text.Json (reflection contracts) | Highest | Lowest (`Span<T>`/UTF-8) | Eager | In-box | Poor | Simpler initially, but unsuitable for trimming and Native AOT. |
| Newtonsoft.Json (Json.NET) | ~20–35% slower; ~3× more allocations on .NET 10 | High (reflection, no `Span`) | Eager or `JObject` (lazy, mutable) | **+dependency** | Reflection (AOT-hostile) | Lenient by default; ubiquitous, but a dependency and slower. |
| Lazy DOM — `JsonNode` / `JsonElement` (STJ) or `JObject` (Newtonsoft) | n/a (no bind) | Low for partial reads | **Lazy** | In-box (STJ) | ok | A *different consumption model*: expose untyped views instead of typed state. Reducers can't run on it without materializing. |
| Utf8Json / Jil / other high-perf | Very high | Very low | Eager | +dependency | varies | Effectively unmaintained; not worth the dependency/risk for a JSON wire protocol. |
Expand Down Expand Up @@ -72,14 +72,14 @@ Rationale, against the dimensions:
- **Dependencies:** STJ is **in the shared framework** for net8 — the
packages stay at **zero NuGet dependencies**, which is a hard goal for this
library.
- **AOT / trimming:** STJ supports source generation as a path to
Native-AOT/trimming friendliness later. Note this is **not** a free,
drop-in step for this client: the discriminated unions dispatch on a
runtime `Type`, which the source generator does not support, so the
migration is a typed-variant rewrite of the generated unions (see
"Deferred, on purpose"). Until then the shipping libraries declare the
reflection unsafety via `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]`
on the serializer seam so trimmed/AOT consumers are warned at build time.
- **AOT / trimming:** the C# generator emits metadata for every generated
protocol type and exposes it through the compact
`AhpJsonMetadata.Default` resolver API. Runtime-type union dispatch remains
compact, but resolves each known variant through
`JsonSerializerOptions.GetTypeInfo` and uses metadata-based serializer
overloads. The `net8.0` assets declare `IsTrimmable` and
`IsAotCompatible`; CI publishes and executes a reflection-disabled Native
AOT smoke application.
- **Strictness:** strict-by-default is correct for a wire protocol — a
malformed or unexpected frame should fail loudly, not be silently coerced.
- **Custom shapes:** the protocol's discriminated unions, `StringOrMarkdown`,
Expand All @@ -101,7 +101,7 @@ Rationale, against the dimensions:
mean re-emitting the types for that engine — tractable since they're
generated, but STJ stays the one default.

### Deferred, on purpose
### Boundaries and deferred work

- **Validation ("validated vs not"):** a future opt-in
`Microsoft.AgentHostProtocol.Validation` package will decorate
Expand All @@ -112,29 +112,28 @@ Rationale, against the dimensions:
without materializing typed state (a proxy/pass-through), that is a separate
read-only `JsonNode`/`JsonElement` surface — not a drop-in serializer swap,
because the reducers require typed state.
- **Source generation:** add source-gen for AOT/trimming and a further perf
bump when there is a concrete AOT consumer. This is **not** merely "add a
`[JsonSerializable]` `JsonSerializerContext`." The union machinery is
fundamentally reflection-polymorphic: `UnionConverter<T>.Read` resolves the
payload type at runtime from a `Dictionary<string, Type>` and calls
`root.Deserialize(variantType, options)`, and `Write` serializes via
`inner.GetType()`. The STJ source generator only emits metadata for
compile-time-known closed types and does **not** support custom converters
that dispatch on a runtime `Type`. A real source-gen migration therefore
requires reshaping every discriminated union away from the `object?`-valued
`AhpUnion` + runtime-`Type` dispatch toward a closed, statically-known variant
representation (e.g. STJ's `[JsonPolymorphic]`/`[JsonDerivedType]`, or
per-variant typed properties) — a redesign of the generated wire types plus
the codegen, not a drop-in. In the meantime the libraries opt into the
trim/AOT analyzers and annotate the reflection entry points with
`[RequiresUnreferencedCode]`/`[RequiresDynamicCode]` so the limitation is
declared at build time rather than discovered at runtime.
- **Application-defined JSON types:** the AHP context intentionally contains
only protocol types. Consumers that pass their own types through
`IAhpSerializer` must add an `IJsonTypeInfoResolver` (normally their own
source-generated context) to the options supplied to
`SystemTextJsonAhpSerializer` when reflection serialization is disabled.
Reflection-enabled applications retain the generic serializer methods'
compatibility fallback for application-defined values.
- **Generated union shape:** `AhpUnion.Value` still provides a compact common
representation and supports lossless unknown-value round trips. The
converter's runtime `Type` lookup is AOT-safe because every known variant has
generated metadata, but the wrapper can still represent an empty or
caller-supplied invalid state. Hardening that public shape would be a separate
compatibility decision.

## Consequences

- The default path is fast, allocation-light, and dependency-free.
- Native AOT and trimmed applications use the default serializer without
reflection contracts.
- A different engine or a validating layer can be added behind `IAhpSerializer`
without touching the client or transport.
- Custom application types require explicitly registered JSON metadata.
- Consumers who want JSON-Schema validation opt into a separate package; the
core never pays for it.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,10 @@
I/O and no dependencies beyond the base class library; reference it to
parse, construct, or inspect AHP messages, or to implement a transport.
</Description>
<!-- Trim/AOT analyzer posture. The union/enum converters are reflection-
based (root.Deserialize(runtimeType), GetFields over [WireValue]), so this
package is NOT trim/AOT-safe and does not CLAIM to be — no IsTrimmable /
IsAotCompatible (those would mislead consumers into trimming/AOT-publishing
against it). These flags only turn the analyzers ON so those entry points
surface IL2026/IL3050 at build time and are declared unsafe via
[RequiresUnreferencedCode]/[RequiresDynamicCode] (source-gen is deferred
per docs/decisions/serialization.md). -->
<!-- The net8 asset is validated under the trimming and Native AOT analyzers.
Generated System.Text.Json metadata covers every generated wire type. -->
<IsTrimmable Condition="'$(TargetFramework)' == 'net8.0'">true</IsTrimmable>
<IsAotCompatible Condition="'$(TargetFramework)' == 'net8.0'">true</IsAotCompatible>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
</PropertyGroup>
Expand Down
Loading