ag-ui-protocol / ag-ui-protocol/ag-ui

[Bug]: [.NET] AsChatMessages throws Unknown chat role: reasoning for roles the SDK itself defines and parses

Abierto
#2,290 2 comentarios 4 reacciones 0 asignados Ver en GitHub
bug
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

Abrir la guía de contribución

Evaluación

Este issue todavía no se ha evaluado.

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.