Skip to content

Commit 3f6d73f

Browse files
bloveclaude
andauthored
fix(middleware)!: server_tools router default; Python emit_custom_event helper (#1052)
* feat(middleware): emit_custom_event, the Python helper that survives ag-ui-langgraph The ag-ui-langgraph bridge consumes the graph through astream_events, so the only path from a node to the adapter's customEvents() signal is adispatch_custom_event. A get_stream_writer() write with stream_mode="custom" surfaces at most as a raw event and is silently dropped — the payload never reaches the client, with no error anywhere. Adds threadplane.middleware.langgraph.emit_custom_event, an async wrapper around adispatch_custom_event that accepts the node's config when the caller has it and otherwise relies on the ambient run context. Exported from the package's __all__. The pytest drives a real one-node graph and asserts both calls arrive as on_custom_event through astream_events. Documents it in the package README, the Python LangGraph guide, and the AG-UI Custom Events guide, whose backend snippet now shows the helper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(middleware)!: default the JS router's toolsNode to 'server_tools' clientToolsChannel() declares a `tools` state channel, and LangGraph.js shares one namespace between channel names and node names — so on exactly the graphs these helpers are for, addNode('tools', …) throws "tools is already being used as a state attribute". routeAfterAgent() and clientToolsRouter() nonetheless defaulted toolsNode to 'tools', a destination no such graph could ever have. Every working consumer already passed an override. BREAKING CHANGE: the default is now 'server_tools'. Rename your server tool node to server_tools and drop the override, or keep the override pointing at whatever name your node uses. There is no shim. The Python package's route_after_agent() keeps tools_node="tools"; Python LangGraph has no such namespace collision. Two integration specs record the behavior: one invokes a graph whose ToolNode is named server_tools with no override and asserts the tool actually ran (it failed with "Branch condition returned unknown or null destination" before the fix), and one pins the addNode('tools', …) throw so the reason is written down. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 9de5c5d commit 3f6d73f

16 files changed

Lines changed: 237 additions & 27 deletions

File tree

apps/website/content/docs/ag-ui/guides/custom-events.mdx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,30 +35,34 @@ The adapter JSON-parses `value` when it arrives as a string, so consumers always
3535

3636
### The working path under ag-ui-langgraph
3737

38-
The `ag-ui-langgraph` bridge consumes the graph through `astream_events`, and it forwards every `on_custom_event` it sees one-for-one as an AG-UI `CUSTOM` frame carrying the same name and payload. LangChain's `adispatch_custom_event` is what puts an `on_custom_event` on that stream, so it is the call a node (or a callback handler running inside one) makes to reach `customEvents`:
38+
The `ag-ui-langgraph` bridge consumes the graph through `astream_events`, and it forwards every `on_custom_event` it sees one-for-one as an AG-UI `CUSTOM` frame carrying the same name and payload. LangChain's `adispatch_custom_event` is what puts an `on_custom_event` on that stream, so it is the call a node (or a callback handler running inside one) makes to reach `customEvents`.
39+
40+
The `threadplane-middleware` Python package wraps that call as `emit_custom_event`, which is the recommended way to make it:
3941

4042
```python
41-
from langchain_core.callbacks import adispatch_custom_event
4243
from langchain_core.runnables import RunnableConfig
44+
from threadplane.middleware.langgraph import emit_custom_event
4345

4446
async def analysis_node(state: State, config: RunnableConfig) -> State:
4547
# Emit a partial result as the node runs
46-
await adispatch_custom_event(
47-
"analysis_progress", {"step": "scoring", "pct": 42}
48+
await emit_custom_event(
49+
"analysis_progress", {"step": "scoring", "pct": 42}, config=config
4850
)
4951

5052
# ... do more work ...
5153

52-
await adispatch_custom_event(
53-
"analysis_progress", {"step": "scoring", "pct": 100}
54+
await emit_custom_event(
55+
"analysis_progress", {"step": "scoring", "pct": 100}, config=config
5456
)
5557
return state
5658
```
5759

60+
The signature is `emit_custom_event(name, value, *, config=None)`. Pass `config` when the node already receives one; omit it and the ambient run context is used. Backends that do not depend on the middleware package can call `adispatch_custom_event` from `langchain_core.callbacks` directly — the helper adds no wire behavior of its own.
61+
5862
The event name becomes `CustomStreamEvent.name` and the payload becomes `CustomStreamEvent.data`. This is the mechanism the [subagents example](/docs/ag-ui/guides/subagents) uses to stream child-agent tokens from a callback handler.
5963

6064
<Callout type="warning" title="get_stream_writer does not survive ag-ui-langgraph">
61-
Writing to `get_stream_writer()` with `stream_mode='custom'` does **not** produce a `CUSTOM` frame under `ag-ui-langgraph`. The bridge reads `astream_events`, where a stream-writer write surfaces at most as a raw event, so nothing is appended to `customEvents`. Use `adispatch_custom_event` instead. Other AG-UI runtimes that emit `CUSTOM` frames directly are unaffected by this constraint — the adapter only cares that a `CUSTOM` frame arrives.
65+
Writing to `get_stream_writer()` with `stream_mode='custom'` does **not** produce a `CUSTOM` frame under `ag-ui-langgraph`. The bridge reads `astream_events`, where a stream-writer write surfaces at most as a raw event, so nothing is appended to `customEvents`. Use `emit_custom_event` (or `adispatch_custom_event`) instead. Other AG-UI runtimes that emit `CUSTOM` frames directly are unaffected by this constraint — the adapter only cares that a `CUSTOM` frame arrives.
6266
</Callout>
6367

6468
### Graph state is a different signal

apps/website/content/docs/middleware/api/api-docs.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,7 @@
559559
{
560560
"name": "clientToolsRouter",
561561
"kind": "function",
562-
"description": "A prebuilt conditional-edge callback. serverToolNames is bound once at construction;\nthe returned function takes only state.\n\n graph.addConditionalEdges('agent', clientToolsRouter([]), ['tools', END]);",
562+
"description": "A prebuilt conditional-edge callback. serverToolNames is bound once at construction;\nthe returned function takes only state.\n\n graph.addConditionalEdges('agent', clientToolsRouter(names), ['server_tools', END]);\n\n`opts.toolsNode` defaults to `'server_tools'`; a graph carrying the client-tool\nchannels cannot name a node `tools`, because clientToolsChannel already\nclaims that name as a state channel.",
563563
"signature": "clientToolsRouter(serverToolNames: Iterable<string>, opts: object): (state: ClientToolsState) => string",
564564
"params": [
565565
{
@@ -760,7 +760,7 @@
760760
{
761761
"name": "routeAfterAgent",
762762
"kind": "function",
763-
"description": "Routing helper for a LangGraph conditional edge. Returns `toolsNode` when the last\nmessage has a server tool call (dispatch to the server ToolNode); otherwise `end`\n(client-only calls — the browser executes them — and no-tool-call turns both end).",
763+
"description": "Routing helper for a LangGraph conditional edge. Returns `toolsNode` when the last\nmessage has a server tool call (dispatch to the server ToolNode); otherwise `end`\n(client-only calls — the browser executes them — and no-tool-call turns both end).\n\n`toolsNode` defaults to `'server_tools'`. It cannot default to `'tools'`, because\nclientToolsChannel declares a `tools` state channel and LangGraph.js shares\none namespace between channel names and node names — `addNode('tools', …)` throws\n\"tools is already being used as a state attribute\".",
764764
"signature": "routeAfterAgent(state: ClientToolsState, serverToolNames: Iterable<string>, opts: object): string",
765765
"params": [
766766
{

apps/website/content/docs/middleware/getting-started/introduction.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ If a turn mixes server tool calls and client tool calls, server tools win the fi
5454
|-----|---------|
5555
| `clientToolsChannel()` | Adds the `tools` and `client_tools` state channels to a LangGraph annotation. |
5656
| `bindClientTools()` | Binds server tools plus client-declared tool stubs onto a model. |
57-
| `clientToolsRouter()` | Creates a conditional-edge router for server-tool vs client-tool routing. |
57+
| `clientToolsRouter()` | Creates a conditional-edge router for server-tool vs client-tool routing. Its server destination defaults to `'server_tools'`, because `tools` is already a state channel and LangGraph.js forbids a node of that name. |
5858
| `clientToolSpecs()` | Converts state catalog entries into OpenAI function-tool specs. |
5959
| `clientToolNames()` | Returns the set of client-declared tool names for a run. |
6060
| `hasClientToolCall()` | Checks whether the last message calls a client tool. |
@@ -89,6 +89,7 @@ The same entry point also exports a deduplication surface, for backends that mus
8989
| `last_message()` | Reads the last message from state. |
9090
| `a2ui_client_capabilities(state)` | Reads the A2UI client capabilities the frontend advertised, or `None` when it advertised none. |
9191
| `announce_subagent(config, tool_call_id)` | Emits a custom event binding a child graph's stream namespace to the tool call that started it. |
92+
| `emit_custom_event(name, value, config=None)` | Pushes a payload to the frontend as an AG-UI `CUSTOM` event, on the one delivery path an `ag-ui-langgraph` bridge reads. |
9293

9394
## When to use it
9495

apps/website/content/docs/middleware/getting-started/quickstart.mdx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,19 +65,19 @@ const graph = new StateGraph(State)
6565
.addEdge('server_tools', 'agent')
6666
.addConditionalEdges(
6767
'agent',
68-
(state) => clientToolsRouter(serverToolNames, { toolsNode: 'server_tools' })(state),
68+
(state) => clientToolsRouter(serverToolNames)(state),
6969
['server_tools', END],
7070
)
7171
.compile();
7272
```
7373

74-
When the last model message calls a server tool, the router returns `'server_tools'`. When the last model message calls only browser-declared client tools, the router returns `END` so the frontend can execute the call and resume.
74+
When the last model message calls a server tool, the router returns `'server_tools'` — its default destination, which is why the snippet above passes no options. When the last model message calls only browser-declared client tools, the router returns `END` so the frontend can execute the call and resume.
7575

76-
<Callout type="warning" title="Do not name the server tool node `tools`">
77-
`clientToolsChannel()` declares a `tools` state channel, and LangGraph refuses a node whose name collides with a channel: `addNode` throws *"tools is already being used as a state attribute (a.k.a. a channel), cannot also be used as a node name"*. The router's default destination is `'tools'`, so pass `toolsNode` whenever the graph actually has a server tool node. Every destination named in the path map must also exist as a node, or `.compile()` throws *"Found edge ending at unknown node"*.
76+
<Callout type="warning" title="A node cannot be named `tools`">
77+
`clientToolsChannel()` declares a `tools` state channel, and LangGraph refuses a node whose name collides with a channel: `addNode` throws *"tools is already being used as a state attribute (a.k.a. a channel), cannot also be used as a node name"*. That is why the router's default destination is `'server_tools'`. Name the node something else and pass `toolsNode` if `'server_tools'` does not suit you. Every destination named in the path map must also exist as a node, or `.compile()` throws *"Found edge ending at unknown node"*.
7878
</Callout>
7979

80-
A graph with no server tools at all can drop the node, the override, and the path-map entry, and route to `[END]` alone — which is what the package's own integration test does.
80+
A graph with no server tools at all can drop the node and the path-map entry, and route to `[END]` alone — which is what the package's own integration test does.
8181

8282
## Complete skeleton
8383

@@ -114,7 +114,7 @@ export const graph = new StateGraph(State)
114114
.addEdge('server_tools', 'agent')
115115
.addConditionalEdges(
116116
'agent',
117-
(state) => clientToolsRouter(serverToolNames, { toolsNode: 'server_tools' })(state),
117+
(state) => clientToolsRouter(serverToolNames)(state),
118118
['server_tools', END],
119119
)
120120
.compile();

apps/website/content/docs/middleware/guides/langgraph-client-tools.mdx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,14 @@ The router returns `END` when the last model message has only known client-tool
6161
```ts
6262
.addConditionalEdges(
6363
'agent',
64-
(state) => clientToolsRouter(['lookupOrder'], { toolsNode: 'server_tools' })(state),
64+
(state) => clientToolsRouter(['lookupOrder'])(state),
6565
['server_tools', END],
6666
)
6767
```
6868

6969
Use `serverToolNames` to disambiguate tools you actually execute on the backend.
7070

71-
Two rules govern the destination name. Every node named in the path map must already exist on the graph, or `.compile()` throws *"Found edge ending at unknown node"*. And the node cannot be called `tools`, because `clientToolsChannel()` declares a `tools` state channel and LangGraph rejects a node name that collides with a channel — hence the `toolsNode` override above.
71+
Two rules govern the destination name. Every node named in the path map must already exist on the graph, or `.compile()` throws *"Found edge ending at unknown node"*. And the node cannot be called `tools`, because `clientToolsChannel()` declares a `tools` state channel and LangGraph rejects a node name that collides with a channel. The router's default destination is therefore `'server_tools'`; pass `{ toolsNode }` only when your server tool node carries a different name.
7272

7373
## Mixed tool calls
7474

@@ -91,12 +91,12 @@ import {
9191
} from '@threadplane/middleware/langgraph';
9292
```
9393

94-
`routeAfterAgent(state, serverToolNames, opts)` is the primitive behind `clientToolsRouter()`. The default destinations are `'tools'` and `'__end__'`. Because `'tools'` is unusable as a node name on a graph that carries the client-tool channels, override it whenever a server tool node exists:
94+
`routeAfterAgent(state, serverToolNames, opts)` is the primitive behind `clientToolsRouter()`. The default destinations are `'server_tools'` and `'__end__'`. Override either one when your graph names those nodes differently:
9595

9696
```ts
9797
routeAfterAgent(state, ['lookupOrder'], {
98-
toolsNode: 'server_tools',
99-
end: '__end__',
98+
toolsNode: 'backend_tools',
99+
end: 'wrap_up',
100100
});
101101
```
102102

apps/website/content/docs/middleware/guides/python-langgraph.mdx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ from threadplane.middleware.langgraph import (
8282
bind_client_tools,
8383
client_tool_names,
8484
client_tool_specs,
85+
emit_custom_event,
8586
has_client_tool_call,
8687
has_server_tool_call,
8788
last_message,
@@ -100,9 +101,31 @@ from threadplane.middleware.langgraph import (
100101
| `last_message(state)` | Return the last message from `state["messages"]`, or `None`. |
101102
| `a2ui_client_capabilities(state)` | Return the A2UI capabilities the frontend advertised, or `None`. |
102103
| `announce_subagent(config, tool_call_id)` | Emit a custom event binding a child graph's stream namespace to the tool call that started it. |
104+
| `emit_custom_event(name, value, config=None)` | Push a payload to the frontend as an AG-UI `CUSTOM` event. |
103105

104106
That import list is the package's full `__all__`.
105107

108+
## Pushing data to the frontend mid-run
109+
110+
`emit_custom_event` is an async helper that wraps LangChain's `adispatch_custom_event`:
111+
112+
```python
113+
from langchain_core.runnables import RunnableConfig
114+
from threadplane.middleware.langgraph import emit_custom_event
115+
116+
async def analysis_node(state: State, config: RunnableConfig) -> State:
117+
await emit_custom_event("analysis_progress", {"pct": 42}, config=config)
118+
return state
119+
```
120+
121+
The `name` becomes `CustomStreamEvent.name` on the client and the `value` becomes `CustomStreamEvent.data`. Pass `config` when the node already receives one; omit it and the ambient run context is used.
122+
123+
<Callout type="warning" title="get_stream_writer does not reach the frontend">
124+
An `ag-ui-langgraph` backend consumes the graph through `astream_events`, and only `adispatch_custom_event` places an event on that stream. Writing to `get_stream_writer()` with `stream_mode="custom"` is silently dropped, so nothing reaches the adapter. Use `emit_custom_event` and the payload survives.
125+
</Callout>
126+
127+
The Angular side of this is documented in the AG-UI [Custom Events guide](/docs/ag-ui/guides/custom-events).
128+
106129
## Frontend contract
107130

108131
The middleware does not execute browser tools. The frontend still needs to send the catalog, observe the model tool call, execute the local function or UI interaction, and resume the graph with a `ToolMessage` containing the result.

libs/middleware/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Changelog
2+
3+
## [Unreleased]
4+
5+
### Breaking
6+
7+
- `routeAfterAgent()` and `clientToolsRouter()` now default `toolsNode` to `'server_tools'` instead of `'tools'`. The old default could never work: `clientToolsChannel()` declares a `tools` state channel, and LangGraph.js shares one namespace between channel names and node names, so `addNode('tools', …)` throws *"tools is already being used as a state attribute"* on exactly the graphs these helpers are for. Any graph that relied on the old default was already passing `{ toolsNode: 'server_tools' }` (or an equivalent override) to work around it. Rename your server tool node to `server_tools` and drop the override, or keep the override pointing at whatever name your node uses. There is no shim.
8+
9+
The Python package's `route_after_agent()` keeps `tools_node="tools"`; Python LangGraph does not share that namespace.

libs/middleware/README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,17 @@ const graph = new StateGraph(State)
5555
.addNode('agent', agent)
5656
.addEdge('__start__', 'agent')
5757
// clientToolsRouter binds the server tool names once; pass [] when there are none.
58-
.addConditionalEdges('agent', clientToolsRouter([]), ['tools', END])
58+
// With no server tools there is no tool node, so END is the only destination.
59+
.addConditionalEdges('agent', clientToolsRouter([]), [END])
5960
.compile();
6061
```
6162

63+
The router's server destination defaults to `'server_tools'`. It cannot default to
64+
`'tools'`: `clientToolsChannel()` declares a `tools` state channel, and LangGraph.js
65+
shares one namespace between channel names and node names, so `addNode('tools', …)`
66+
throws *"tools is already being used as a state attribute"*. Name the server tool node
67+
`server_tools` (or pass `{ toolsNode }` to use another name).
68+
6269
### What happens with a client tool call
6370

6471
1. The model emits a tool call whose name matches a client-declared tool.

libs/middleware/src/integration.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, it, expect } from 'vitest';
22
import { Annotation, MessagesAnnotation, StateGraph, END } from '@langchain/langgraph';
3+
import { ToolNode } from '@langchain/langgraph/prebuilt';
34
import { AIMessage, ToolMessage, HumanMessage } from '@langchain/core/messages';
5+
import { tool } from '@langchain/core/tools';
6+
import { z } from 'zod';
47
import { bindClientTools, clientToolsChannel, clientToolsRouter } from './langgraph';
58

69
// A scripted fake chat model exposing the bindTools + invoke surface the graph uses.
@@ -51,3 +54,43 @@ describe('client-tools loop (in-process)', () => {
5154
expect((r2.messages[r2.messages.length - 1] as AIMessage).content).toBe('It is 65F in SF.');
5255
});
5356
});
57+
58+
describe("the router's default toolsNode", () => {
59+
it('dispatches a server tool call to a node named by the default, with no override', async () => {
60+
const echo = tool(async ({ text }: { text: string }) => `echoed:${text}`, {
61+
name: 'echo',
62+
description: 'Echo the input.',
63+
schema: z.object({ text: z.string() }),
64+
});
65+
66+
let agentTurns = 0;
67+
const graph = new StateGraph(State)
68+
.addNode('agent', async () => {
69+
agentTurns += 1;
70+
if (agentTurns > 1) return { messages: [new AIMessage({ content: 'done' })] };
71+
return {
72+
messages: [
73+
new AIMessage({ content: '', tool_calls: [{ name: 'echo', args: { text: 'hi' }, id: 'call_1' }] }),
74+
],
75+
};
76+
})
77+
.addNode('server_tools', new ToolNode([echo]))
78+
.addEdge('__start__', 'agent')
79+
.addEdge('server_tools', 'agent')
80+
.addConditionalEdges('agent', (s) => clientToolsRouter(['echo'])(s), ['server_tools', END])
81+
.compile();
82+
83+
const result = await graph.invoke({ messages: [new HumanMessage('echo hi')] });
84+
const toolMessage = result.messages.find((m): m is ToolMessage => m instanceof ToolMessage);
85+
expect(toolMessage?.content).toBe('echoed:hi');
86+
});
87+
88+
it("records why 'tools' cannot be a node name on a client-tools graph", () => {
89+
// clientToolsChannel() declares a `tools` state channel, and LangGraph JS
90+
// shares one namespace between channel names and node names — which is why
91+
// the router's default destination is 'server_tools', not 'tools'.
92+
expect(() => new StateGraph(State).addNode('tools', async () => ({ messages: [] }))).toThrow(
93+
/tools is already being used as a state attribute/,
94+
);
95+
});
96+
});

libs/middleware/src/langgraph.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ describe('routeAfterAgent', () => {
105105
tools: [{ name: 'get_weather', description: '', parameters: {} }],
106106
});
107107
it('routes a server tool call to the tools node', () => {
108-
expect(routeAfterAgent(st(['search']), ['search'])).toBe('tools');
108+
expect(routeAfterAgent(st(['search']), ['search'])).toBe('server_tools');
109109
});
110110
it('routes a client-only tool call to END', () => {
111111
expect(routeAfterAgent(st(['get_weather']), [])).toBe('__end__');
@@ -114,7 +114,7 @@ describe('routeAfterAgent', () => {
114114
expect(routeAfterAgent(st([]), [])).toBe('__end__');
115115
});
116116
it('routes a mixed call to the server (precedence)', () => {
117-
expect(routeAfterAgent(st(['get_weather', 'search']), ['search'])).toBe('tools');
117+
expect(routeAfterAgent(st(['get_weather', 'search']), ['search'])).toBe('server_tools');
118118
});
119119
it('honors custom node names', () => {
120120
expect(routeAfterAgent(st(['search']), ['search'], { toolsNode: 'act' })).toBe('act');
@@ -131,7 +131,7 @@ describe('clientToolsRouter', () => {
131131
});
132132
it('returns a callback that routes via routeAfterAgent with bound serverToolNames', () => {
133133
const route = clientToolsRouter(['search']);
134-
expect(route(st(['search']))).toBe('tools');
134+
expect(route(st(['search']))).toBe('server_tools');
135135
expect(route(st(['get_weather']))).toBe('__end__');
136136
});
137137
it('honors custom node names', () => {

0 commit comments

Comments
 (0)