ag-ui-protocol / ag-ui-protocol/ag-ui
[Bug]: [.NET] AsChatMessages throws Unknown chat role: reasoning for roles the SDK itself defines and parses
- Lenguaje dominante
- Python
- Estrellas
- 15.9k
- Forks
- 1.4k
- Merge medio
- 1 d 17 h
- PR fusionados (30 d)
- 163
Descripción
### Pre-flight Checklist
- [x] I have searched [existing issues](https://github.com/ag-ui-protocol/ag-ui/issues) and this hasn't been reported yet.
- [x] I am using the **latest** version AG-UI.
### Describe the Bug
`AGUI.Abstractions` is internally inconsistent about message roles: it **defines** and **deserialises** `reasoning` and `activity`, then **throws** when converting them to `ChatMessage`.
The three pieces, all in `sdks/dotnet/src/AGUI.Abstractions`:
1. **`Messages/AGUIRoles.cs`** declares all seven protocol roles, including `Activity = "activity"` and `Reasoning = "reasoning"`.
2. **`Messages/AGUIMessageJsonConverter.cs#L42-L45`** deserialises them happily into `AGUIActivityMessage` / `AGUIReasoningMessage`.
3. **`Extensions/AGUIChatMessageExtensions.cs#L271-L277`** — `MapChatRole` handles only `system`, `user`, `assistant`, `developer` and `tool`, and otherwise throws:
```csharp
public static ChatRole MapChatRole(string role) =>
string.Equals(role, AGUIRoles.System, StringComparison.OrdinalIgnoreCase) ? ChatRole.System :
string.Equals(role, AGUIRoles.User, StringComparison.OrdinalIgnoreCase) ? ChatRole.User :
string.Equals(role, AGUIRoles.Assistant, StringComparison.OrdinalIgnoreCase) ? ChatRole.Assistant :
string.Equals(role, AGUIRoles.Developer, StringComparison.OrdinalIgnoreCase) ? s_developerChatRole :
string.Equals(role, AGUIRoles.Tool, StringComparison.OrdinalIgnoreCase) ? ChatRole.Tool :
throw new InvalidOperationException($"Unknown chat role: {role}");
```
`AsChatMessages` calls it unconditionally at [`AGUIChatMessageExtensions.cs#L67`](https://github.com/ag-ui-protocol/ag-ui/blob/main/sdks/dotnet/src/AGUI.Abstractions/Extensions/AGUIChatMessageExtensions.cs#L67), **before** any per-message-type branching, so a single reasoning message anywhere in `RunAgentInput.Messages` fails the whole run with an unhandled exception — surfacing to the caller as **HTTP 500**.
### Why this breaks conforming clients
This isn't an edge case: the protocol *requires* the behaviour that triggers it. From [docs.ag-ui.com/concepts/messages](https://docs.ag-ui.com/concepts/messages), under **Reasoning Messages**:
> Unlike Activity messages, Reasoning messages are intended to represent the agent's internal thought process and may be encrypted for privacy and **are meant to be sent back to the agent for further processing on subsequent turns**.
So any spec-compliant client that echoes reasoning messages back — as it is told to — gets a **500 on the second turn of every conversation in which the model emitted reasoning**. Turn 1 succeeds; turn 2 always fails.
`activity` is the mirror image. The same doc says:
> **Frontend-only:** never forwarded to the agent, so no filtering and no LLM confusion.
So it should never arrive — but if a client sends one anyway, the SDK 500s rather than ignoring it.
Net effect: **a .NET AG-UI server cannot be used with a reasoning model and a conforming client.**
### Steps to Reproduce
Minimal, no server and no client required — the throw is in `AGUI.Abstractions` alone. This snippet is verified against `AGUI.Abstractions` 0.0.4 on .NET 10; it prints `AGUIReasoningMessage` and then throws.
```csharp
using AGUI.Abstractions;
using System.Text.Json;
// A RunAgentInput exactly as a conforming client re-sends it on turn 2.
const string json = """
{
"threadId": "t-1",
"runId": "r-2",
"messages": [
{ "id": "1", "role": "user", "content": "How many objectives are listed?" },
{ "id": "2", "role": "reasoning", "content": "**Counting objectives** ... 4 items." },
{ "id": "3", "role": "assistant", "content": "There are 4 objectives listed." },
{ "id": "4", "role": "user", "content": "How many agreed actions are listed?" }
]
}
""";
var input = JsonSerializer.Deserialize(json, AGUIJsonSerializerContext.Default.RunAgentInput)!;
// Deserialization succeeds: messages[1] is a well-formed AGUIReasoningMessage.
Console.WriteLine(input.Messages[1].GetType().Name); // AGUIReasoningMessage
// Conversion throws.
var chatMessages = input.Messages.AsChatMessages().ToList();
// System.InvalidOperationException: Unknown chat role: reasoning
```
### End-to-end reproduction
1. Host an agent with `AGUI.Server` / `Microsoft.Agents.AI.Hosting.AGUI.AspNetCore` using a **reasoning-capable model** (e.g. GPT-5 class).
2. Connect any conforming client that persists and re-sends the streamed conversation — we hit this with CopilotKit, but the behaviour is mandated by the spec, not specific to that client.
3. Send a first message. The agent emits `REASONING_MESSAGE_START` / `REASONING_MESSAGE_CONTENT` / `REASONING_MESSAGE_END`; the client stores the resulting `role: "reasoning"` message. ✅ Works.
4. Send a **second** message in the same thread. The client includes the reasoning message in `RunAgentInput.Messages`, per spec.
5. The request fails with HTTP 500 / `Unknown chat role: reasoning`. ❌
Every subsequent turn in that thread fails identically — the thread is permanently unusable, because the offending message is now part of the client's authoritative history.
### Expected Behavior
`AsChatMessages` should handle every role `AGUIRoles` declares and `AGUIMessageJsonConverter` accepts. Specifically:
- **`reasoning`** — mapped to a `ChatMessage` rather than dropped. `Microsoft.Extensions.AI` already models this as `TextReasoningContent`, so the natural mapping is `ChatRole.Assistant` with a `TextReasoningContent` payload, preserving `encryptedValue` so encrypted chain-of-thought keeps its continuity across turns (which is the entire point of the field, and required for `store:false` / ZDR scenarios).
- **`activity`** — skipped, matching the spec's "frontend-only, never forwarded to the agent". Silently ignoring an activity message is strictly better than throwing.
More generally: an unrecognised role arriving from a client should not produce an **unhandled exception in a request pipeline**. Even if some role genuinely cannot be represented, skipping it (or surfacing a validation error) keeps the conversation usable, whereas throwing bricks the thread permanently.
It would also help to have a round-trip test asserting that everything `AGUIMessageJsonConverter` can deserialise, `AsChatMessages` can consume — the two lists are currently allowed to drift, which is exactly what happened here.
### Environment
```text
AG-UI package(s) & version(s): AGUI.Abstractions 0.0.3 and 0.0.4 (both affected)
AGUI.Server 0.0.3
Also present on: main @ 125befb3e887fc7e2fbf16211f607cd0c0da7d1e (verified by inspection)
Runtime: .NET 10.0 (net10.0), ASP.NET Core
Host packages: Microsoft.Agents.AI.Hosting.AGUI.AspNetCore 1.14.0-preview.260721.1
Microsoft.Extensions.AI 10.6.0
OS: Windows 11
Client: CopilotKit (any client that re-sends reasoning messages per spec)
```
### Screenshots
```text
AG-UI package(s) & version(s): AGUI.Abstractions 0.0.3 and 0.0.4 (both affected)
AGUI.Server 0.0.3
Also present on: main @ 125befb3e887fc7e2fbf16211f607cd0c0da7d1e (verified by inspection)
Runtime: .NET 10.0 (net10.0), ASP.NET Core
Host packages: Microsoft.Agents.AI.Hosting.AGUI.AspNetCore 1.14.0-preview.260721.1
Microsoft.Extensions.AI 10.6.0
OS: Windows 11
Client: CopilotKit (any client that re-sends reasoning messages per spec)
```
### Logs & Errors
```shell
AGUIReasoningMessage
Unhandled exception. System.InvalidOperationException: Unknown chat role: reasoning
at AGUI.Abstractions.AGUIChatMessageExtensions.MapChatRole(String role)
at AGUI.Abstractions.AGUIChatMessageExtensions.AsChatMessages(IEnumerable`1 aguiMessages)+MoveNext()
at System.Collections.Generic.List`1..ctor(IEnumerable`1 collection)
at System.Linq.Enumerable.ToList[TSource](IEnumerable`1 source)
# As returned to the caller when this happens inside a hosted agent endpoint:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"detail": "Unknown chat role: reasoning"
}
```
### Additional Context
### Relevant source
| File | Line | Behaviour |
| --- | --- | --- |
| [`Messages/AGUIRoles.cs`](https://github.com/ag-ui-protocol/ag-ui/blob/main/sdks/dotnet/src/AGUI.Abstractions/Messages/AGUIRoles.cs) | — | Declares `Activity` and `Reasoning` alongside the other five roles |
| [`Messages/AGUIMessageJsonConverter.cs`](https://github.com/ag-ui-protocol/ag-ui/blob/main/sdks/dotnet/src/AGUI.Abstractions/Messages/AGUIMessageJsonConverter.cs#L42-L45) | 42–45 | Deserialises `activity` and `reasoning` |
| [`Extensions/AGUIChatMessageExtensions.cs`](https://github.com/ag-ui-protocol/ag-ui/blob/main/sdks/dotnet/src/AGUI.Abstractions/Extensions/AGUIChatMessageExtensions.cs#L67) | 67 | `AsChatMessages` calls `MapChatRole` for every message |
| [`Extensions/AGUIChatMessageExtensions.cs`](https://github.com/ag-ui-protocol/ag-ui/blob/main/sdks/dotnet/src/AGUI.Abstractions/Extensions/AGUIChatMessageExtensions.cs#L271-L277) | 271–277 | `MapChatRole` throws for `reasoning` / `activity` |
Note that `AGUIMessageJsonConverter.Write` (L136–L140) *serialises* both types correctly, so the gap really is isolated to `MapChatRole`.
### Our workaround (and why it isn't a fix)
We strip any message whose role `MapChatRole` cannot handle from `RunAgentInput.Messages` in the proxy that fronts our agent, using an allow-list of the five supported roles so a future display-only role degrades to a dropped message instead of a 500.
This unblocks us, but it is **a deviation from the spec, not a fix**: dropping reasoning messages discards exactly the cross-turn reasoning continuity (including `encryptedValue`) that the protocol asks clients to preserve. Consumers shouldn't have to choose between a 500 and silently violating the spec.
### Possibly related
The same `MapChatRole` is reached through `Microsoft.Agents.AI.Hosting.AGUI.AspNetCore`, so this affects the Microsoft Agent Framework AG-UI hosting path too — the exception simply surfaces there as a 500 from the agent endpoint.
Guía de contribución
Evaluación
Este issue todavía no se ha evaluado.