Skip to content

Commit 53bb646

Browse files
bloveclaude
andauthored
feat(middleware): @threadplane/middleware/langgraph — LangGraph.js client-tools middleware (#667)
* feat(middleware): scaffold libs/middleware (@threadplane/middleware) Nx tsc/vitest package Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(middleware): clientToolSpecs + clientToolNames (mirror python catalog) * feat(middleware): lastMessage + client/server tool-call predicates * feat(middleware): bindClientTools + routeAfterAgent + public index (extras pending) * feat(middleware): clientToolsChannel Annotation fragment * feat(middleware): clientToolsRouter factory + enable extras export * test(middleware): in-process StateGraph client-tools loop integration Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(middleware): README usage example --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f1491d5 commit 53bb646

17 files changed

Lines changed: 650 additions & 67 deletions

libs/middleware/README.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# @threadplane/middleware
2+
3+
Backend middleware for the [Threadplane](https://github.com/cacheplane/angular-agent-framework)
4+
client-tools capability — frontend-declared tools the model calls and the browser executes.
5+
6+
The `@threadplane/middleware/langgraph` entrypoint is the LangGraph.js twin of the Python
7+
`threadplane-middleware` package: it binds client-declared tool stubs onto your model and
8+
routes client-tool-only turns to `END` so the browser executes them.
9+
10+
## How it works
11+
12+
When a browser client sends a tool catalog (`{ name, description, parameters }` objects)
13+
along with a run request, the graph exposes those tools to the model and routes their calls
14+
back to the browser instead of executing them server-side. The browser executes the call and
15+
re-runs the graph with a `ToolMessage` carrying the result.
16+
17+
The catalog is read from `state.tools`, falling back to `state.client_tools` if `tools` is
18+
absent.
19+
20+
## Installation
21+
22+
```bash
23+
npm install @threadplane/middleware
24+
# peer deps:
25+
npm install @langchain/core @langchain/langgraph
26+
```
27+
28+
## Usage
29+
30+
```ts
31+
import { Annotation, MessagesAnnotation, StateGraph, END } from '@langchain/langgraph';
32+
import { ChatOpenAI } from '@langchain/openai';
33+
import {
34+
bindClientTools,
35+
clientToolsChannel,
36+
clientToolsRouter,
37+
} from '@threadplane/middleware/langgraph';
38+
39+
// Declare the client-tools state channels (tools + client_tools) in one line.
40+
const State = Annotation.Root({ ...MessagesAnnotation.spec, ...clientToolsChannel() });
41+
42+
const SERVER_TOOLS: unknown[] = []; // your server-owned tools (if any)
43+
const baseLlm = new ChatOpenAI({ model: 'gpt-4o-mini' });
44+
45+
async function agent(state: typeof State.State) {
46+
// Call bindClientTools per-run inside the node — the client catalog arrives
47+
// in state and may differ between runs.
48+
const llm = bindClientTools(baseLlm, SERVER_TOOLS, state);
49+
const response = await llm.invoke(state.messages);
50+
return { messages: [response] };
51+
}
52+
53+
const graph = new StateGraph(State)
54+
.addNode('agent', agent)
55+
.addEdge('__start__', 'agent')
56+
// clientToolsRouter binds the server tool names once; pass [] when there are none.
57+
.addConditionalEdges('agent', clientToolsRouter([]), ['tools', END])
58+
.compile();
59+
```
60+
61+
### What happens with a client tool call
62+
63+
1. The model emits a tool call whose name matches a client-declared tool.
64+
2. `clientToolsRouter` (via `routeAfterAgent`) returns `"__end__"` — the run ends.
65+
3. The browser receives the partial output, executes the tool locally, and re-runs the graph
66+
with a `ToolMessage` containing the result.
67+
4. The model continues from there as if it had called a server tool.
68+
69+
A turn that mixes a server tool call and a client tool call routes to the **server**
70+
destination first (the server tool runs; the client call surfaces on a later turn).
71+
72+
### Lower-level helpers
73+
74+
```ts
75+
import {
76+
clientToolSpecs, // → OpenAI function-tool objects for model.bindTools
77+
clientToolNames, // → Set<string> of client tool names
78+
hasClientToolCall, // → boolean
79+
hasServerToolCall, // → boolean (takes serverToolNames)
80+
lastMessage, // → the last message from state.messages
81+
routeAfterAgent, // → routing string (takes serverToolNames)
82+
} from '@threadplane/middleware/langgraph';
83+
```
84+
85+
## Peer dependencies
86+
87+
`@langchain/core` and `@langchain/langgraph`. The package has no runtime dependencies of its
88+
own.

libs/middleware/eslint.config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import baseConfig from '../../eslint.config.mjs';
2+
export default [...baseConfig, { files: ['**/*.ts'] }];

libs/middleware/package.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@threadplane/middleware",
3+
"version": "0.0.1",
4+
"description": "Backend middleware for the Threadplane client-tools capability. The /langgraph entrypoint targets LangGraph.js.",
5+
"keywords": ["langgraph", "agent", "client-tools", "middleware", "threadplane"],
6+
"license": "MIT",
7+
"type": "module",
8+
"sideEffects": false,
9+
"publishConfig": { "access": "public" },
10+
"repository": { "type": "git", "url": "https://github.com/cacheplane/angular-agent-framework.git", "directory": "libs/middleware" },
11+
"homepage": "https://github.com/cacheplane/angular-agent-framework#readme",
12+
"bugs": { "url": "https://github.com/cacheplane/angular-agent-framework/issues" },
13+
"exports": {
14+
"./langgraph": { "types": "./src/langgraph/index.d.ts", "default": "./src/langgraph/index.js" },
15+
"./README.md": "./README.md"
16+
},
17+
"peerDependencies": {
18+
"@langchain/core": "^1.0.0",
19+
"@langchain/langgraph": "^1.0.0"
20+
}
21+
}

libs/middleware/project.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "middleware",
3+
"$schema": "../../node_modules/nx/schemas/project-schema.json",
4+
"sourceRoot": "libs/middleware/src",
5+
"projectType": "library",
6+
"tags": ["type:lib", "scope:library", "scope:shared"],
7+
"targets": {
8+
"build": {
9+
"executor": "@nx/js:tsc",
10+
"outputs": ["{workspaceRoot}/dist/libs/middleware"],
11+
"options": {
12+
"outputPath": "dist/libs/middleware",
13+
"main": "libs/middleware/src/langgraph/index.ts",
14+
"tsConfig": "libs/middleware/tsconfig.lib.json",
15+
"assets": ["libs/middleware/README.md", "libs/middleware/package.json"]
16+
}
17+
},
18+
"test": { "executor": "@nx/vitest:test", "options": { "configFile": "libs/middleware/vite.config.mts" } },
19+
"lint": { "executor": "@nx/eslint:lint" }
20+
}
21+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// SPDX-License-Identifier: MIT
2+
import { describe, it, expect } from 'vitest';
3+
import { Annotation, MessagesAnnotation, StateGraph, END } from '@langchain/langgraph';
4+
import { AIMessage, ToolMessage, HumanMessage } from '@langchain/core/messages';
5+
import { bindClientTools, clientToolsChannel, clientToolsRouter } from './langgraph';
6+
7+
// A scripted fake chat model exposing the bindTools + invoke surface the graph uses.
8+
class FakeModel {
9+
bound: unknown[] = [];
10+
private turn = 0;
11+
bindTools(tools: unknown[]) { this.bound = tools; return this; }
12+
async invoke(_messages: unknown[]) {
13+
this.turn += 1;
14+
if (this.turn === 1) {
15+
return new AIMessage({ content: '', tool_calls: [{ name: 'get_weather', args: { city: 'SF' }, id: 'call_1' }] });
16+
}
17+
return new AIMessage({ content: 'It is 65F in SF.' });
18+
}
19+
}
20+
21+
const State = Annotation.Root({ ...MessagesAnnotation.spec, ...clientToolsChannel() });
22+
23+
function buildGraph(model: FakeModel) {
24+
const agent = async (state: typeof State.State) => {
25+
const bound = bindClientTools(model, [], state);
26+
const res = await (bound as FakeModel).invoke(state.messages);
27+
return { messages: [res] };
28+
};
29+
return new StateGraph(State)
30+
.addNode('agent', agent)
31+
.addEdge('__start__', 'agent')
32+
.addConditionalEdges('agent', (s) => clientToolsRouter([])(s), [END])
33+
.compile();
34+
}
35+
36+
describe('client-tools loop (in-process)', () => {
37+
it('binds the client stub, ends on the client call, then continues after a ToolMessage', async () => {
38+
const model = new FakeModel();
39+
const graph = buildGraph(model);
40+
const tools = [{ name: 'get_weather', description: 'Weather', parameters: { type: 'object' } }];
41+
42+
const r1 = await graph.invoke({ messages: [new HumanMessage('weather in SF?')], tools });
43+
const last1 = r1.messages[r1.messages.length - 1] as AIMessage;
44+
expect(last1.tool_calls?.[0]?.name).toBe('get_weather');
45+
expect((model.bound[0] as { function: { name: string } }).function.name).toBe('get_weather');
46+
47+
const r2 = await graph.invoke({
48+
messages: [...r1.messages, new ToolMessage({ content: '65F', tool_call_id: 'call_1' })],
49+
tools,
50+
});
51+
expect((r2.messages[r2.messages.length - 1] as AIMessage).content).toBe('It is 65F in SF.');
52+
});
53+
});
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// SPDX-License-Identifier: MIT
2+
import { describe, it, expect } from 'vitest';
3+
import { clientToolSpecs, clientToolNames } from './langgraph/middleware';
4+
5+
const WEATHER = { name: 'get_weather', description: 'Weather', parameters: { type: 'object' } };
6+
7+
describe('clientToolSpecs', () => {
8+
it('wraps each catalog entry as an OpenAI function tool', () => {
9+
expect(clientToolSpecs({ messages: [], tools: [WEATHER] })).toEqual([
10+
{ type: 'function', function: { name: 'get_weather', description: 'Weather', parameters: { type: 'object' } } },
11+
]);
12+
});
13+
it('falls back to client_tools when tools is absent', () => {
14+
expect(clientToolSpecs({ messages: [], client_tools: [WEATHER] })).toHaveLength(1);
15+
});
16+
it('defaults missing description/parameters and drops nameless entries', () => {
17+
const specs = clientToolSpecs({ messages: [], tools: [{ name: 'x' } as never, { description: 'no name' } as never] });
18+
expect(specs).toEqual([{ type: 'function', function: { name: 'x', description: '', parameters: {} } }]);
19+
});
20+
it('returns [] for empty state', () => {
21+
expect(clientToolSpecs({ messages: [] })).toEqual([]);
22+
});
23+
});
24+
25+
describe('clientToolNames', () => {
26+
it('returns the set of catalog names', () => {
27+
expect(clientToolNames({ messages: [], tools: [WEATHER] })).toEqual(new Set(['get_weather']));
28+
});
29+
});
30+
31+
import { lastMessage, hasClientToolCall, hasServerToolCall } from './langgraph/middleware';
32+
import { AIMessage, HumanMessage } from '@langchain/core/messages';
33+
34+
const stateWith = (toolCalls: { name: string }[]) => ({
35+
messages: [new HumanMessage('hi'), new AIMessage({ content: '', tool_calls: toolCalls.map((c) => ({ name: c.name, args: {}, id: c.name })) })],
36+
tools: [{ name: 'get_weather', description: '', parameters: {} }],
37+
});
38+
39+
describe('lastMessage', () => {
40+
it('returns the last message or undefined', () => {
41+
expect(lastMessage({ messages: [] })).toBeUndefined();
42+
expect(lastMessage({ messages: [new HumanMessage('a'), new HumanMessage('b')] })?.content).toBe('b');
43+
});
44+
});
45+
46+
describe('hasClientToolCall', () => {
47+
it('true when the last AI message calls a client tool', () => {
48+
expect(hasClientToolCall(stateWith([{ name: 'get_weather' }]))).toBe(true);
49+
});
50+
it('false when the last AI message calls only non-client tools', () => {
51+
expect(hasClientToolCall(stateWith([{ name: 'search' }]))).toBe(false);
52+
});
53+
it('false when there are no tool calls', () => {
54+
expect(hasClientToolCall(stateWith([]))).toBe(false);
55+
});
56+
});
57+
58+
describe('hasServerToolCall', () => {
59+
it('true when a call name is in serverToolNames', () => {
60+
expect(hasServerToolCall(stateWith([{ name: 'search' }]), ['search'])).toBe(true);
61+
});
62+
it('true when a call name is unknown (not a client tool)', () => {
63+
expect(hasServerToolCall(stateWith([{ name: 'mystery' }]), [])).toBe(true);
64+
});
65+
it('false when the only call is a known client tool', () => {
66+
expect(hasServerToolCall(stateWith([{ name: 'get_weather' }]), [])).toBe(false);
67+
});
68+
});
69+
70+
import { bindClientTools, routeAfterAgent } from './langgraph/middleware';
71+
import { clientToolsChannel } from './langgraph/channel';
72+
import { Annotation, MessagesAnnotation } from '@langchain/langgraph';
73+
74+
describe('clientToolsChannel', () => {
75+
it('produces tools + client_tools channels usable in Annotation.Root', () => {
76+
const frag = clientToolsChannel();
77+
expect(Object.keys(frag).sort()).toEqual(['client_tools', 'tools']);
78+
const State = Annotation.Root({ ...MessagesAnnotation.spec, ...frag });
79+
expect(State.spec).toHaveProperty('tools');
80+
expect(State.spec).toHaveProperty('client_tools');
81+
});
82+
});
83+
84+
describe('bindClientTools', () => {
85+
it('binds server tools then client stubs (server first), calling bindTools once', () => {
86+
const calls: unknown[][] = [];
87+
const fake = { bindTools: (tools: unknown[]) => { calls.push(tools); return 'BOUND'; } };
88+
const SERVER = { name: 'search' };
89+
const result = bindClientTools(fake as never, [SERVER as never], { messages: [], tools: [{ name: 'get_weather', description: '', parameters: {} }] });
90+
expect(result).toBe('BOUND');
91+
expect(calls).toHaveLength(1);
92+
expect(calls[0][0]).toBe(SERVER);
93+
expect((calls[0][1] as { function: { name: string } }).function.name).toBe('get_weather');
94+
});
95+
it('binds only server tools when there is no client catalog', () => {
96+
let bound: unknown[] = [];
97+
const fake = { bindTools: (tools: unknown[]) => { bound = tools; return fake; } };
98+
bindClientTools(fake as never, [{ name: 'search' } as never], { messages: [] });
99+
expect(bound).toHaveLength(1);
100+
});
101+
});
102+
103+
describe('routeAfterAgent', () => {
104+
const st = (names: string[]) => ({
105+
messages: [new AIMessage({ content: '', tool_calls: names.map((n) => ({ name: n, args: {}, id: n })) })],
106+
tools: [{ name: 'get_weather', description: '', parameters: {} }],
107+
});
108+
it('routes a server tool call to the tools node', () => {
109+
expect(routeAfterAgent(st(['search']), ['search'])).toBe('tools');
110+
});
111+
it('routes a client-only tool call to END', () => {
112+
expect(routeAfterAgent(st(['get_weather']), [])).toBe('__end__');
113+
});
114+
it('routes no tool calls to END', () => {
115+
expect(routeAfterAgent(st([]), [])).toBe('__end__');
116+
});
117+
it('routes a mixed call to the server (precedence)', () => {
118+
expect(routeAfterAgent(st(['get_weather', 'search']), ['search'])).toBe('tools');
119+
});
120+
it('honors custom node names', () => {
121+
expect(routeAfterAgent(st(['search']), ['search'], { toolsNode: 'act' })).toBe('act');
122+
expect(routeAfterAgent(st([]), [], { end: 'DONE' })).toBe('DONE');
123+
});
124+
});
125+
126+
import { clientToolsRouter } from './langgraph/router';
127+
128+
describe('clientToolsRouter', () => {
129+
const st = (names: string[]) => ({
130+
messages: [new AIMessage({ content: '', tool_calls: names.map((n) => ({ name: n, args: {}, id: n })) })],
131+
tools: [{ name: 'get_weather', description: '', parameters: {} }],
132+
});
133+
it('returns a callback that routes via routeAfterAgent with bound serverToolNames', () => {
134+
const route = clientToolsRouter(['search']);
135+
expect(route(st(['search']))).toBe('tools');
136+
expect(route(st(['get_weather']))).toBe('__end__');
137+
});
138+
it('honors custom node names', () => {
139+
const route = clientToolsRouter([], { end: 'DONE' });
140+
expect(route(st([]))).toBe('DONE');
141+
});
142+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// SPDX-License-Identifier: MIT
2+
import { Annotation } from '@langchain/langgraph';
3+
import type { ClientToolSpec } from './types';
4+
5+
/**
6+
* State channels for the client-tools catalog. Spread into Annotation.Root so a graph
7+
* declares the `tools` (primary) and `client_tools` (fallback) slices in one line:
8+
*
9+
* const State = Annotation.Root({ ...MessagesAnnotation.spec, ...clientToolsChannel() });
10+
*
11+
* Both are last-value-wins channels (the catalog is replaced per run, not accumulated).
12+
*/
13+
export function clientToolsChannel() {
14+
return {
15+
tools: Annotation<ClientToolSpec[] | undefined>(),
16+
client_tools: Annotation<ClientToolSpec[] | undefined>(),
17+
};
18+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// SPDX-License-Identifier: MIT
2+
export type { ClientToolSpec, ClientToolsState, OpenAIFunctionTool, BaseMessage } from './types';
3+
export {
4+
clientToolSpecs,
5+
clientToolNames,
6+
lastMessage,
7+
hasClientToolCall,
8+
hasServerToolCall,
9+
bindClientTools,
10+
routeAfterAgent,
11+
type BindableModel,
12+
} from './middleware';
13+
export { clientToolsChannel } from './channel';
14+
export { clientToolsRouter } from './router';

0 commit comments

Comments
 (0)