Skip to content

Commit 6ac61c9

Browse files
bloveclaude
andcommitted
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>
1 parent 5d9e33b commit 6ac61c9

10 files changed

Lines changed: 89 additions & 20 deletions

File tree

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

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', () => {

libs/middleware/src/langgraph/middleware.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,18 @@ export function bindClientTools<M extends BindableModel>(
8585
* Routing helper for a LangGraph conditional edge. Returns `toolsNode` when the last
8686
* message has a server tool call (dispatch to the server ToolNode); otherwise `end`
8787
* (client-only calls — the browser executes them — and no-tool-call turns both end).
88+
*
89+
* `toolsNode` defaults to `'server_tools'`. It cannot default to `'tools'`, because
90+
* {@link clientToolsChannel} declares a `tools` state channel and LangGraph.js shares
91+
* one namespace between channel names and node names — `addNode('tools', …)` throws
92+
* "tools is already being used as a state attribute".
8893
*/
8994
export function routeAfterAgent(
9095
state: ClientToolsState,
9196
serverToolNames: Iterable<string>,
9297
opts?: { toolsNode?: string; end?: string },
9398
): string {
94-
const toolsNode = opts?.toolsNode ?? 'tools';
99+
const toolsNode = opts?.toolsNode ?? 'server_tools';
95100
const end = opts?.end ?? '__end__';
96101
return hasServerToolCall(state, serverToolNames) ? toolsNode : end;
97102
}

libs/middleware/src/langgraph/router.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import type { ClientToolsState } from './types.js';
55
* A prebuilt conditional-edge callback. serverToolNames is bound once at construction;
66
* the returned function takes only state.
77
*
8-
* graph.addConditionalEdges('agent', clientToolsRouter([]), ['tools', END]);
8+
* graph.addConditionalEdges('agent', clientToolsRouter(names), ['server_tools', END]);
9+
*
10+
* `opts.toolsNode` defaults to `'server_tools'`; a graph carrying the client-tool
11+
* channels cannot name a node `tools`, because {@link clientToolsChannel} already
12+
* claims that name as a state channel.
913
*/
1014
export function clientToolsRouter(
1115
serverToolNames: Iterable<string>,

0 commit comments

Comments
 (0)