diff --git a/docs/sdk/dotnet/abstractions/events.mdx b/docs/sdk/dotnet/abstractions/events.mdx index 4c476fd0fd..2deae8da18 100644 --- a/docs/sdk/dotnet/abstractions/events.mdx +++ b/docs/sdk/dotnet/abstractions/events.mdx @@ -61,6 +61,11 @@ public abstract class BaseEvent | `Timestamp` | `timestamp` | `long?` | Optional event timestamp | | `RawEvent` | `rawEvent` | `JsonElement?` | Optional original event data | +On the server, `AsAGUIEventStreamAsync` fills `RawEvent` in with the originating +`ChatResponseUpdate` by default. Turn it off with +[`AGUIStreamOptions.IncludeRawEvents`](/sdk/dotnet/hosting/extensibility#includerawevents) +when no client reads the field. + ## Lifecycle Events Lifecycle events represent the run and step lifecycle. diff --git a/docs/sdk/dotnet/hosting/extensibility.mdx b/docs/sdk/dotnet/hosting/extensibility.mdx index 84ea276f1b..c94776a908 100644 --- a/docs/sdk/dotnet/hosting/extensibility.mdx +++ b/docs/sdk/dotnet/hosting/extensibility.mdx @@ -6,11 +6,13 @@ description: "Customize AG-UI stream conversion with AGUIStreamOptions" # Extensibility `AGUIStreamOptions` customizes how `ChatResponseUpdate` streams are converted -to AG-UI events. It is fluent and method-only: create an instance, register -`Map*` hooks, and pass it to `ToChatRequestContext`. +to AG-UI events. Mapping is fluent and method-based: create an instance, +register `Map*` hooks, and pass it to `ToChatRequestContext`. Plain toggles such +as [`IncludeRawEvents`](#includerawevents) are init-only properties, so set them +in an object initializer before chaining. ```csharp -var streamOptions = new AGUIStreamOptions() +var streamOptions = new AGUIStreamOptions { IncludeRawEvents = false } .MapResultAsStateSnapshot("create_plan"); var ctx = input.ToChatRequestContext(jsonSerializerOptions, streamOptions); @@ -40,6 +42,36 @@ maps: `RawEvent` values. +## IncludeRawEvents + +By default the converter serializes each `ChatResponseUpdate` and attaches it as +the `rawEvent` field of every event derived from that update — the +`TEXT_MESSAGE_*`, `TOOL_CALL_*`, and `REASONING_ENCRYPTED_VALUE` events. It lets +a client inspect the provider payload behind an event, but it is not free: on a +typical streaming response the `rawEvent` payloads are several times the size of +the events that carry them, because a short text delta ships alongside a full +serialized update. + +Set `IncludeRawEvents` to `false` when no client reads the field. This skips the +serialization altogether, so you save the per-update CPU and allocations as well +as the bytes. + +```csharp +var streamOptions = new AGUIStreamOptions { IncludeRawEvents = false }; + +var ctx = input.ToChatRequestContext(jsonSerializerOptions, streamOptions); +``` + +The event sequence is unchanged — only the `rawEvent` field is omitted. Events +you supply yourself keep whatever `RawEvent` you set on them, whether they come +from `ChatResponseUpdate.RawRepresentation` or from a `Map*` callback; the +toggle only governs the payload the converter attaches on your behalf. + + + This applies to both transports. `rawEvent` rides the protobuf encoding as + well as SSE, so the saving is the same either way. + + ## MapContent `MapContent(Func?> mapper)` receives an diff --git a/sdks/dotnet/docs/architecture.md b/sdks/dotnet/docs/architecture.md index ddd0b82ca7..274a441622 100644 --- a/sdks/dotnet/docs/architecture.md +++ b/sdks/dotnet/docs/architecture.md @@ -79,7 +79,9 @@ On the server, the negotiating `AGUIResults.Events` result picks the formatter f ### Tool mapping with AGUIStreamOptions -`AGUIStreamOptions` is the configuration object passed to `ToChatRequestContext` and consumed by `AsAGUIEventStreamAsync`. It is method-only — no public getters or setters; everything is configured fluently. +`AGUIStreamOptions` is the configuration object passed to `ToChatRequestContext` and consumed by `AsAGUIEventStreamAsync`. Mappings are configured fluently through the `Map*` methods; plain toggles are init-only properties set in an object initializer. + +One toggle exists today: **`IncludeRawEvents`** (default `true`) controls whether the converter serializes each `ChatResponseUpdate` and attaches it as the `rawEvent` field of the `TEXT_MESSAGE_*`, `TOOL_CALL_*`, and `REASONING_ENCRYPTED_VALUE` events derived from it. This attachment is a .NET-only behavior — neither the TypeScript nor the Python SDK populates `rawEvent` from the server side — and it is not cheap: on a typical streaming response the attached payloads outweigh the events carrying them several times over. Setting it to `false` skips the serialization entirely. Events supplied by the caller (via `RawRepresentation` or a `Map*` mapping) keep whatever `RawEvent` they were given either way. The most common use case is state management. Suppose your agent has a `write_document` tool that writes a markdown document. You want the frontend to update a live preview as the document is written. Without tool mapping, you'd have to intercept the stream manually and inject `StateSnapshotEvent`s. With tool mapping: diff --git a/sdks/dotnet/src/AGUI.Server/AGUIStreamOptions.cs b/sdks/dotnet/src/AGUI.Server/AGUIStreamOptions.cs index 080366d6a7..314d55a8aa 100644 --- a/sdks/dotnet/src/AGUI.Server/AGUIStreamOptions.cs +++ b/sdks/dotnet/src/AGUI.Server/AGUIStreamOptions.cs @@ -10,9 +10,11 @@ namespace AGUI.Server; /// Options for configuring how streams are converted to AG-UI event streams. /// /// -/// Configuration is entirely method-based and fluent — there are no public setters. Construct an -/// instance and call the Map* helpers to register mappings for tool calls, tool results, -/// custom interrupts, and otherwise-unmapped instances. +/// Mapping configuration is method-based and fluent — the Map* helpers register mappings for +/// tool calls, tool results, custom interrupts, and otherwise-unmapped +/// instances, and each returns the same instance so calls can be chained. Plain toggles such as +/// are init-only properties, so combine the two with an object +/// initializer: new AGUIStreamOptions { IncludeRawEvents = false }.MapResultAsStateSnapshot("x"). /// public sealed class AGUIStreamOptions { @@ -21,6 +23,26 @@ public sealed class AGUIStreamOptions private List>? _interruptMappers; private List?>>? _contentMappers; + /// + /// Gets a value indicating whether the originating is serialized + /// and attached as to the events derived from it. Defaults to + /// . + /// + /// + /// + /// The attachment applies to the TEXT_MESSAGE_*, TOOL_CALL_*, and + /// REASONING_ENCRYPTED_VALUE events produced from an update; events the SDK synthesizes on + /// its own (RUN_STARTED, RUN_FINISHED) and events supplied by a caller — whether via + /// or a registered mapping — are never touched. + /// + /// + /// Setting this to skips the serialization entirely, which removes both the + /// wire cost (the payload is typically several times the size of a small text delta) and the + /// per-update serialization work. Turn it off unless a client actually reads rawEvent. + /// + /// + public bool IncludeRawEvents { get; init; } = true; + /// /// Registers a fallback that maps an to an . /// When the registered mapper returns a non-null value, the hosting layer emits a diff --git a/sdks/dotnet/src/AGUI.Server/ChatResponseUpdateAGUIExtensions.cs b/sdks/dotnet/src/AGUI.Server/ChatResponseUpdateAGUIExtensions.cs index 8a26ca1694..15ad7f80e0 100644 --- a/sdks/dotnet/src/AGUI.Server/ChatResponseUpdateAGUIExtensions.cs +++ b/sdks/dotnet/src/AGUI.Server/ChatResponseUpdateAGUIExtensions.cs @@ -196,8 +196,12 @@ private static async IAsyncEnumerable CoreAsync( yield return RunStartedEvent.Create(threadId, runId, context.Input.ParentRunId); } - // Serialize the raw ChatResponseUpdate once for attaching to emitted events - var raw = JsonSerializer.SerializeToElement(chatResponse, jsonSerializerOptions.GetTypeInfo(typeof(ChatResponseUpdate))); + // Serialize the raw ChatResponseUpdate once for attaching to emitted events. + // Skipped entirely when the caller opted out, so neither the serialization work nor the + // wire bytes are paid for. + JsonElement? raw = options.IncludeRawEvents + ? JsonSerializer.SerializeToElement(chatResponse, jsonSerializerOptions.GetTypeInfo(typeof(ChatResponseUpdate))) + : null; string? effectiveMessageId = null; foreach (var content in chatResponse.Contents) diff --git a/sdks/dotnet/src/AGUI.Server/Internal/ReasoningMessageTracker.cs b/sdks/dotnet/src/AGUI.Server/Internal/ReasoningMessageTracker.cs index 268161cf92..15e0675bbb 100644 --- a/sdks/dotnet/src/AGUI.Server/Internal/ReasoningMessageTracker.cs +++ b/sdks/dotnet/src/AGUI.Server/Internal/ReasoningMessageTracker.cs @@ -37,7 +37,7 @@ public BaseEvent EmitDelta(string delta) => Delta = delta }; - public BaseEvent EmitEncryptedValue(string encryptedValue, JsonElement raw) => + public BaseEvent EmitEncryptedValue(string encryptedValue, JsonElement? raw) => new ReasoningEncryptedValueEvent { Subtype = "message", diff --git a/sdks/dotnet/src/AGUI.Server/PublicAPI.Unshipped.txt b/sdks/dotnet/src/AGUI.Server/PublicAPI.Unshipped.txt index 583fa06017..5cacff7c74 100644 --- a/sdks/dotnet/src/AGUI.Server/PublicAPI.Unshipped.txt +++ b/sdks/dotnet/src/AGUI.Server/PublicAPI.Unshipped.txt @@ -5,6 +5,8 @@ AGUI.Server.ChatResponseUpdateAGUIExtensions static AGUI.Server.ChatResponseUpdateAGUIExtensions.AsAGUIEventStreamAsync(this System.Collections.Generic.IAsyncEnumerable! updates, AGUI.Server.ChatRequestContext! context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! AGUI.Server.AGUIStreamOptions AGUI.Server.AGUIStreamOptions.AGUIStreamOptions() -> void +AGUI.Server.AGUIStreamOptions.IncludeRawEvents.get -> bool +AGUI.Server.AGUIStreamOptions.IncludeRawEvents.init -> void AGUI.Server.AGUIStreamOptions.MapInterrupt(System.Func! mapper) -> AGUI.Server.AGUIStreamOptions! AGUI.Server.AGUIStreamOptions.MapContent(System.Func?>! mapper) -> AGUI.Server.AGUIStreamOptions! AGUI.Server.AGUIStreamOptions.MapCall(string! toolName, System.Func!>! mapper) -> AGUI.Server.AGUIStreamOptions! diff --git a/sdks/dotnet/tests/AGUI.Hosting.AspNetCore.IntegrationTests/RawEventSuppressionIntegrationTest.cs b/sdks/dotnet/tests/AGUI.Hosting.AspNetCore.IntegrationTests/RawEventSuppressionIntegrationTest.cs new file mode 100644 index 0000000000..4710cda47b --- /dev/null +++ b/sdks/dotnet/tests/AGUI.Hosting.AspNetCore.IntegrationTests/RawEventSuppressionIntegrationTest.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using AGUI.Abstractions; +using AGUI.Samples.Shared; +using AGUI.Server; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace AGUI.Server.IntegrationTests; + +/// +/// Verifies that reaches the wire: the field is +/// serialized into the SSE body by default and absent from it when the option is turned off. +/// +public sealed class RawEventSuppressionIntegrationTest +{ + private const string SseMediaType = "text/event-stream"; + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SseBody_ContainsRawEvent_OnlyWhenIncludeRawEventsIsTrue(bool includeRawEvents) + { + var body = await WriteSseBody(new AGUIStreamOptions { IncludeRawEvents = includeRawEvents }); + + Assert.Equal(includeRawEvents, body.Contains("\"rawEvent\"")); + + // The events themselves are on the wire either way — only the field differs. + Assert.Contains("TEXT_MESSAGE_CONTENT", body); + Assert.Contains("TOOL_CALL_ARGS", body); + } + + [Fact] + public async Task SseBody_DefaultOptions_ContainRawEvent() + { + var body = await WriteSseBody(new AGUIStreamOptions()); + + Assert.Contains("\"rawEvent\"", body); + } + + private static async Task WriteSseBody(AGUIStreamOptions streamOptions) + { + var input = new RunAgentInput { ThreadId = "thread-1", RunId = "run-1" }; + var ctx = input.ToChatRequestContext(AIJsonUtilities.DefaultOptions, streamOptions); + + var context = new DefaultHttpContext + { + RequestServices = new ServiceCollection().AddLogging().BuildServiceProvider(), + }; + context.Response.Body = new MemoryStream(); + context.Request.Headers.Accept = SseMediaType; + + var result = AGUIResults.Events(Updates().AsAGUIEventStreamAsync(ctx), context); + await result.ExecuteAsync(context).ConfigureAwait(false); + + Assert.Equal(SseMediaType, context.Response.ContentType); + + return Encoding.UTF8.GetString(((MemoryStream)context.Response.Body).ToArray()); + } + + private static async IAsyncEnumerable Updates() + { + yield return new ChatResponseUpdate(ChatRole.Assistant, "Hello") { MessageId = "msg-1" }; + yield return new ChatResponseUpdate + { + Role = ChatRole.Assistant, + MessageId = "msg-1", + Contents = [new FunctionCallContent("call-1", "search", new Dictionary { ["q"] = "x" })], + }; + + await Task.CompletedTask.ConfigureAwait(false); + } +} diff --git a/sdks/dotnet/tests/AGUI.Server.UnitTests/ChatResponseUpdateAGUIExtensionsTest.cs b/sdks/dotnet/tests/AGUI.Server.UnitTests/ChatResponseUpdateAGUIExtensionsTest.cs index 0fef2be146..b815c540d6 100644 --- a/sdks/dotnet/tests/AGUI.Server.UnitTests/ChatResponseUpdateAGUIExtensionsTest.cs +++ b/sdks/dotnet/tests/AGUI.Server.UnitTests/ChatResponseUpdateAGUIExtensionsTest.cs @@ -1344,6 +1344,112 @@ public async Task NonRawRepresentation_Updates_HaveRawEventAttached() Assert.NotNull(content.RawEvent); } + [Fact] + public void IncludeRawEvents_DefaultsToTrue() + { + Assert.True(new AGUIStreamOptions().IncludeRawEvents); + } + + [Fact] + public async Task IncludeRawEventsFalse_OmitsRawEventFromTextEvents() + { + var updates = ToAsyncEnumerable( + new ChatResponseUpdate(ChatRole.Assistant, "Hello") { MessageId = "msg-1" }, + new ChatResponseUpdate(ChatRole.Assistant, " world") { MessageId = "msg-1" }); + + var events = await CollectEvents(updates, NoRawEvents()); + + Assert.NotEmpty(events.OfType()); + Assert.All(events, e => Assert.Null(e.RawEvent)); + } + + [Fact] + public async Task IncludeRawEventsFalse_OmitsRawEventFromToolCallEvents() + { + var updates = ToAsyncEnumerable( + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new FunctionCallContent("call-1", "search", new Dictionary { ["q"] = "x" })] + }, + new ChatResponseUpdate + { + Role = ChatRole.Tool, + Contents = [new FunctionResultContent("call-1", "done")] + }); + + var events = await CollectEvents(updates, NoRawEvents()); + + Assert.Null(events.OfType().Single().RawEvent); + Assert.Null(events.OfType().Single().RawEvent); + Assert.Null(events.OfType().Single().RawEvent); + Assert.Null(events.OfType().Single().RawEvent); + } + + [Fact] + public async Task IncludeRawEventsFalse_OmitsRawEventFromReasoningEncryptedValue() + { + var updates = ToAsyncEnumerable( + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new TextReasoningContent("") { ProtectedData = "signed-blob" }] + }); + + var withRaw = await CollectEvents(updates, new AGUIStreamOptions()); + Assert.NotNull(withRaw.OfType().Single().RawEvent); + + var events = await CollectEvents(updates, NoRawEvents()); + Assert.Null(events.OfType().Single().RawEvent); + } + + [Fact] + public async Task IncludeRawEventsFalse_PreservesCallerSuppliedRawEvent() + { + var passthrough = new CustomEvent + { + Name = "mine", + Value = JsonDocument.Parse("""{"a":1}""").RootElement, + RawEvent = JsonDocument.Parse("""{"keep":"me"}""").RootElement, + }; + + var events = await CollectEvents( + ToAsyncEnumerable(new ChatResponseUpdate { RawRepresentation = passthrough }), + NoRawEvents()); + + var emitted = Assert.Single(events.OfType()); + Assert.Equal("me", emitted.RawEvent!.Value.GetProperty("keep").GetString()); + } + + [Fact] + public async Task IncludeRawEventsFalse_ProducesIdenticalEventSequence() + { + static IAsyncEnumerable Updates() => ToAsyncEnumerable( + new ChatResponseUpdate(ChatRole.Assistant, "Hello") { MessageId = "msg-1" }, + new ChatResponseUpdate + { + Role = ChatRole.Assistant, + MessageId = "msg-1", + Contents = [new FunctionCallContent("call-1", "search", new Dictionary { ["q"] = "x" })] + }, + new ChatResponseUpdate + { + Role = ChatRole.Tool, + Contents = [new FunctionResultContent("call-1", "done")] + }); + + var withRaw = await CollectEvents(Updates(), new AGUIStreamOptions()); + var withoutRaw = await CollectEvents(Updates(), NoRawEvents()); + + Assert.Equal( + withRaw.ConvertAll(e => e.Type), + withoutRaw.ConvertAll(e => e.Type)); + Assert.Contains(withRaw, e => e.RawEvent is not null); + Assert.All(withoutRaw, e => Assert.Null(e.RawEvent)); + } + + private static AGUIStreamOptions NoRawEvents() => new() { IncludeRawEvents = false }; + #endregion #region Unicode and Special Characters (GAP-7) @@ -1540,6 +1646,13 @@ private static async Task> CollectEvents( options.MapContent(content => unmappedUpdateHandler(null!, content)); } + return await CollectEvents(updates, options).ConfigureAwait(false); + } + + private static async Task> CollectEvents( + IAsyncEnumerable updates, + AGUIStreamOptions options) + { var events = new List(); await foreach (var evt in updates.AsAGUIEventStreamAsync(BuildContext(options)).ConfigureAwait(false)) {