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
- Vorherrschende Sprache
- Python
- Sterne
- 15.9k
- Forks
- 1.4k
- Ø Merge
- 1 T. 17 Std.
- Gemergte PRs (30 T.)
- 163
Beschreibung
### 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.
Beitragsleitfaden
Bewertung
Dieses Issue wurde noch nicht bewertet.