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

[Bug]: AG-UI ToolMessage has no name field, so LangGraph adapters cannot set ToolMessage.name — name-based routing silently falls through

Abierto
#2,530 1 comentario 0 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

`ToolMessage` is the only role in the AG-UI message schema without a `name` field. Every
other role — `user`, `assistant`, `system`, `developer` — inherits an optional `name` from
the base message type. The tool role does not inherit it, in either SDK.

The consequence is not cosmetic. LangChain's `ToolMessage` has a `name` field, and matching
on it is the canonical way a LangGraph developer routes a tool result:

```python
if message.name == "request_page_oncall":
return "execute_page"
```

Because the adapters have no name to forward, `name` is `None` on arrival, that branch
falls through **silently**, and the gated node never runs. The model then reports the gated
action as done. See "Why it matters" below — the failure mode is worse than a dropped field.

This is the same code block as #2226 (`ToolMessage.error` dropped), fixed in 0.0.43. That
fix carried `error` onto LangChain's `status`. `name` is the remaining omission in the same
branch, and it needs a schema change first.

#### Where

### The schema — `name` is absent on the tool role only

**Python**, `ag_ui/core/types.py` (`ag-ui-protocol` 0.1.19). `ToolMessage` extends
`ConfiguredBaseModel` directly, while `UserMessage`, `AssistantMessage`, `SystemMessage`,
and `DeveloperMessage` all extend `BaseMessage` and pick up its `name`:

```python
class ToolMessage(ConfiguredBaseModel): # <-- not BaseMessage
id: str
role: Literal["tool"] = "tool"
content: str
tool_call_id: str
error: Optional[str] = None
encrypted_value: Optional[str] = None
# no name
```

**TypeScript**, `@ag-ui/core` 0.0.57. `ToolMessageSchema` is built standalone; the other
four roles spread `BaseMessageSchema`, which carries `name: z.ZodOptional`:

```ts
declare const ToolMessageSchema: z.ZodObject<{
id: z.ZodString;
content: z.ZodString;
role: z.ZodLiteral<"tool">;
toolCallId: z.ZodString;
error: z.ZodOptional;
encryptedValue: z.ZodOptional;
}, ...>; // no name
```

The two SDKs agree, which reads like an oversight carried across the port rather than a
deliberate exclusion.

### The adapters — the omission is visible as an asymmetry

**Python**, `ag_ui_langgraph/utils.py`, `agui_messages_to_langchain` (0.0.42). Three roles
forward `name` explicitly; the tool branch is the only one that cannot, because there is
nothing to read:

```python
elif role == "system":
langchain_messages.append(SystemMessage(
id=message.id, content=message.content, name=message.name,
))
elif role == "tool":
langchain_messages.append(ToolMessage(
id=message.id,
content=message.content,
tool_call_id=message.tool_call_id,
)) # <-- no name
```

**TypeScript**, `integrations/langgraph/typescript/src/utils.ts`,
`aguiMessagesToLangChain` (0.0.42, lines 402-411; recovered from the published sourcemap):

```ts
case "tool":
pendingReasoning = [];
out.push({
content: message.content,
role: message.role,
type: message.role,
tool_call_id: message.toolCallId,
id: message.id,
} as LangGraphMessage);
break;
```

I checked 0.0.43 (current `latest`) — that block gained only
`status: message.error ? "error" : "success"` from #2226. `name` is still absent, so
bumping the pin does not help a consumer hitting this.

### Outbound loses it too, so the round-trip destroys it

`langchain_messages_to_agui` has the same ceiling in the other direction — the field has
nowhere to go:

```python
elif isinstance(message, ToolMessage):
agui_messages.append(AGUIToolMessage(
id=str(message.id),
role="tool",
content=stringify_if_needed(resolve_message_content(message.content)),
tool_call_id=message.tool_call_id,
)) # message.name discarded
```

This is the part I would flag hardest. It means the name is destroyed even for **backend**
tools, where LangGraph's own `ToolNode` sets it correctly. Any client that rebuilds history
from a `MessagesSnapshotEvent` and hands it back gets a transcript in which no tool message
has a name — including the ones that had one on the server a moment earlier.

### Steps to Reproduce

The converter can be exercised directly — no graph, no model, no network. Against
`ag-ui-langgraph==0.0.42` / `ag-ui-protocol==0.1.19`:

```python
from ag_ui.core import ToolMessage as AGUIToolMessage, AssistantMessage, ToolCall, FunctionCall
from ag_ui_langgraph.utils import agui_messages_to_langchain

print("AG-UI ToolMessage fields:", list(AGUIToolMessage.model_fields.keys()))

msgs = [
AssistantMessage(id="a1", role="assistant", content="",
tool_calls=[ToolCall(id="tc1", type="function",
function=FunctionCall(name="request_page_oncall", arguments='{"reason":"db down"}'))]),
AGUIToolMessage(id="t1", role="tool", content='{"approved": true}', tool_call_id="tc1"),
]
tm = agui_messages_to_langchain(msgs)[-1]
print("tm.name:", repr(tm.name))
print("routes on name == 'request_page_oncall':", tm.name == "request_page_oncall")
```

Output:

```
AG-UI ToolMessage fields: ['id', 'role', 'content', 'tool_call_id', 'error', 'encrypted_value']
tm.name: None
routes on name == 'request_page_oncall': False
```

The name is present on the assistant's `tool_calls[0]` two entries earlier in the same
list the converter is walking. It is simply never carried to the tool message.

The round-trip loss is one step shorter to show — start from a LangChain `ToolMessage`
that *does* have a name, as `ToolNode` produces for any backend tool:

```python
from langchain_core.messages import ToolMessage as LCToolMessage
from ag_ui_langgraph.utils import langchain_messages_to_agui, agui_messages_to_langchain

original = LCToolMessage(id="t1", content='{"approved": true}', tool_call_id="tc1",
name="request_page_oncall")
snapshot = langchain_messages_to_agui([original])
back = agui_messages_to_langchain(snapshot)
print("server-side name: ", repr(original.name))
print("in AG-UI snapshot: ", snapshot[0].model_dump())
print("after round-trip name:", repr(back[0].name))
```

Output:

```
server-side name: 'request_page_oncall'
in AG-UI snapshot: {'id': 't1', 'role': 'tool', 'content': '{"approved": true}', 'tool_call_id': 'tc1', 'error': None, 'encrypted_value': None}
after round-trip name: None
```

### Expected Behavior

`ToolMessage.name` is populated on the converted LangChain message, so that the canonical
LangGraph routing pattern works:

```python
if message.name == "request_page_oncall":
return "execute_page"
```

and so that a tool message that had a name on the server still has it after a snapshot
round-trip.

### Environment

```text
AG-UI package(s) & version(s): ag-ui-langgraph 0.0.42 (0.0.43 checked, unchanged),
ag-ui-protocol 0.1.19, @ag-ui/core 0.0.57,
@ag-ui/langgraph 0.0.42 and 0.0.43
Runtime: Python 3.14 / Node 20
```

### Additional Context

#### Why it matters

A dropped field usually surfaces as an error. This one surfaces as a **confident false
claim that a side effect occurred**, which is the inverse of what a developer guards
against:

- the router matches on `message.name`, gets `None`, and falls through
- the gated node never executes, so nothing happens
- the side effect's audit trail therefore stays clean
- **a clean audit trail is exactly what a correctly working gate looks like**

From inside the application, the evidence of safety and the evidence of total failure are
identical. A developer who checks "did we page anyone? no" concludes the gate works. The
distinguishing signal in production is that nobody arrives.

Observed in a real run (CopilotKit/CopilotKit#6571): an operator approved a paging action
in the browser, the router fell through, and the model told the operator "the on-call
engineer has been paged." That was false.

The SDKs have been quietly compensating for this already, which is some evidence the field
is expected to be there. `sdk-python/copilotkit/langgraph.py` builds a `tool_call_id → name`
map from the preceding assistant and falls back to `message.name or ""`.

#### Two things that could be fixed independently

**1. Add `name` to `ToolMessage` in both SDKs** — optional, matching every other role. This
is the fix for the round-trip loss, and nothing else fully substitutes for it.

**2. Recover the name in the adapters, no protocol change required.** Both converters walk
the message list in order and have already converted the preceding assistant message, whose
`tool_calls[]` carry `id` and `name`. Matching `tool_call_id → name` gives the tool branch a
name to set even while the wire format lacks the field. This is exactly the lookup
`sdk-python/copilotkit/langgraph.py:61-134` already performs.

Doing (2) alone fixes name-based routing for a live run. Doing (1) as well is what makes a
snapshot round-trip lossless.

#### Related

- #2226 — `ToolMessage.error` dropped in the same branch; fixed in 0.0.43. Same shape of
defect, and the fix landed one line above where this one belongs.
- #1742 — `ToolMessage.name=None` on `OnToolEnd`. Adjacent but distinct: that is the
outbound Python path failing validation on a `None` name. This issue is about the field
not existing in the schema at all, which is why the inbound path has nothing to forward.

#### How this was found

A LangGraph Python + Angular onboarding validation run on 2026-08-19, gating a real side
effect on a named human answer. Filed downstream as CopilotKit/CopilotKit#6571; that report
initially attributed a second symptom (a `{toolName, result}` envelope) to this package —
that half turned out to be a CopilotKit Angular bug and is fixed separately in
CopilotKit/CopilotKit#6586. This issue is the half that lives here.

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.