Skip to content
Open
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
5 changes: 5 additions & 0 deletions docs/sdk/dotnet/abstractions/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 35 additions & 3 deletions docs/sdk/dotnet/hosting/extensibility.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -40,6 +42,36 @@ maps:
`RawEvent` values.
</Note>

## 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.

<Note>
This applies to both transports. `rawEvent` rides the protobuf encoding as
well as SSE, so the saving is the same either way.
</Note>

## MapContent

`MapContent(Func<AIContent, IEnumerable<BaseEvent>?> mapper)` receives an
Expand Down
4 changes: 3 additions & 1 deletion sdks/dotnet/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
28 changes: 25 additions & 3 deletions sdks/dotnet/src/AGUI.Server/AGUIStreamOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ namespace AGUI.Server;
/// Options for configuring how <see cref="ChatResponseUpdate"/> streams are converted to AG-UI event streams.
/// </summary>
/// <remarks>
/// Configuration is entirely method-based and fluent — there are no public setters. Construct an
/// instance and call the <c>Map*</c> helpers to register mappings for tool calls, tool results,
/// custom interrupts, and otherwise-unmapped <see cref="AIContent"/> instances.
/// Mapping configuration is method-based and fluent — the <c>Map*</c> helpers register mappings for
/// tool calls, tool results, custom interrupts, and otherwise-unmapped <see cref="AIContent"/>
/// instances, and each returns the same instance so calls can be chained. Plain toggles such as
/// <see cref="IncludeRawEvents"/> are init-only properties, so combine the two with an object
/// initializer: <c>new AGUIStreamOptions { IncludeRawEvents = false }.MapResultAsStateSnapshot("x")</c>.
/// </remarks>
public sealed class AGUIStreamOptions
{
Expand All @@ -21,6 +23,26 @@ public sealed class AGUIStreamOptions
private List<Func<AIContent, AGUIInterrupt?>>? _interruptMappers;
private List<Func<AIContent, IEnumerable<BaseEvent>?>>? _contentMappers;

/// <summary>
/// Gets a value indicating whether the originating <see cref="ChatResponseUpdate"/> is serialized
/// and attached as <see cref="BaseEvent.RawEvent"/> to the events derived from it. Defaults to
/// <see langword="true"/>.
/// </summary>
/// <remarks>
/// <para>
/// The attachment applies to the <c>TEXT_MESSAGE_*</c>, <c>TOOL_CALL_*</c>, and
/// <c>REASONING_ENCRYPTED_VALUE</c> events produced from an update; events the SDK synthesizes on
/// its own (<c>RUN_STARTED</c>, <c>RUN_FINISHED</c>) and events supplied by a caller — whether via
/// <see cref="ChatResponseUpdate.RawRepresentation"/> or a registered mapping — are never touched.
/// </para>
/// <para>
/// Setting this to <see langword="false"/> 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 <c>rawEvent</c>.
/// </para>
/// </remarks>
public bool IncludeRawEvents { get; init; } = true;

/// <summary>
/// Registers a fallback that maps an <see cref="AIContent"/> to an <see cref="AGUIInterrupt"/>.
/// When the registered mapper returns a non-null value, the hosting layer emits a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,12 @@ private static async IAsyncEnumerable<BaseEvent> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions sdks/dotnet/src/AGUI.Server/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ AGUI.Server.ChatResponseUpdateAGUIExtensions
static AGUI.Server.ChatResponseUpdateAGUIExtensions.AsAGUIEventStreamAsync(this System.Collections.Generic.IAsyncEnumerable<Microsoft.Extensions.AI.ChatResponseUpdate!>! updates, AGUI.Server.ChatRequestContext! context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable<AGUI.Abstractions.BaseEvent!>!
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<Microsoft.Extensions.AI.AIContent!, AGUI.Abstractions.AGUIInterrupt?>! mapper) -> AGUI.Server.AGUIStreamOptions!
AGUI.Server.AGUIStreamOptions.MapContent(System.Func<Microsoft.Extensions.AI.AIContent!, System.Collections.Generic.IEnumerable<AGUI.Abstractions.BaseEvent!>?>! mapper) -> AGUI.Server.AGUIStreamOptions!
AGUI.Server.AGUIStreamOptions.MapCall(string! toolName, System.Func<Microsoft.Extensions.AI.FunctionCallContent!, System.Collections.Generic.IEnumerable<AGUI.Abstractions.BaseEvent!>!>! mapper) -> AGUI.Server.AGUIStreamOptions!
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Verifies that <see cref="AGUIStreamOptions.IncludeRawEvents"/> reaches the wire: the field is
/// serialized into the SSE body by default and absent from it when the option is turned off.
/// </summary>
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<string> 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<ChatResponseUpdate> 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<string, object?> { ["q"] = "x" })],
};

await Task.CompletedTask.ConfigureAwait(false);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextMessageContentEvent>());
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<string, object?> { ["q"] = "x" })]
},
new ChatResponseUpdate
{
Role = ChatRole.Tool,
Contents = [new FunctionResultContent("call-1", "done")]
});

var events = await CollectEvents(updates, NoRawEvents());

Assert.Null(events.OfType<ToolCallStartEvent>().Single().RawEvent);
Assert.Null(events.OfType<ToolCallArgsEvent>().Single().RawEvent);
Assert.Null(events.OfType<ToolCallEndEvent>().Single().RawEvent);
Assert.Null(events.OfType<ToolCallResultEvent>().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<ReasoningEncryptedValueEvent>().Single().RawEvent);

var events = await CollectEvents(updates, NoRawEvents());
Assert.Null(events.OfType<ReasoningEncryptedValueEvent>().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<CustomEvent>());
Assert.Equal("me", emitted.RawEvent!.Value.GetProperty("keep").GetString());
}

[Fact]
public async Task IncludeRawEventsFalse_ProducesIdenticalEventSequence()
{
static IAsyncEnumerable<ChatResponseUpdate> 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<string, object?> { ["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)
Expand Down Expand Up @@ -1540,6 +1646,13 @@ private static async Task<List<BaseEvent>> CollectEvents(
options.MapContent(content => unmappedUpdateHandler(null!, content));
}

return await CollectEvents(updates, options).ConfigureAwait(false);
}

private static async Task<List<BaseEvent>> CollectEvents(
IAsyncEnumerable<ChatResponseUpdate> updates,
AGUIStreamOptions options)
{
var events = new List<BaseEvent>();
await foreach (var evt in updates.AsAGUIEventStreamAsync(BuildContext(options)).ConfigureAwait(false))
{
Expand Down