Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions libs/middleware/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# @threadplane/middleware

Backend middleware for the [Threadplane](https://github.com/cacheplane/angular-agent-framework)
client-tools capability — frontend-declared tools the model calls and the browser executes.

The `@threadplane/middleware/langgraph` entrypoint is the LangGraph.js twin of the Python
`threadplane-middleware` package: it binds client-declared tool stubs onto your model and
routes client-tool-only turns to `END` so the browser executes them.

## How it works

When a browser client sends a tool catalog (`{ name, description, parameters }` objects)
along with a run request, the graph exposes those tools to the model and routes their calls
back to the browser instead of executing them server-side. The browser executes the call and
re-runs the graph with a `ToolMessage` carrying the result.

The catalog is read from `state.tools`, falling back to `state.client_tools` if `tools` is
absent.

## Installation

```bash
npm install @threadplane/middleware
# peer deps:
npm install @langchain/core @langchain/langgraph
```

## Usage

```ts
import { Annotation, MessagesAnnotation, StateGraph, END } from '@langchain/langgraph';
import { ChatOpenAI } from '@langchain/openai';
import {
bindClientTools,
clientToolsChannel,
clientToolsRouter,
} from '@threadplane/middleware/langgraph';

// Declare the client-tools state channels (tools + client_tools) in one line.
const State = Annotation.Root({ ...MessagesAnnotation.spec, ...clientToolsChannel() });

const SERVER_TOOLS: unknown[] = []; // your server-owned tools (if any)
const baseLlm = new ChatOpenAI({ model: 'gpt-4o-mini' });

async function agent(state: typeof State.State) {
// Call bindClientTools per-run inside the node — the client catalog arrives
// in state and may differ between runs.
const llm = bindClientTools(baseLlm, SERVER_TOOLS, state);
const response = await llm.invoke(state.messages);
return { messages: [response] };
}

const graph = new StateGraph(State)
.addNode('agent', agent)
.addEdge('__start__', 'agent')
// clientToolsRouter binds the server tool names once; pass [] when there are none.
.addConditionalEdges('agent', clientToolsRouter([]), ['tools', END])
.compile();
```

### What happens with a client tool call

1. The model emits a tool call whose name matches a client-declared tool.
2. `clientToolsRouter` (via `routeAfterAgent`) returns `"__end__"` — the run ends.
3. The browser receives the partial output, executes the tool locally, and re-runs the graph
with a `ToolMessage` containing the result.
4. The model continues from there as if it had called a server tool.

A turn that mixes a server tool call and a client tool call routes to the **server**
destination first (the server tool runs; the client call surfaces on a later turn).

### Lower-level helpers

```ts
import {
clientToolSpecs, // → OpenAI function-tool objects for model.bindTools
clientToolNames, // → Set<string> of client tool names
hasClientToolCall, // → boolean
hasServerToolCall, // → boolean (takes serverToolNames)
lastMessage, // → the last message from state.messages
routeAfterAgent, // → routing string (takes serverToolNames)
} from '@threadplane/middleware/langgraph';
```

## Peer dependencies

`@langchain/core` and `@langchain/langgraph`. The package has no runtime dependencies of its
own.
2 changes: 2 additions & 0 deletions libs/middleware/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import baseConfig from '../../eslint.config.mjs';
export default [...baseConfig, { files: ['**/*.ts'] }];
21 changes: 21 additions & 0 deletions libs/middleware/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "@threadplane/middleware",
"version": "0.0.1",
"description": "Backend middleware for the Threadplane client-tools capability. The /langgraph entrypoint targets LangGraph.js.",
"keywords": ["langgraph", "agent", "client-tools", "middleware", "threadplane"],
"license": "MIT",
"type": "module",
"sideEffects": false,
"publishConfig": { "access": "public" },
"repository": { "type": "git", "url": "https://github.com/cacheplane/angular-agent-framework.git", "directory": "libs/middleware" },
"homepage": "https://github.com/cacheplane/angular-agent-framework#readme",
"bugs": { "url": "https://github.com/cacheplane/angular-agent-framework/issues" },
"exports": {
"./langgraph": { "types": "./src/langgraph/index.d.ts", "default": "./src/langgraph/index.js" },
"./README.md": "./README.md"
},
"peerDependencies": {
"@langchain/core": "^1.0.0",
"@langchain/langgraph": "^1.0.0"
}
}
21 changes: 21 additions & 0 deletions libs/middleware/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "middleware",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/middleware/src",
"projectType": "library",
"tags": ["type:lib", "scope:library", "scope:shared"],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{workspaceRoot}/dist/libs/middleware"],
"options": {
"outputPath": "dist/libs/middleware",
"main": "libs/middleware/src/langgraph/index.ts",
"tsConfig": "libs/middleware/tsconfig.lib.json",
"assets": ["libs/middleware/README.md", "libs/middleware/package.json"]
}
},
"test": { "executor": "@nx/vitest:test", "options": { "configFile": "libs/middleware/vite.config.mts" } },
"lint": { "executor": "@nx/eslint:lint" }
}
}
53 changes: 53 additions & 0 deletions libs/middleware/src/integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: MIT
import { describe, it, expect } from 'vitest';
import { Annotation, MessagesAnnotation, StateGraph, END } from '@langchain/langgraph';
import { AIMessage, ToolMessage, HumanMessage } from '@langchain/core/messages';
import { bindClientTools, clientToolsChannel, clientToolsRouter } from './langgraph';

// A scripted fake chat model exposing the bindTools + invoke surface the graph uses.
class FakeModel {
bound: unknown[] = [];
private turn = 0;
bindTools(tools: unknown[]) { this.bound = tools; return this; }
async invoke(_messages: unknown[]) {
this.turn += 1;
if (this.turn === 1) {
return new AIMessage({ content: '', tool_calls: [{ name: 'get_weather', args: { city: 'SF' }, id: 'call_1' }] });
}
return new AIMessage({ content: 'It is 65F in SF.' });
}
}

const State = Annotation.Root({ ...MessagesAnnotation.spec, ...clientToolsChannel() });

function buildGraph(model: FakeModel) {
const agent = async (state: typeof State.State) => {
const bound = bindClientTools(model, [], state);
const res = await (bound as FakeModel).invoke(state.messages);
return { messages: [res] };
};
return new StateGraph(State)
.addNode('agent', agent)
.addEdge('__start__', 'agent')
.addConditionalEdges('agent', (s) => clientToolsRouter([])(s), [END])
.compile();
}

describe('client-tools loop (in-process)', () => {
it('binds the client stub, ends on the client call, then continues after a ToolMessage', async () => {
const model = new FakeModel();
const graph = buildGraph(model);
const tools = [{ name: 'get_weather', description: 'Weather', parameters: { type: 'object' } }];

const r1 = await graph.invoke({ messages: [new HumanMessage('weather in SF?')], tools });
const last1 = r1.messages[r1.messages.length - 1] as AIMessage;
expect(last1.tool_calls?.[0]?.name).toBe('get_weather');
expect((model.bound[0] as { function: { name: string } }).function.name).toBe('get_weather');

const r2 = await graph.invoke({
messages: [...r1.messages, new ToolMessage({ content: '65F', tool_call_id: 'call_1' })],
tools,
});
expect((r2.messages[r2.messages.length - 1] as AIMessage).content).toBe('It is 65F in SF.');
});
});
142 changes: 142 additions & 0 deletions libs/middleware/src/langgraph.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: MIT
import { describe, it, expect } from 'vitest';
import { clientToolSpecs, clientToolNames } from './langgraph/middleware';

const WEATHER = { name: 'get_weather', description: 'Weather', parameters: { type: 'object' } };

describe('clientToolSpecs', () => {
it('wraps each catalog entry as an OpenAI function tool', () => {
expect(clientToolSpecs({ messages: [], tools: [WEATHER] })).toEqual([
{ type: 'function', function: { name: 'get_weather', description: 'Weather', parameters: { type: 'object' } } },
]);
});
it('falls back to client_tools when tools is absent', () => {
expect(clientToolSpecs({ messages: [], client_tools: [WEATHER] })).toHaveLength(1);
});
it('defaults missing description/parameters and drops nameless entries', () => {
const specs = clientToolSpecs({ messages: [], tools: [{ name: 'x' } as never, { description: 'no name' } as never] });
expect(specs).toEqual([{ type: 'function', function: { name: 'x', description: '', parameters: {} } }]);
});
it('returns [] for empty state', () => {
expect(clientToolSpecs({ messages: [] })).toEqual([]);
});
});

describe('clientToolNames', () => {
it('returns the set of catalog names', () => {
expect(clientToolNames({ messages: [], tools: [WEATHER] })).toEqual(new Set(['get_weather']));
});
});

import { lastMessage, hasClientToolCall, hasServerToolCall } from './langgraph/middleware';
import { AIMessage, HumanMessage } from '@langchain/core/messages';

const stateWith = (toolCalls: { name: string }[]) => ({
messages: [new HumanMessage('hi'), new AIMessage({ content: '', tool_calls: toolCalls.map((c) => ({ name: c.name, args: {}, id: c.name })) })],
tools: [{ name: 'get_weather', description: '', parameters: {} }],
});

describe('lastMessage', () => {
it('returns the last message or undefined', () => {
expect(lastMessage({ messages: [] })).toBeUndefined();
expect(lastMessage({ messages: [new HumanMessage('a'), new HumanMessage('b')] })?.content).toBe('b');
});
});

describe('hasClientToolCall', () => {
it('true when the last AI message calls a client tool', () => {
expect(hasClientToolCall(stateWith([{ name: 'get_weather' }]))).toBe(true);
});
it('false when the last AI message calls only non-client tools', () => {
expect(hasClientToolCall(stateWith([{ name: 'search' }]))).toBe(false);
});
it('false when there are no tool calls', () => {
expect(hasClientToolCall(stateWith([]))).toBe(false);
});
});

describe('hasServerToolCall', () => {
it('true when a call name is in serverToolNames', () => {
expect(hasServerToolCall(stateWith([{ name: 'search' }]), ['search'])).toBe(true);
});
it('true when a call name is unknown (not a client tool)', () => {
expect(hasServerToolCall(stateWith([{ name: 'mystery' }]), [])).toBe(true);
});
it('false when the only call is a known client tool', () => {
expect(hasServerToolCall(stateWith([{ name: 'get_weather' }]), [])).toBe(false);
});
});

import { bindClientTools, routeAfterAgent } from './langgraph/middleware';
import { clientToolsChannel } from './langgraph/channel';
import { Annotation, MessagesAnnotation } from '@langchain/langgraph';

describe('clientToolsChannel', () => {
it('produces tools + client_tools channels usable in Annotation.Root', () => {
const frag = clientToolsChannel();
expect(Object.keys(frag).sort()).toEqual(['client_tools', 'tools']);
const State = Annotation.Root({ ...MessagesAnnotation.spec, ...frag });
expect(State.spec).toHaveProperty('tools');
expect(State.spec).toHaveProperty('client_tools');
});
});

describe('bindClientTools', () => {
it('binds server tools then client stubs (server first), calling bindTools once', () => {
const calls: unknown[][] = [];
const fake = { bindTools: (tools: unknown[]) => { calls.push(tools); return 'BOUND'; } };
const SERVER = { name: 'search' };
const result = bindClientTools(fake as never, [SERVER as never], { messages: [], tools: [{ name: 'get_weather', description: '', parameters: {} }] });
expect(result).toBe('BOUND');
expect(calls).toHaveLength(1);
expect(calls[0][0]).toBe(SERVER);
expect((calls[0][1] as { function: { name: string } }).function.name).toBe('get_weather');
});
it('binds only server tools when there is no client catalog', () => {
let bound: unknown[] = [];
const fake = { bindTools: (tools: unknown[]) => { bound = tools; return fake; } };
bindClientTools(fake as never, [{ name: 'search' } as never], { messages: [] });
expect(bound).toHaveLength(1);
});
});

describe('routeAfterAgent', () => {
const st = (names: string[]) => ({
messages: [new AIMessage({ content: '', tool_calls: names.map((n) => ({ name: n, args: {}, id: n })) })],
tools: [{ name: 'get_weather', description: '', parameters: {} }],
});
it('routes a server tool call to the tools node', () => {
expect(routeAfterAgent(st(['search']), ['search'])).toBe('tools');
});
it('routes a client-only tool call to END', () => {
expect(routeAfterAgent(st(['get_weather']), [])).toBe('__end__');
});
it('routes no tool calls to END', () => {
expect(routeAfterAgent(st([]), [])).toBe('__end__');
});
it('routes a mixed call to the server (precedence)', () => {
expect(routeAfterAgent(st(['get_weather', 'search']), ['search'])).toBe('tools');
});
it('honors custom node names', () => {
expect(routeAfterAgent(st(['search']), ['search'], { toolsNode: 'act' })).toBe('act');
expect(routeAfterAgent(st([]), [], { end: 'DONE' })).toBe('DONE');
});
});

import { clientToolsRouter } from './langgraph/router';

describe('clientToolsRouter', () => {
const st = (names: string[]) => ({
messages: [new AIMessage({ content: '', tool_calls: names.map((n) => ({ name: n, args: {}, id: n })) })],
tools: [{ name: 'get_weather', description: '', parameters: {} }],
});
it('returns a callback that routes via routeAfterAgent with bound serverToolNames', () => {
const route = clientToolsRouter(['search']);
expect(route(st(['search']))).toBe('tools');
expect(route(st(['get_weather']))).toBe('__end__');
});
it('honors custom node names', () => {
const route = clientToolsRouter([], { end: 'DONE' });
expect(route(st([]))).toBe('DONE');
});
});
18 changes: 18 additions & 0 deletions libs/middleware/src/langgraph/channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: MIT
import { Annotation } from '@langchain/langgraph';
import type { ClientToolSpec } from './types';

/**
* State channels for the client-tools catalog. Spread into Annotation.Root so a graph
* declares the `tools` (primary) and `client_tools` (fallback) slices in one line:
*
* const State = Annotation.Root({ ...MessagesAnnotation.spec, ...clientToolsChannel() });
*
* Both are last-value-wins channels (the catalog is replaced per run, not accumulated).
*/
export function clientToolsChannel() {
return {
tools: Annotation<ClientToolSpec[] | undefined>(),
client_tools: Annotation<ClientToolSpec[] | undefined>(),
};
}
14 changes: 14 additions & 0 deletions libs/middleware/src/langgraph/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: MIT
export type { ClientToolSpec, ClientToolsState, OpenAIFunctionTool, BaseMessage } from './types';
export {
clientToolSpecs,
clientToolNames,
lastMessage,
hasClientToolCall,
hasServerToolCall,
bindClientTools,
routeAfterAgent,
type BindableModel,
} from './middleware';
export { clientToolsChannel } from './channel';
export { clientToolsRouter } from './router';
Loading
Loading