From 9987b4600d17dd6ce2709ce368889f77bc1fda70 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 15 Jun 2026 11:59:14 -0700 Subject: [PATCH] docs(middleware): spec (amended) + Plan A (py rename) + Plan B (js package) Spec amended for repo reality: JS package lives at libs/middleware built with @nx/js:tsc/@nx/vitest (no tsup in repo), mirroring libs/telemetry; API signatures reconciled to the Python source (serverToolNames params). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-15-threadplane-middleware-langgraph-js.md | 998 ++++++++++++++++++ ...15-threadplane-middleware-python-rename.md | 558 ++++++++++ ...eadplane-middleware-langgraph-js-design.md | 298 ++++++ 3 files changed, 1854 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-threadplane-middleware-langgraph-js.md create mode 100644 docs/superpowers/plans/2026-06-15-threadplane-middleware-python-rename.md create mode 100644 docs/superpowers/specs/2026-06-15-threadplane-middleware-langgraph-js-design.md diff --git a/docs/superpowers/plans/2026-06-15-threadplane-middleware-langgraph-js.md b/docs/superpowers/plans/2026-06-15-threadplane-middleware-langgraph-js.md new file mode 100644 index 00000000..ff45e297 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-threadplane-middleware-langgraph-js.md @@ -0,0 +1,998 @@ +# @threadplane/middleware/langgraph (JS package) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Subagents follow superpowers:test-driven-development for each function. + +**Goal:** Build `@threadplane/middleware` (npm), imported as `@threadplane/middleware/langgraph` — the TypeScript/LangGraph.js twin of the Python `threadplane-middleware`, so a LangGraph.js backend supports the `@threadplane/chat` client-tools capability. + +**Architecture:** Plan B of the two-plan split. A new Nx library `libs/middleware` built with `@nx/js:tsc` and tested with `@nx/vitest:test`, mirroring `libs/telemetry`. The public surface is the single subpath export `./langgraph`. It 1:1-mirrors the Python module's seven functions plus two idiomatic LangGraph.js extras (an `Annotation` channel fragment and a conditional-edge factory). `@langchain/core` + `@langchain/langgraph` are peer dependencies. Verification = vitest units (mirroring the Python suite) + an in-process real-`StateGraph` integration test + a TS demo server at `examples/ag-ui/node` live-smoked through the existing `examples/ag-ui/angular` itinerary frontend. Publishes on its own cadence via a staged `workflow_dispatch` npm workflow — NOT in the Angular `publishable` lockstep group. + +**Tech Stack:** TypeScript 5.9 (`@nx/js:tsc`), vitest 4 (`@nx/vitest:test`), `@langchain/core` ^1.1.33 (already in repo), `@langchain/langgraph`, `@ag-ui/langgraph` (^0.0.41, demo only), Nx 22.6, GitHub Actions, npm trusted publishing. + +--- + +## Spec & reference + +- Spec: `docs/superpowers/specs/2026-06-15-threadplane-middleware-langgraph-js-design.md` (sections "JS API surface", "Idiomatic LangGraph.js extras", "Verification ladder", "npm publishing"). +- **Behavior reference — the Python source this mirrors** (`packages/threadplane-middleware/src/threadplane/middleware/langgraph/middleware.py`). The seven functions and their exact semantics (catalog read with `tools`→`client_tools` fallback; OpenAI function wrapping; the server-vs-client precedence rule) are the contract. Every TS function below has its Python counterpart cited. +- **Build/test pattern reference:** `libs/telemetry/{project.json,package.json,tsconfig.json,tsconfig.lib.json,tsconfig.spec.json,vite.config.mts}` — copy this structure. + +## File map + +``` +libs/middleware/ + package.json # @threadplane/middleware, type:module, exports ./langgraph, peerDeps + project.json # @nx/js:tsc build:node + @nx/vitest:test + @nx/eslint:lint + tsconfig.json # extends ../../tsconfig.base.json + tsconfig.lib.json # declaration:true, emitDeclarationOnly:false, include src/langgraph + tsconfig.spec.json # types: vitest/globals + node + vite.config.mts # @nx/vitest, environment node, globals true + eslint.config.mjs # extends root (copy libs/telemetry/eslint.config.mjs) + README.md + src/ + langgraph/ + index.ts # re-exports the public surface + types.ts # ClientToolSpec, ClientToolsState, OpenAIFunctionTool + middleware.ts # clientToolSpecs, clientToolNames, lastMessage, hasClientToolCall, + # hasServerToolCall, bindClientTools, routeAfterAgent + channel.ts # clientToolsChannel() + router.ts # clientToolsRouter() + langgraph.spec.ts # unit tests (mirror Python test_middleware.py) + integration.spec.ts # in-process real StateGraph + fake chat model +examples/ag-ui/node/ # demo server (Task 10) +.github/workflows/publish-middleware-npm.yml # staged npm publish (Task 12) +``` + +## Root dependency additions (done in Task 1) + +- `package.json` root `devDependencies`: `@langchain/langgraph` (integration test + peer-satisfaction), `@ag-ui/langgraph` (^0.0.41, demo server). `@langchain/core` is already present (^1.1.33). +- `libs/middleware/package.json` `peerDependencies`: `@langchain/core`, `@langchain/langgraph`. + +> **Lockfile caution (known issue):** installing on macOS can drop the Linux `@next/swc-*` optional-dep entries from `package-lock.json`, breaking CI. Task 1 verifies the lockfile diff and restores any dropped platform bindings surgically. Do NOT blindly regenerate the lockfile. + +--- + +## Task 0: Branch + +- [ ] **Step 1: Create the branch from latest main** + +```bash +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/quirky-haslett-d443a4 +git fetch origin +git checkout -b claude/middleware-js origin/main +``` + +--- + +## Task 1: Scaffold `libs/middleware` + dependencies + +**Files:** create the whole `libs/middleware/` config skeleton (no source logic yet — that's TDD'd in later tasks). Modify root `package.json` + `package-lock.json`. + +- [ ] **Step 1: Create the package manifest** + +`libs/middleware/package.json`: +```json +{ + "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": "./langgraph/index.d.ts", + "default": "./langgraph/index.js" + }, + "./README.md": "./README.md" + }, + "peerDependencies": { + "@langchain/core": "^1.0.0", + "@langchain/langgraph": "^1.0.0 || ^0.4.0 || ^0.3.0" + } +} +``` +> The `exports` paths are relative to the published package root (`dist/libs/middleware`), where `@nx/js:tsc` emits `langgraph/index.js` + `langgraph/index.d.ts`. Confirm the exact `@langchain/langgraph` version range in Step 6 against what actually installs, and tighten the peer range to match (the `||` list is a starting guess). + +- [ ] **Step 2: Create the Nx project.json (mirror libs/telemetry's build:node)** + +`libs/middleware/project.json`: +```json +{ + "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" } + } +} +``` +> `main` points at `src/langgraph/index.ts`; `@nx/js:tsc` emits to `dist/libs/middleware/langgraph/index.js` preserving the `langgraph/` directory (because `rootDir` is `src`). Verify the emitted path in Step 7 and adjust `exports` if tsc flattens it. + +- [ ] **Step 3: Create the tsconfigs (copy libs/telemetry, adjust includes)** + +`libs/middleware/tsconfig.json`: +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "target": "es2022", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "lib": ["es2022"] + }, + "include": [] +} +``` + +`libs/middleware/tsconfig.lib.json`: +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "rootDir": "src", + "declaration": true, + "emitDeclarationOnly": false + }, + "include": ["src/langgraph/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} +``` + +`libs/middleware/tsconfig.spec.json`: +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": false, + "types": ["vitest/globals", "node"] + }, + "include": ["src/**/*.spec.ts", "src/**/*.ts"] +} +``` + +- [ ] **Step 4: Create the vitest + eslint configs** + +`libs/middleware/vite.config.mts`: +```ts +import { defineConfig } from 'vite'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + plugins: [nxViteTsPaths()], + test: { + environment: 'node', + globals: true, + include: ['src/**/*.spec.ts'], + }, +}); +``` + +`libs/middleware/eslint.config.mjs`: copy `libs/telemetry/eslint.config.mjs` verbatim (it extends the root flat config and sets the project boundary; no per-lib edits needed). Read that file and reproduce it. + +- [ ] **Step 5: Create a placeholder README + an empty index so the project resolves** + +`libs/middleware/README.md`: +```markdown +# @threadplane/middleware + +Backend middleware for the Threadplane client-tools capability. + +## `@threadplane/middleware/langgraph` + +LangGraph.js helpers that bind frontend-declared client tools onto the model and route +client-tool-only turns to `END` so the browser executes them. See the +[client-tools guide](https://github.com/cacheplane/angular-agent-framework). +``` + +`libs/middleware/src/langgraph/index.ts` (temporary — replaced as functions land): +```ts +// SPDX-License-Identifier: MIT +export {}; +``` + +- [ ] **Step 6: Add the dependencies (carefully)** + +Add `@langchain/langgraph` and `@ag-ui/langgraph` to the ROOT `package.json` `devDependencies` (alphabetically, next to the existing `@langchain/core`). Then install: +```bash +npm install +``` +Immediately check the lockfile did NOT drop Linux platform bindings: +```bash +git diff package-lock.json | grep -E "^-.*swc-linux|^-.*@next/swc" || echo "ok: no linux bindings dropped" +``` +If that prints removed lines (`-` entries for `@next/swc-linux-*`), restore them: `git checkout package-lock.json`, then re-add ONLY the two new deps by hand-editing `package.json` and running `npm install --package-lock-only` — re-check until the grep prints `ok`. (Known macOS issue: a full install can strip Linux optional deps that CI's `npm ci` needs.) + +- [ ] **Step 7: Verify the project is recognized and builds empty** + +```bash +npx nx show project middleware --json | head -c 200; echo +npx nx build middleware +``` +Expected: `nx show project` prints the project graph entry; `nx build` succeeds and emits `dist/libs/middleware/langgraph/index.js` + `dist/libs/middleware/package.json`. Confirm the emitted path: +```bash +ls dist/libs/middleware/langgraph/index.js dist/libs/middleware/package.json +``` +If tsc emitted to a different path (e.g. flattened to `dist/libs/middleware/index.js`), fix `rootDir`/`main` and the package.json `exports` to match, and rebuild. + +- [ ] **Step 8: Commit** + +```bash +git add libs/middleware package.json package-lock.json +git commit -m "feat(middleware): scaffold libs/middleware (@threadplane/middleware) Nx tsc/vitest package" +``` + +--- + +## Task 2: types + `clientToolSpecs` + `clientToolNames` (TDD) + +**Mirrors Python** `_catalog`, `client_tool_specs`, `client_tool_names`. + +**Files:** Create `src/langgraph/types.ts`, `src/langgraph/middleware.ts`, `src/langgraph.spec.ts`. + +- [ ] **Step 1: Write the failing tests** + +`libs/middleware/src/langgraph.spec.ts`: +```ts +// 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'])); + }); +}); +``` + +- [ ] **Step 2: Run — verify it fails** + +```bash +npx nx test middleware +``` +Expected: FAIL (`clientToolSpecs is not a function` / cannot find module). + +- [ ] **Step 3: Implement types + the two functions** + +`libs/middleware/src/langgraph/types.ts`: +```ts +// SPDX-License-Identifier: MIT +import type { BaseMessage } from '@langchain/core/messages'; + +/** A frontend-declared client tool: name + description + JSON-Schema parameters. */ +export interface ClientToolSpec { + name: string; + description?: string; + parameters?: Record; +} + +/** The explicit OpenAI function-tool shape accepted by ChatModel.bindTools across versions. */ +export interface OpenAIFunctionTool { + type: 'function'; + function: { name: string; description: string; parameters: Record }; +} + +/** The slice of graph state this middleware reads. */ +export interface ClientToolsState { + messages: BaseMessage[]; + /** Primary channel — AG-UI/LangGraph merges RunAgentInput.tools here. */ + tools?: ClientToolSpec[]; + /** Fallback channel — the raw run input key. */ + client_tools?: ClientToolSpec[]; +} + +export type { BaseMessage }; +``` + +`libs/middleware/src/langgraph/middleware.ts`: +```ts +// SPDX-License-Identifier: MIT +import type { ClientToolSpec, ClientToolsState, OpenAIFunctionTool } from './types'; + +/** Read the catalog from state.tools, falling back to state.client_tools; drop nameless. */ +function catalog(state: ClientToolsState): ClientToolSpec[] { + const raw = state.tools && state.tools.length > 0 ? state.tools : state.client_tools; + return (raw ?? []).filter((t): t is ClientToolSpec => !!t && typeof t === 'object' && !!t.name); +} + +/** The client catalog as OpenAI function-tool dicts for `model.bindTools`. */ +export function clientToolSpecs(state: ClientToolsState): OpenAIFunctionTool[] { + return catalog(state).map((t) => ({ + type: 'function', + function: { name: t.name, description: t.description ?? '', parameters: t.parameters ?? {} }, + })); +} + +/** The set of tool names declared by the client in this run. */ +export function clientToolNames(state: ClientToolsState): Set { + return new Set(catalog(state).map((t) => t.name)); +} +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +npx nx test middleware +``` +Expected: PASS (all clientToolSpecs/clientToolNames tests green). + +- [ ] **Step 5: Commit** + +```bash +git add libs/middleware/src +git commit -m "feat(middleware): clientToolSpecs + clientToolNames (mirror python catalog)" +``` + +--- + +## Task 3: `lastMessage` + `hasClientToolCall` + `hasServerToolCall` (TDD) + +**Mirrors Python** `_tool_calls`, `_call_name`, `last_message`, `has_client_tool_call`, `has_server_tool_call`. + +**Files:** append to `src/langgraph.spec.ts` and `src/langgraph/middleware.ts`. + +- [ ] **Step 1: Write the failing tests** + +Append to `libs/middleware/src/langgraph.spec.ts`: +```ts +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); + }); +}); +``` + +- [ ] **Step 2: Run — verify fail** + +```bash +npx nx test middleware +``` +Expected: FAIL (`lastMessage`/`hasClientToolCall`/`hasServerToolCall` not exported). + +- [ ] **Step 3: Implement** + +Append to `libs/middleware/src/langgraph/middleware.ts`: +```ts +import type { BaseMessage } from './types'; + +interface ToolCallLike { + name?: string; + function?: { name?: string }; +} + +function toolCalls(message: unknown): ToolCallLike[] { + const tc = (message as { tool_calls?: unknown } | null)?.tool_calls; + return Array.isArray(tc) ? (tc as ToolCallLike[]) : []; +} + +function callName(call: ToolCallLike): string | undefined { + return call.name ?? call.function?.name; +} + +/** The last message from state.messages, or undefined. */ +export function lastMessage(state: ClientToolsState): BaseMessage | undefined { + const msgs = state.messages ?? []; + return msgs.length ? msgs[msgs.length - 1] : undefined; +} + +/** True if the last message calls at least one client tool. */ +export function hasClientToolCall(state: ClientToolsState): boolean { + const names = clientToolNames(state); + return toolCalls(lastMessage(state)).some((c) => { + const n = callName(c); + return n !== undefined && names.has(n); + }); +} + +/** + * True if the last message calls at least one server (non-client) tool. + * A call is server-side when its name is in serverToolNames OR is not a known + * client tool (unknown tools are assumed server-side). + */ +export function hasServerToolCall(state: ClientToolsState, serverToolNames: Iterable): boolean { + const server = new Set(serverToolNames); + const client = clientToolNames(state); + return toolCalls(lastMessage(state)).some((c) => { + const n = callName(c); + return n !== undefined && (server.has(n) || !client.has(n)); + }); +} +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +npx nx test middleware +``` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/middleware/src +git commit -m "feat(middleware): lastMessage + client/server tool-call predicates" +``` + +--- + +## Task 4: `bindClientTools` + `routeAfterAgent` + index export (TDD) + +**Mirrors Python** `bind_client_tools`, `route_after_agent`. + +**Files:** append to spec + middleware.ts; write `src/langgraph/index.ts`. + +- [ ] **Step 1: Write the failing tests** + +Append to `libs/middleware/src/langgraph.spec.ts`: +```ts +import { bindClientTools, routeAfterAgent } from './langgraph/middleware'; + +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); // server tool first + expect((calls[0][1] as { function: { name: string } }).function.name).toBe('get_weather'); // client stub follows + }); + 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'); + }); +}); +``` + +- [ ] **Step 2: Run — verify fail** + +```bash +npx nx test middleware +``` +Expected: FAIL. + +- [ ] **Step 3: Implement bindClientTools + routeAfterAgent** + +Append to `libs/middleware/src/langgraph/middleware.ts`: +```ts +/** A chat model that can bind tools (the LangChain `Runnable.bindTools` surface). */ +export interface BindableModel { + bindTools(tools: unknown[], kwargs?: unknown): unknown; +} + +/** + * Bind server tools + the client catalog stubs onto `llm`. Call this INSIDE the + * agent node (per-run) — the client catalog arrives in state and may differ per run. + */ +export function bindClientTools( + llm: M, + serverTools: unknown[], + state: ClientToolsState, +): ReturnType { + return llm.bindTools([...serverTools, ...clientToolSpecs(state)]) as ReturnType; +} + +/** + * Routing helper for a LangGraph conditional edge. Returns `toolsNode` when the last + * message has a server tool call (dispatch to the server ToolNode); otherwise `end` + * (client-only calls — the browser executes them — and no-tool-call turns both end). + */ +export function routeAfterAgent( + state: ClientToolsState, + serverToolNames: Iterable, + opts?: { toolsNode?: string; end?: string }, +): string { + const toolsNode = opts?.toolsNode ?? 'tools'; + const end = opts?.end ?? '__end__'; + return hasServerToolCall(state, serverToolNames) ? toolsNode : end; +} +``` + +- [ ] **Step 4: Write the public index surface** + +Replace `libs/middleware/src/langgraph/index.ts`: +```ts +// SPDX-License-Identifier: MIT +export type { ClientToolSpec, ClientToolsState, OpenAIFunctionTool, BaseMessage } from './types'; +export { + clientToolSpecs, + clientToolNames, + lastMessage, + hasClientToolCall, + hasServerToolCall, + bindClientTools, + routeAfterAgent, + type BindableModel, +} from './middleware'; +// extras added in Tasks 5-6: +export { clientToolsChannel } from './channel'; +export { clientToolsRouter } from './router'; +``` +> NOTE: this imports `./channel` and `./router`, which don't exist until Tasks 5–6. To keep the tree compiling NOW, temporarily comment out the last two `export` lines, and uncomment them in Task 6 Step 4. (Mark this in the commit message.) + +- [ ] **Step 5: Run tests + typecheck-build** + +```bash +npx nx test middleware && npx nx build middleware +``` +Expected: tests PASS; build succeeds (with the two extra exports temporarily commented). + +- [ ] **Step 6: Commit** + +```bash +git add libs/middleware/src +git commit -m "feat(middleware): bindClientTools + routeAfterAgent + public index (extras pending)" +``` + +--- + +## Task 5: `clientToolsChannel` Annotation fragment (TDD) + +**Files:** `src/langgraph/channel.ts`; append to spec. + +- [ ] **Step 1: Write the failing test** + +Append to `libs/middleware/src/langgraph.spec.ts`: +```ts +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']); + // The fragment composes into a state annotation without throwing. + const State = Annotation.Root({ ...MessagesAnnotation.spec, ...frag }); + expect(State.spec).toHaveProperty('tools'); + expect(State.spec).toHaveProperty('client_tools'); + }); +}); +``` + +- [ ] **Step 2: Run — verify fail** + +```bash +npx nx test middleware +``` +Expected: FAIL (cannot find `./langgraph/channel`). + +- [ ] **Step 3: Implement** + +`libs/middleware/src/langgraph/channel.ts`: +```ts +// 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(), + client_tools: Annotation(), + }; +} +``` + +- [ ] **Step 4: Run — verify pass** + +```bash +npx nx test middleware +``` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/middleware/src +git commit -m "feat(middleware): clientToolsChannel Annotation fragment" +``` + +--- + +## Task 6: `clientToolsRouter` factory (TDD) + enable extras export + +**Files:** `src/langgraph/router.ts`; append to spec; uncomment index exports. + +- [ ] **Step 1: Write the failing test** + +Append to `libs/middleware/src/langgraph.spec.ts`: +```ts +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'); + }); +}); +``` + +- [ ] **Step 2: Run — verify fail** + +```bash +npx nx test middleware +``` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +`libs/middleware/src/langgraph/router.ts`: +```ts +// SPDX-License-Identifier: MIT +import { routeAfterAgent } from './middleware'; +import type { ClientToolsState } from './types'; + +/** + * A prebuilt conditional-edge callback. serverToolNames is bound once at construction; + * the returned function takes only state. + * + * graph.addConditionalEdges('agent', clientToolsRouter([]), ['tools', END]); + */ +export function clientToolsRouter( + serverToolNames: Iterable, + opts?: { toolsNode?: string; end?: string }, +): (state: ClientToolsState) => string { + const names = [...serverToolNames]; + return (state: ClientToolsState) => routeAfterAgent(state, names, opts); +} +``` + +- [ ] **Step 4: Enable the extras exports in index.ts** + +Uncomment the two `export` lines for `./channel` and `./router` in `libs/middleware/src/langgraph/index.ts` (added in Task 4 Step 4). + +- [ ] **Step 5: Run tests + build** + +```bash +npx nx test middleware && npx nx lint middleware && npx nx build middleware +``` +Expected: all tests PASS, lint clean, build emits `dist/libs/middleware/langgraph/{index,middleware,channel,router,types}.{js,d.ts}`. + +- [ ] **Step 6: Commit** + +```bash +git add libs/middleware/src +git commit -m "feat(middleware): clientToolsRouter factory + enable extras export" +``` + +--- + +## Task 7: In-process StateGraph integration test + +Proves the full loop with a real LangGraph.js graph + a scripted fake chat model: bind → client-only tool call → route to END → caller appends ToolMessage → re-invoke → final content. + +**Files:** `libs/middleware/src/integration.spec.ts`. + +- [ ] **Step 1: Write the integration test** + +`libs/middleware/src/integration.spec.ts`: +```ts +// 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) { + // First turn: call the client tool get_weather. + return new AIMessage({ content: '', tool_calls: [{ name: 'get_weather', args: { city: 'SF' }, id: 'call_1' }] }); + } + // Second turn (after the ToolMessage): summarize. + 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', clientToolsRouter([]), ['tools' as never, 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' } }]; + + // Run 1: the model calls the client tool; the graph ends (browser would execute it). + 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'); + // The client stub was bound onto the model. + expect((model.bound[0] as { function: { name: string } }).function.name).toBe('get_weather'); + + // Run 2: the frontend re-submits with the executed ToolMessage; the model summarizes. + 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.'); + }); +}); +``` + +- [ ] **Step 2: Run — verify pass** + +```bash +npx nx test middleware +``` +Expected: PASS (the integration test plus all units). If the LangGraph `addConditionalEdges` path-array typing rejects `'tools'` since no `tools` node exists, change the mapping to `[END]` and route only to END (there are no server tools in this test) — keep the assertion on the two-run loop. + +- [ ] **Step 3: Commit** + +```bash +git add libs/middleware/src/integration.spec.ts +git commit -m "test(middleware): in-process StateGraph client-tools loop integration" +``` + +--- + +## Task 8: README + final package verification + +- [ ] **Step 1: Flesh out the README with a usage example** + +Replace `libs/middleware/README.md` with install + a minimal graph example using `clientToolsChannel`, `bindClientTools`, and `clientToolsRouter` (mirror the structure of `packages/threadplane-middleware/README.md` but in TypeScript). Include the `@threadplane/middleware/langgraph` import path and the peer-dependency note (`@langchain/core`, `@langchain/langgraph`). + +- [ ] **Step 2: Full verification** + +```bash +npx nx lint middleware && npx nx test middleware && npx nx build middleware +node -e "const p=require('./dist/libs/middleware/package.json'); console.log(p.name, p.version, JSON.stringify(p.exports['./langgraph']))" +ls dist/libs/middleware/langgraph/index.d.ts +``` +Expected: lint clean, all tests pass, build green, the printed package.json has the `./langgraph` export, and the `.d.ts` exists. + +- [ ] **Step 3: Commit** + +```bash +git add libs/middleware/README.md +git commit -m "docs(middleware): README usage example" +``` + +--- + +## Task 9: Open PR for the JS package (core) + +- [ ] **Step 1: Push + PR** + +```bash +git push -u origin claude/middleware-js +gh pr create --base main --head claude/middleware-js \ + --title "feat(middleware): @threadplane/middleware/langgraph — LangGraph.js client-tools middleware" \ + --body "Plan B core: new Nx lib \`libs/middleware\` (npm \`@threadplane/middleware\`, import \`@threadplane/middleware/langgraph\`), the TS twin of \`threadplane-middleware\`. 1:1 mirror of the 7 Python functions + clientToolsChannel/clientToolsRouter extras. Vitest units (mirror the Python suite) + in-process StateGraph integration test. Built with @nx/js:tsc, excluded from the Angular publishable lockstep group. + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +gh pr merge --squash --auto claude/middleware-js +``` +> The demo server (Task 10) and npm publish workflow (Task 12) can ship in this same PR or a follow-up — see notes. If splitting, open them as a second PR off the merged main. + +--- + +## Task 10: Demo server `examples/ag-ui/node` (live-smoke harness) + +A minimal LangGraph.js + `@ag-ui/langgraph` server exposing `/agent`, used to live-smoke the middleware behind the existing Angular itinerary demo. + +**Files:** `examples/ag-ui/node/{package.json,tsconfig.json,src/graph.ts,src/server.ts,README.md}` (+ Nx `project.json` with a `serve` run-commands target if the repo's example-serving harness requires it — check `examples/ag-ui/python`'s nx wiring and mirror the registration). + +- [ ] **Step 1: Survey the existing examples/ag-ui wiring** + +Read `examples/ag-ui/python/src/{graph.py,server.py}` and `examples/ag-ui/angular/proxy.conf*.json` (or the dev-server proxy config) to learn the exact `/agent` contract, port, and how the Angular dev proxy targets the backend. Note the port the Angular app proxies `/agent` to. + +- [ ] **Step 2: Implement the graph** + +`examples/ag-ui/node/src/graph.ts` — a `StateGraph` using `Annotation.Root({ ...MessagesAnnotation.spec, ...clientToolsChannel() })`, an `agent` node that does `bindClientTools(new ChatOpenAI({ model: 'gpt-4o-mini', streaming: true }), [], state)` then `await bound.invoke([system, ...state.messages])`, and `addConditionalEdges('agent', clientToolsRouter([]), [END])`. Compile WITHOUT a checkpointer (the AG-UI server manages threads). Import the middleware from `@threadplane/middleware/langgraph`. + +- [ ] **Step 3: Implement the server** + +`examples/ag-ui/node/src/server.ts` — stand up the `@ag-ui/langgraph` agent over the compiled graph and serve `/agent` on the SAME port the Angular dev proxy expects (from Step 1). Read OPENAI_API_KEY from env. + +- [ ] **Step 4: Register + typecheck** + +Add `examples/ag-ui/node/package.json` + `tsconfig.json` (mirror an existing TS example if one exists; else a minimal `tsc --noEmit` typecheck). Ensure `npx tsc -p examples/ag-ui/node/tsconfig.json --noEmit` is clean. + +- [ ] **Step 5: Commit** + +```bash +git add examples/ag-ui/node +git commit -m "feat(examples/ag-ui): node (LangGraph.js) backend using @threadplane/middleware/langgraph" +``` + +--- + +## Task 11: Live-LLM smoke (manual gate — per the standing rule) + +Not a CI test — the standing live-LLM-before-merge gate. + +- [ ] **Step 1: Serve the node backend + the existing Angular itinerary demo** + +Start `examples/ag-ui/node` with a real `OPENAI_API_KEY` (from repo-root `.env`) on the port the Angular proxy targets, and serve `examples/ag-ui/angular` pointed at it (the dev proxy already routes `/agent`; no frontend change). Free conflicting ports first; do NOT run the e2e suite against the same ports simultaneously. + +- [ ] **Step 2: Drive the three behaviors in Chrome** + +Confirm against the real model: an `action` client tool (e.g. add_stop) mutates the panel and the run continues after the ToolMessage; a `view` renders; an `ask` resolves and freezes. Capture a screenshot. If a streaming-shape bug appears (the class the reducer fix addressed), fix before merge. + +- [ ] **Step 3: Record the smoke result in the PR** + +Comment the outcome (+ screenshot) on the PR. This task has no commit. + +--- + +## Task 12: Staged npm publish workflow + +**Files:** `.github/workflows/publish-middleware-npm.yml`. + +- [ ] **Step 1: Author the workflow (mirror publish-middleware-python.yml structure)** + +`workflow_dispatch` only, `dry_run` input default `true`; `permissions: id-token: write` (trusted publishing + provenance); Node 24 + npm 11+; steps: checkout → setup-node (registry npmjs) → `npm ci` → `npm i -g npm@latest` → `npx nx build middleware` → publish. Real publish: `npm publish dist/libs/middleware --provenance --access public`; dry-run: add `--dry-run`. Include the header comment documenting the first-publish bootstrap (a maintainer's local `npm publish` with a token creates the package; subsequent releases use OIDC) and the trusted-publisher setup note for npm. + +- [ ] **Step 2: Validate the workflow YAML** + +```bash +npx tsx -e "1" 2>/dev/null; python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/publish-middleware-npm.yml')); print('yaml ok')" +``` +Expected: `yaml ok`. + +- [ ] **Step 3: Commit (+ include in the PR)** + +```bash +git add .github/workflows/publish-middleware-npm.yml +git commit -m "ci(middleware): staged npm publish workflow (workflow_dispatch, dry-run default)" +git push +``` + +--- + +## Out-of-band (maintainer keystrokes, after merge) + +1. Bootstrap the first npm publish of `@threadplane/middleware@0.0.1` (local `npm publish` with a token creates the package; configure the npm trusted publisher for `publish-middleware-npm.yml`; thereafter dispatch the workflow). +2. Nothing couples this to the Python `threadplane-middleware` publish — they are independent. + +--- + +## Self-review notes + +- **Spec coverage:** layout `libs/middleware` (Task 1), 1:1 mirror of all 7 Python functions (Tasks 2–4), both extras `clientToolsChannel`/`clientToolsRouter` (Tasks 5–6), peer deps + no runtime deps (Task 1), in-process integration (Task 7), demo server at `examples/ag-ui/node` + reused frontend smoke (Tasks 10–11), staged npm workflow excluded from the lockstep group (Task 12). The `serverToolNames` parameter threads through `hasServerToolCall`/`routeAfterAgent`/`clientToolsRouter` consistently (matches the corrected spec). +- **Placeholders:** the only deferred specifics are (a) the exact `@langchain/langgraph` peer version range — pinned against the actual install in Task 1 Step 6; (b) the demo server's port/proxy contract — read from the existing `examples/ag-ui/python` wiring in Task 10 Step 1; (c) the emitted dist path — verified in Task 1 Step 7. Each is an explicit verify-and-adjust step, not a vague instruction. +- **Type consistency:** `ClientToolSpec`/`ClientToolsState`/`OpenAIFunctionTool`/`BindableModel` defined in Task 2, reused unchanged in Tasks 3–7. Function names match the spec exactly. +- **Risk — Annotation/StateGraph API drift:** the integration test (Task 7) exercises the real API; Step 2 includes a fallback if `addConditionalEdges` path typing rejects a non-existent `tools` node. diff --git a/docs/superpowers/plans/2026-06-15-threadplane-middleware-python-rename.md b/docs/superpowers/plans/2026-06-15-threadplane-middleware-python-rename.md new file mode 100644 index 00000000..b0bf9b47 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-threadplane-middleware-python-rename.md @@ -0,0 +1,558 @@ +# threadplane-middleware (Python clean-cut rename) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rename the published Python middleware `threadplane-client-tools` → `threadplane-middleware` with a vendor-first module path `threadplane.middleware.langgraph`, migrating all in-repo consumers, with zero backwards-compat shim and zero deploy breakage. + +**Architecture:** Plan A of the two-plan split from the spec (Plan B is the JS package). The middleware *logic is unchanged* — this is a package/module rename plus consumer migration. Safe sequencing is the crux: the deploy drift guard runs ONLY in the deploy workflows (triggered by changes under `deployments/**` or the generator scripts), never in PR CI. So **PR1** migrates the package + consumer code/pyproject via a `[tool.uv.sources]` path dependency (what `uv run`/pytest/smoke consume) but deliberately leaves every consumer `requirements.txt` and all of `deployments/**` untouched — deploys keep resolving the still-published, code-identical `threadplane-client-tools==0.0.1`, and no deploy workflow fires. After the maintainer publishes `threadplane-middleware 0.0.1` to PyPI, **PR2** flips pyproject path→published and regenerates `requirements.txt` + `deployments/**`, moving deploys onto the new package. + +**Tech Stack:** Python 3.10+, hatchling, uv, LangGraph/LangChain (peer of consumers), PyPI trusted publishing, Nx (smoke targets), GitHub Actions. + +--- + +## Spec + +`docs/superpowers/specs/2026-06-15-threadplane-middleware-langgraph-js-design.md` — sections "Python clean-cut rename", "Naming & home decisions", "Repository layout". + +## File map + +**Renamed package (`packages/threadplane-client-tools/` → `packages/threadplane-middleware/`):** +- `pyproject.toml` — `name = "threadplane-middleware"`; wheel `packages = ["src/threadplane"]` unchanged (still captures the whole namespace tree). +- `src/threadplane/client_tools/__init__.py` → `src/threadplane/middleware/langgraph/__init__.py` — re-exports unchanged; intermediate `threadplane/` and `threadplane/middleware/` dirs stay `__init__`-free (PEP 420). +- `src/threadplane/client_tools/middleware.py` → `src/threadplane/middleware/langgraph/middleware.py` — content byte-identical (no internal package imports). +- `tests/test_middleware.py` — import path updated. +- `README.md` — name + import strings updated. + +**Consumers (code + pyproject + lock only; NOT requirements.txt):** +- `examples/ag-ui/python/{src/graph.py,pyproject.toml,uv.lock}` +- `cockpit/ag-ui/client-tools/python/{src/graph.py,pyproject.toml,uv.lock}` +- `cockpit/langgraph/client-tools/python/{src/graph.py,pyproject.toml,uv.lock}` + +**Workflow:** `.github/workflows/publish-client-tools-python.yml` → `publish-middleware-python.yml`. + +**Docs:** `cockpit/ag-ui/client-tools/python/docs/guide.md`, `cockpit/langgraph/client-tools/python/docs/guide.md`, `packages/threadplane/README.md`. + +**Deferred to PR2 (post-publish):** all consumer `requirements.txt`, `deployments/ag-ui-dev/**`, `deployments/shared-dev/**`. + +--- + +## Task 0: Branch + +- [ ] **Step 1: Create the PR1 branch from latest main** + +```bash +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/quirky-haslett-d443a4 +git fetch origin +git checkout -b claude/py-middleware-rename origin/main +``` + +--- + +## Task 1: Rename the package directory + manifest + +**Files:** +- Move: `packages/threadplane-client-tools/` → `packages/threadplane-middleware/` +- Modify: `packages/threadplane-middleware/pyproject.toml` +- Modify: `packages/threadplane-middleware/README.md` + +- [ ] **Step 1: git mv the package directory** + +```bash +git mv packages/threadplane-client-tools packages/threadplane-middleware +``` + +- [ ] **Step 2: Rename the distribution in pyproject.toml** + +In `packages/threadplane-middleware/pyproject.toml`, change only the `name` line under `[project]`: + +```toml +name = "threadplane-middleware" +``` + +Leave `version = "0.0.1"`, `dependencies`, and `[tool.hatch.build.targets.wheel] packages = ["src/threadplane"]` unchanged — the wheel path already captures the whole `threadplane` namespace tree, so it stays correct after the module move in Task 2. + +- [ ] **Step 3: Update the package README** + +In `packages/threadplane-middleware/README.md`: +- Line 1 heading: `# threadplane-middleware` +- `pip install threadplane-client-tools` → `pip install threadplane-middleware` +- both `from threadplane.client_tools import …` snippets → `from threadplane.middleware.langgraph import …` + +- [ ] **Step 4: Commit** + +```bash +git add packages/threadplane-middleware +git commit -m "refactor(py): rename package dir threadplane-client-tools -> threadplane-middleware" +``` + +--- + +## Task 2: Move the source module to the vendor-first namespace path + +**Files:** +- Move: `src/threadplane/client_tools/` → `src/threadplane/middleware/langgraph/` (within `packages/threadplane-middleware/`) +- Modify: `packages/threadplane-middleware/src/threadplane/middleware/langgraph/__init__.py` +- Modify: `packages/threadplane-middleware/tests/test_middleware.py` + +- [ ] **Step 1: Create the new namespace path and move the module** + +```bash +cd packages/threadplane-middleware +mkdir -p src/threadplane/middleware +git mv src/threadplane/client_tools src/threadplane/middleware/langgraph +cd ../.. +``` + +Confirm there is NO `__init__.py` directly under `src/threadplane/` or `src/threadplane/middleware/` (PEP 420 namespace packages — only the `langgraph` leaf has one): + +```bash +ls packages/threadplane-middleware/src/threadplane/__init__.py 2>/dev/null && echo "REMOVE THIS" || echo "ok: no threadplane/__init__.py" +ls packages/threadplane-middleware/src/threadplane/middleware/__init__.py 2>/dev/null && echo "REMOVE THIS" || echo "ok: no middleware/__init__.py" +``` + +If either prints "REMOVE THIS", delete it: `git rm packages/threadplane-middleware/src/threadplane/__init__.py` (and/or the `middleware/__init__.py`). + +- [ ] **Step 2: Update the leaf __init__.py import + docstring** + +In `packages/threadplane-middleware/src/threadplane/middleware/langgraph/__init__.py`, change the docstring and the internal import; the `__all__` list is unchanged: + +```python +# SPDX-License-Identifier: MIT +"""threadplane-middleware — LangGraph middleware for client-declared tools.""" + +from threadplane.middleware.langgraph.middleware import ( + bind_client_tools, + client_tool_names, + client_tool_specs, + has_client_tool_call, + has_server_tool_call, + last_message, + route_after_agent, +) + +__all__ = [ + "bind_client_tools", + "client_tool_names", + "client_tool_specs", + "has_client_tool_call", + "has_server_tool_call", + "last_message", + "route_after_agent", +] +``` + +(`middleware.py` itself has no internal package imports — leave it byte-identical.) + +- [ ] **Step 3: Update the test import** + +In `packages/threadplane-middleware/tests/test_middleware.py`, line 2 docstring and line 5 import: + +```python +"""Tests for threadplane.middleware.langgraph.middleware — no LangChain import required.""" +import pytest + +from threadplane.middleware.langgraph.middleware import ( +``` + +(Leave the imported symbol list and all test bodies unchanged.) + +- [ ] **Step 4: Run the package tests — verify green at the new path** + +Run: +```bash +cd packages/threadplane-middleware +uv venv +uv pip install -e '.[test]' +uv run pytest -q +cd ../.. +``` +Expected: all tests PASS (same count as before the move). + +- [ ] **Step 5: Build the wheel — verify the namespace tree packages correctly** + +Run: +```bash +cd packages/threadplane-middleware && uv build && cd ../.. +``` +Expected: builds `dist/threadplane_middleware-0.0.1-*.whl` with no error. Confirm the module path is inside: +```bash +unzip -l packages/threadplane-middleware/dist/threadplane_middleware-0.0.1-*.whl | grep "threadplane/middleware/langgraph/middleware.py" +``` +Expected: one matching line. + +- [ ] **Step 6: Commit** + +```bash +git add packages/threadplane-middleware +git commit -m "refactor(py): move module to threadplane.middleware.langgraph namespace" +``` + +--- + +## Task 3: Migrate consumer — cockpit/ag-ui/client-tools/python + +**Files:** +- Modify: `cockpit/ag-ui/client-tools/python/src/graph.py:20` +- Modify: `cockpit/ag-ui/client-tools/python/pyproject.toml` +- Modify: `cockpit/ag-ui/client-tools/python/uv.lock` (regenerated) + +- [ ] **Step 1: Update the import in graph.py** + +`cockpit/ag-ui/client-tools/python/src/graph.py` line 20: + +```python +from threadplane.middleware.langgraph import bind_client_tools +``` + +- [ ] **Step 2: Update the dependency + add a path source in pyproject.toml** + +In `cockpit/ag-ui/client-tools/python/pyproject.toml`, change the dependency line: + +```toml + "threadplane-middleware>=0.0.1", +``` + +This consumer has no `[tool.uv]` table; append a new one at the end of the file: + +```toml +[tool.uv.sources] +threadplane-middleware = { path = "../../../../packages/threadplane-middleware", editable = true } +``` + +(Path is relative to `cockpit/ag-ui/client-tools/python/` — four levels up to the repo root.) + +- [ ] **Step 3: Regenerate the lock** + +Run: +```bash +cd cockpit/ag-ui/client-tools/python && uv lock && cd - +``` +Expected: `uv.lock` updated; `grep threadplane uv.lock` shows `threadplane-middleware` sourced from the editable path, and no `threadplane-client-tools` entry remains. + +- [ ] **Step 4: Verify the smoke target — import resolves and graph builds** + +Run: +```bash +npx nx run cockpit-ag-ui-client-tools-python:smoke +``` +Expected: exit 0 (the smoke imports `graph` and exercises the build). + +- [ ] **Step 5: Commit** + +```bash +git add cockpit/ag-ui/client-tools/python/src/graph.py cockpit/ag-ui/client-tools/python/pyproject.toml cockpit/ag-ui/client-tools/python/uv.lock +git commit -m "refactor(cockpit): ag-ui client-tools python imports threadplane.middleware.langgraph" +``` + +--- + +## Task 4: Migrate consumer — cockpit/langgraph/client-tools/python + +**Files:** +- Modify: `cockpit/langgraph/client-tools/python/src/graph.py:19` +- Modify: `cockpit/langgraph/client-tools/python/pyproject.toml` +- Modify: `cockpit/langgraph/client-tools/python/uv.lock` (regenerated) + +- [ ] **Step 1: Update the import in graph.py** + +`cockpit/langgraph/client-tools/python/src/graph.py` line 19: + +```python +from threadplane.middleware.langgraph import bind_client_tools +``` + +- [ ] **Step 2: Update the dependency + add a path source in pyproject.toml** + +Change the dependency line: + +```toml + "threadplane-middleware>=0.0.1", +``` + +This consumer already has a `[tool.uv]` table (with `dev-dependencies`). Add a SEPARATE `[tool.uv.sources]` table (do not nest it under `[tool.uv]` — place it as its own table, e.g. directly after the `[tool.uv]` block): + +```toml +[tool.uv.sources] +threadplane-middleware = { path = "../../../../packages/threadplane-middleware", editable = true } +``` + +- [ ] **Step 3: Regenerate the lock** + +Run: +```bash +cd cockpit/langgraph/client-tools/python && uv lock && cd - +``` +Expected: `threadplane-middleware` via editable path in `uv.lock`; no `threadplane-client-tools`. + +- [ ] **Step 4: Verify the smoke target** + +Run: +```bash +npx nx run cockpit-langgraph-client-tools-python:smoke +``` +Expected: exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add cockpit/langgraph/client-tools/python/src/graph.py cockpit/langgraph/client-tools/python/pyproject.toml cockpit/langgraph/client-tools/python/uv.lock +git commit -m "refactor(cockpit): langgraph client-tools python imports threadplane.middleware.langgraph" +``` + +--- + +## Task 5: Migrate consumer — examples/ag-ui/python + +**Files:** +- Modify: `examples/ag-ui/python/src/graph.py:45` +- Modify: `examples/ag-ui/python/pyproject.toml` +- Modify: `examples/ag-ui/python/uv.lock` (regenerated) + +- [ ] **Step 1: Update the import in graph.py** + +`examples/ag-ui/python/src/graph.py` line 45 (note: this consumer imports two symbols): + +```python +from threadplane.middleware.langgraph import bind_client_tools, client_tool_names +``` + +- [ ] **Step 2: Update the dependency + add a path source in pyproject.toml** + +Change the dependency line: + +```toml + "threadplane-middleware>=0.0.1", +``` + +This consumer has a `[tool.uv]` table (`dev-dependencies`). Add a separate `[tool.uv.sources]` table — note this path is only THREE levels up (`examples/ag-ui/python/`): + +```toml +[tool.uv.sources] +threadplane-middleware = { path = "../../../packages/threadplane-middleware", editable = true } +``` + +- [ ] **Step 3: Regenerate the lock** + +Run: +```bash +cd examples/ag-ui/python && uv lock && cd - +``` +Expected: `threadplane-middleware` via editable path; no `threadplane-client-tools`. + +- [ ] **Step 4: Verify the import resolves** + +Run: +```bash +cd examples/ag-ui/python && uv run python -c "from threadplane.middleware.langgraph import bind_client_tools, client_tool_names; print('ok')" && cd - +``` +Expected: prints `ok`. + +- [ ] **Step 5: Commit** + +```bash +git add examples/ag-ui/python/src/graph.py examples/ag-ui/python/pyproject.toml examples/ag-ui/python/uv.lock +git commit -m "refactor(examples): ag-ui python imports threadplane.middleware.langgraph" +``` + +--- + +## Task 6: Rename the publish workflow + +**Files:** +- Move: `.github/workflows/publish-client-tools-python.yml` → `.github/workflows/publish-middleware-python.yml` + +- [ ] **Step 1: git mv the workflow** + +```bash +git mv .github/workflows/publish-client-tools-python.yml .github/workflows/publish-middleware-python.yml +``` + +- [ ] **Step 2: Update the workflow contents** + +In `.github/workflows/publish-middleware-python.yml`, replace every `threadplane-client-tools` occurrence with `threadplane-middleware`. The substantive edits: +- header comment package name and the PyPI Trusted-Publisher setup note (project name + `Workflow: publish-middleware-python.yml`) +- `name: Publish threadplane-middleware (Python)` +- `concurrency.group: publish-middleware-python` +- the job `name:` line +- all three `working-directory: packages/threadplane-client-tools` → `working-directory: packages/threadplane-middleware` + +- [ ] **Step 3: Verify no stale references remain in the workflow** + +Run: +```bash +grep -n "threadplane-client-tools\|publish-client-tools" .github/workflows/publish-middleware-python.yml || echo "clean" +``` +Expected: `clean`. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/publish-middleware-python.yml +git commit -m "ci(py): rename publish workflow to publish-middleware-python" +``` + +--- + +## Task 7: Update docs + +**Files:** +- Modify: `cockpit/ag-ui/client-tools/python/docs/guide.md` +- Modify: `cockpit/langgraph/client-tools/python/docs/guide.md` +- Modify: `packages/threadplane/README.md` + +- [ ] **Step 1: Fix the ag-ui guide import references** + +In `cockpit/ag-ui/client-tools/python/docs/guide.md`, replace every `threadplane_client_tools` AND any `threadplane.client_tools` with `threadplane.middleware.langgraph` (the Python `import`/`from` module path). The `pip install` / distribution-name references become `threadplane-middleware`. Known sites: the `` block (~line 21), the backend `` prose (~line 133), and the code snippet (~line 145: `from threadplane.middleware.langgraph import bind_client_tools`). + +- [ ] **Step 2: Fix the langgraph guide references** + +In `cockpit/langgraph/client-tools/python/docs/guide.md`, apply the same replacements (module path → `threadplane.middleware.langgraph`, distribution → `threadplane-middleware`). + +- [ ] **Step 3: Update the threadplane namespace-root README** + +In `packages/threadplane/README.md`: +- the bullet `- \`threadplane-client-tools\` — importable as \`threadplane.client_tools\`` → `- \`threadplane-middleware\` — importable as \`threadplane.middleware.langgraph\`` +- `pip install threadplane-client-tools` → `pip install threadplane-middleware` + +- [ ] **Step 4: Commit** + +```bash +git add cockpit/ag-ui/client-tools/python/docs/guide.md cockpit/langgraph/client-tools/python/docs/guide.md packages/threadplane/README.md +git commit -m "docs(py): point client-tools guides + threadplane README at threadplane.middleware.langgraph" +``` + +--- + +## Task 8: Repo-wide grep gate + final verification + PR1 + +- [ ] **Step 1: Grep gate — no stale references outside venvs / dated docs** + +Run: +```bash +grep -rln "threadplane.client_tools\|threadplane_client_tools\|threadplane-client-tools" \ + --include="*.py" --include="*.toml" --include="*.md" --include="*.yml" --include="*.txt" . \ + | grep -v "/.venv/" | grep -v "node_modules" +``` +Expected matches and how to treat each: +- `deployments/ag-ui-dev/**`, `deployments/shared-dev/**`, and the three consumer `requirements.txt` files — **EXPECTED to still say `threadplane-client-tools==0.0.1`** (intentionally deferred to PR2; do NOT change them here). +- `docs/superpowers/plans/2026-06-11-*.md` and `docs/superpowers/specs/*` — historical dated records; leave as-is. +- Any OTHER file (source, active docs, workflow) — a miss; fix it and amend the relevant task's commit. + +- [ ] **Step 2: Re-run all three smoke/import checks together** + +Run: +```bash +npx nx run cockpit-ag-ui-client-tools-python:smoke +npx nx run cockpit-langgraph-client-tools-python:smoke +(cd examples/ag-ui/python && uv run python -c "from threadplane.middleware.langgraph import bind_client_tools, client_tool_names; print('ok')") +``` +Expected: two smoke exits 0, one `ok`. + +- [ ] **Step 3: Confirm deployments/ and consumer requirements.txt are untouched** + +Run: +```bash +git diff --name-only origin/main...HEAD | grep -E "deployments/|requirements.txt" || echo "clean: no deploy/requirements changes in PR1" +``` +Expected: `clean: no deploy/requirements changes in PR1`. + +- [ ] **Step 4: Push and open PR1** + +```bash +git push -u origin claude/py-middleware-rename +gh pr create --base main --head claude/py-middleware-rename \ + --title "refactor(py): rename threadplane-client-tools -> threadplane-middleware (.middleware.langgraph)" \ + --body "Plan A of the middleware spec. Clean-cut rename (no backwards-compat shim). Package dir + module path (\`threadplane.middleware.langgraph\`) + 3 consumers migrated via a transitional \`[tool.uv.sources]\` path dependency + publish workflow renamed + docs. + +Deploys are intentionally untouched: every consumer \`requirements.txt\` and all of \`deployments/**\` still pin the still-published, code-identical \`threadplane-client-tools==0.0.1\`, and no deploy workflow fires (the drift guard only runs under \`deployments/**\` / generator changes). After \`threadplane-middleware 0.0.1\` is published to PyPI, PR2 flips pyproject path->published and regenerates requirements + deploy configs. + +Verified: package pytest green at new path; wheel packages the namespace tree; all three consumers' smoke/import checks green; repo-wide grep clean except the deferred deploy/requirements pins. + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +gh pr merge --squash --auto claude/py-middleware-rename +``` + +--- + +## PR2 (post-publish — gated on the maintainer's PyPI publish; do NOT start until `threadplane-middleware 0.0.1` is live) + +Between PR1 merge and the publish, deploys keep working on `threadplane-client-tools==0.0.1`. After the maintainer dispatches `publish-middleware-python.yml` (dry-run, then real) and `threadplane-middleware 0.0.1` exists on PyPI: + +### Task 9: Flip consumers path→published + regenerate deploys + +**Files:** +- Modify (×3 consumers): remove `[tool.uv.sources]` block; regenerate `uv.lock` + `requirements.txt` +- Regenerate: `deployments/ag-ui-dev/**`, `deployments/shared-dev/**` + +- [ ] **Step 1: Branch** + +```bash +git fetch origin && git checkout -b claude/py-middleware-publish-flip origin/main +``` + +- [ ] **Step 2: Remove the path source from all three consumers** + +Delete the `[tool.uv.sources]` table (the `threadplane-middleware = { path = … }` block) from each of: +`examples/ag-ui/python/pyproject.toml`, `cockpit/ag-ui/client-tools/python/pyproject.toml`, `cockpit/langgraph/client-tools/python/pyproject.toml`. Leave the `threadplane-middleware>=0.0.1` dependency line. + +- [ ] **Step 3: Regenerate locks + exported requirements for each consumer** + +For each of the three consumer dirs run: +```bash +cd +uv lock +uv export --no-hashes --no-emit-project -o requirements.txt # match the existing export convention; see the file's header comment for exact flags +cd - +``` +Expected: `requirements.txt` now pins `threadplane-middleware==0.0.1` (PyPI), no path/file ref, no `threadplane-client-tools`. + +> If `uv export` flags differ from the committed files' header, copy the exact flags from the top-of-file comment in the current `requirements.txt` so the diff stays minimal. + +- [ ] **Step 4: Regenerate both deploy configs** + +```bash +npx tsx scripts/generate-ag-ui-deployment-config.ts +npx tsx scripts/generate-shared-deployment-config.ts +``` +Expected: `deployments/ag-ui-dev/**` and `deployments/shared-dev/**` regenerate; the vendored `requirements.txt` in each now pins `threadplane-middleware==0.0.1`. + +- [ ] **Step 5: Grep gate — zero `threadplane-client-tools` left anywhere active** + +```bash +grep -rln "threadplane-client-tools\|threadplane_client_tools\|threadplane.client_tools" \ + --include="*.py" --include="*.toml" --include="*.md" --include="*.yml" --include="*.txt" . \ + | grep -v "/.venv/" | grep -v "node_modules" | grep -v "docs/superpowers/" +``` +Expected: no output (only dated `docs/superpowers/` historical records may still reference the old name). + +- [ ] **Step 6: Verify smokes + deploy drift guards locally** + +```bash +npx nx run cockpit-ag-ui-client-tools-python:smoke +npx nx run cockpit-langgraph-client-tools-python:smoke +git diff --exit-code -- deployments/ag-ui-dev/ && echo "ag-ui deploy in sync" +git diff --exit-code -- deployments/shared-dev/ && echo "shared deploy in sync" +``` +(The last two should show no diff AFTER you've committed the regenerated configs — run them post-commit to mirror the CI guard, which compares a fresh regen against the committed tree.) + +- [ ] **Step 7: Commit, push, PR2** + +```bash +git add examples/ag-ui/python cockpit/ag-ui/client-tools/python cockpit/langgraph/client-tools/python deployments/ +git commit -m "refactor(py): consume published threadplane-middleware; regenerate deploys" +git push -u origin claude/py-middleware-publish-flip +gh pr create --base main --head claude/py-middleware-publish-flip \ + --title "refactor(py): flip consumers to published threadplane-middleware + regenerate deploys" \ + --body "PR2 of the middleware rename. Removes the transitional path sources, regenerates consumer requirements + both deploy configs to pin threadplane-middleware==0.0.1. Triggers the deploy workflows on merge (drift guards verified locally). + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +``` +Watch the deploy-ag-ui and deploy-langgraph workflows on merge; confirm both deploys come up on the new package. + +--- + +## Self-review notes + +- **Spec coverage:** package rename (Tasks 1–2), module path `threadplane.middleware.langgraph` (Task 2), 3 consumers (Tasks 3–5), workflow rename (Task 6), docs incl. both guides + threadplane README (Task 7), clean cut / no shim (no shim task exists — intentional), deferred deploy/publish sequence (PR2 / Task 9). The dormant `threadplane-client-tools` needs no action (left published). +- **No placeholders:** every edit names exact files/lines and shows the literal replacement. The one soft spot is the `uv export` flag set in PR2 Step 3 — mitigated by instructing to copy the exact flags from the committed file's header comment (the convention already in-repo) rather than inventing them. +- **Naming consistency:** module path `threadplane.middleware.langgraph`, distribution `threadplane-middleware`, npm sibling `@threadplane/middleware/langgraph` (Plan B) — consistent throughout. diff --git a/docs/superpowers/specs/2026-06-15-threadplane-middleware-langgraph-js-design.md b/docs/superpowers/specs/2026-06-15-threadplane-middleware-langgraph-js-design.md new file mode 100644 index 00000000..0e8228f8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-threadplane-middleware-langgraph-js-design.md @@ -0,0 +1,298 @@ +# `@threadplane/middleware/langgraph` — LangGraph.js client-tools middleware + +**Date:** 2026-06-15 +**Status:** Design — awaiting review +**Author:** Brian Love (with Claude) + +## Goal + +Ship the TypeScript/LangGraph.js twin of the published Python `threadplane-client-tools` +middleware, so a LangGraph.js backend can support the `@threadplane/chat` client-tools +capability (frontend-declared `action`/`view`/`ask` tools the browser executes) exactly +the way a Python LangGraph backend does today. + +The middleware does two things, mirroring Python: + +1. **Bind** a client-tool catalog (arriving in the run input as `client_tools`, mirrored + onto graph state) onto the chat model as OpenAI-format function stubs — alongside any + real server tools — so the model can call them. +2. **Route** the turn: when the model's last message contains *only* client-tool calls + (no server-tool calls), route to `END` so the run ends, the browser executes the tool, + and the frontend re-submits a `ToolMessage` to continue. Server-tool calls route to the + tools node as normal; no tool calls route to the end node. + +## Naming & home decisions + +These were settled during brainstorming and supersede the day-old `threadplane-client-tools` +naming: + +| Axis | Decision | +|---|---| +| npm package | `@threadplane/middleware` | +| npm import | `@threadplane/middleware/langgraph` (vendor-first subpath) | +| PyPI distribution | `threadplane-middleware` (renamed from `threadplane-client-tools`) | +| Python import | `from threadplane.middleware.langgraph import bind_client_tools` | +| Versioning | independent cadence from `0.0.1`; **not** in the Angular lockstep release group | +| Backwards compat | **none** — clean cut; `threadplane-client-tools` 0.0.1 left published but dormant | + +**Why vendor-first (`/langgraph`), not feature-first (`/client-tools`):** every symbol is +LangGraph-coupled — the `Annotation` state-channel fragment, the conditional-edge router +that returns a LangGraph routing string, the `state.tools` reads. It is not portable to +another agent framework, so the subpath should state that constraint. It also mirrors the +frontend's vendor entrypoints (`@threadplane/langgraph`, `@threadplane/ag-ui`). "Client +tools" lives as function names (`bindClientTools`), not a path segment. Future LangGraph +middleware (auth, telemetry, guardrails) adds exports/submodules under `/langgraph`; a +future non-LangGraph framework gets a sibling subpath (`/mastra`, `/vercel-ai`). Single +level today (YAGNI); nest to `/langgraph/` only if a feature grows large — adding +exports to an existing subpath is non-breaking. + +## Repository layout + +`packages/` is the established home for publishable non-Angular distributions (currently +`cacheplane`, `threadplane` [PEP 420 namespace root], `threadplane-client-tools`). Both +language packages live there: + +The **Python** package lives in `packages/` (the home for publishable non-Angular Python +distributions). The **JS** package follows the repo's own convention for publishable +non-Angular TypeScript libraries — `libs/`, built with `@nx/js:tsc` — mirroring the +existing `libs/telemetry` and `libs/licensing` (themselves non-Angular publishable TS libs). +So the JS package is `libs/middleware` (Nx project name `middleware`), NOT a `packages/` +sibling. + +``` +packages/ + threadplane/ # unchanged — namespace root (provides `threadplane`) + threadplane-middleware/ # RENAMED from threadplane-client-tools (Python) + pyproject.toml # name = "threadplane-middleware" + src/threadplane/middleware/langgraph/ + __init__.py # re-exports the 7 public symbols + middleware.py # moved from client_tools/middleware.py (unchanged logic) + tests/test_middleware.py + README.md + +libs/ + middleware/ # NEW — npm @threadplane/middleware (TypeScript) + package.json # "type":"module"; "exports": { "./langgraph": ... }; peerDeps + project.json # @nx/js:tsc build + @nx/vitest:test + @nx/eslint:lint + tsconfig.json # extends ../../tsconfig.base.json (mirror libs/telemetry) + tsconfig.lib.json # declaration:true, emitDeclarationOnly:false + tsconfig.spec.json # types: ["vitest/globals","node"] + vite.config.mts # @nx/vitest config (environment: node, globals: true) + src/ + langgraph/ + index.ts # public surface for the /langgraph subpath + middleware.ts # bindClientTools, routing, predicates + channel.ts # clientToolsChannel() Annotation fragment + router.ts # clientToolsRouter() conditional-edge factory + types.ts # ClientToolSpec, ClientToolsState + langgraph.spec.ts # unit (fakes, mirrors Python suite) + integration.spec.ts # in-process real StateGraph + fake chat model + README.md +``` + +The JS package builds with `@nx/js:tsc` and tests with `@nx/vitest:test` (the repo's native +toolchain — there is no `tsup` anywhere in the repo), exactly mirroring `libs/telemetry`'s +`project.json`. It participates in `nx affected`/CI like every other lib. It has **no +workspace dependencies** (so no shared-types build coupling), and is deliberately **excluded +from the `publishable` Nx release group** in `nx.json` (that group is the lockstep Angular +libs); it publishes on its own cadence via the staged npm workflow below. + +## JS API surface (`@threadplane/middleware/langgraph`) + +### 1:1 mirror of the Python public API (camelCased) + +```ts +// types.ts +export interface ClientToolSpec { + name: string; + description: string; + parameters: Record; // JSON Schema (OpenAI function `parameters`) +} + +// a minimal structural view of the slice of graph state we read +export interface ClientToolsState { + messages: BaseMessage[]; // from @langchain/core (type-only) + tools?: ClientToolSpec[]; // primary channel + client_tools?: ClientToolSpec[]; // fallback channel (raw run input) +} +``` + +```ts +// middleware.ts +// Read the catalog from state.tools, falling back to state.client_tools. +export function clientToolSpecs(state: ClientToolsState): ClientToolSpec[]; +export function clientToolNames(state: ClientToolsState): Set; + +// Bind client stubs (as OpenAI function tools) + any server tools onto a chat model. +// `llm` is anything with .bindTools (Runnable<...>); typed against @langchain/core. +export function bindClientTools( + llm: M, + serverTools: ServerTool[], + state: ClientToolsState, +): M; + +export function lastMessage(state: ClientToolsState): BaseMessage | undefined; +export function hasClientToolCall(state: ClientToolsState): boolean; // last AI msg calls a client tool + +// A call is a "server" call when its name is in serverToolNames OR is not a known client +// tool (unknown tools are assumed server-side) — mirrors the Python signature exactly. +export function hasServerToolCall( + state: ClientToolsState, + serverToolNames: Iterable, +): boolean; + +// Router decision string for a conditional edge. Mirrors Python's +// route_after_agent(state, server_tool_names, *, tools_node="tools", end="__end__"): +// has server tool call -> toolsNode (default "tools") +// has client tool call (only) -> end (default "__end__") +// no tool calls -> end +export function routeAfterAgent( + state: ClientToolsState, + serverToolNames: Iterable, + opts?: { toolsNode?: string; end?: string }, +): string; +``` + +Semantics match Python exactly, including the precedence rule: a turn that mixes a server +tool call and a client tool call routes to the **server** destination (the server tool runs +first; the client call surfaces on a later turn). + +### Idiomatic LangGraph.js extras + +```ts +// channel.ts — drop-in state channels so a graph declares the client-tools slice in one line +export function clientToolsChannel(): { + tools: ReturnType>; + client_tools: ReturnType>; +}; +// Usage: const State = Annotation.Root({ ...MessagesAnnotation.spec, ...clientToolsChannel() }); + +// router.ts — prebuilt conditional-edge callback wrapping routeAfterAgent. +// serverToolNames is bound once at construction; the returned callback takes only state. +export function clientToolsRouter( + serverToolNames: Iterable, + opts?: { toolsNode?: string; end?: string }, +): (state: ClientToolsState) => string; +// Usage: graph.addConditionalEdges("agent", clientToolsRouter([]), ["tools", END]); +// (pass [] when there are no server tools — the common client-tools-only case) +``` + +`@langchain/core` and `@langchain/langgraph` are **peer dependencies** (and dev deps for +tests). The package ships no runtime dependencies of its own. `Annotation`/`MessagesAnnotation` +are imported from `@langchain/langgraph`. + +## Python clean-cut rename + +Same logic, new home and import path. No behavior changes to `middleware.py`. + +**Package move:** +- `packages/threadplane-client-tools/` → `packages/threadplane-middleware/` +- `pyproject.toml`: `name = "threadplane-middleware"`, version reset to `0.0.1`, description + unchanged in substance. +- `src/threadplane/client_tools/` → `src/threadplane/middleware/langgraph/` (PEP 420 chain: + `threadplane` → `threadplane.middleware` → `threadplane.middleware.langgraph`, all + `__init__`-free except the leaf, which re-exports the 7 symbols). +- `tests/` move with the package; assertions unchanged. + +**In-repo consumers to migrate (same PR):** +- `cockpit/ag-ui/client-tools/python/src/graph.py` +- `cockpit/langgraph/client-tools/python/src/graph.py` +- `examples/ag-ui/python/src/graph.py` +- each consumer's `pyproject.toml` dependency + regenerated `uv.lock` / `requirements.txt` +- the import string changes `from threadplane.client_tools import …` + → `from threadplane.middleware.langgraph import …` + +**Deploy configs to regenerate (drift-guarded):** +- `deployments/shared-dev/deps/langgraph-client-tools/` (vendored source + graph) +- `deployments/ag-ui-dev/deps/client_tools/` (vendored source + graph) +- Regenerate via the existing generator scripts; verify the drift guard passes. + +**Docs:** +- `cockpit/ag-ui/client-tools/python/docs/guide.md`, + `cockpit/langgraph/client-tools/python/docs/guide.md` +- `packages/threadplane/README.md`, the package README +- the stale plan doc reference under `docs/superpowers/plans/` (historical — update import + string only if it's presented as current guidance; otherwise leave as dated record) + +**Workflow rename:** +- `.github/workflows/publish-client-tools-python.yml` → + `publish-middleware-python.yml`, pointed at `packages/threadplane-middleware`, same + `workflow_dispatch`-only trigger with `dry-run` defaulting to `true`. Trusted-publishing + config updated to the new PyPI project once it exists. + +**Sequencing (mirrors the prior two-step):** +1. PR 1 — rename + migrate all in-repo consumers using **path sources** (`uv` path deps) so + nothing depends on a published artifact; smoke targets + deploy drift guards green. +2. You publish `threadplane-middleware 0.0.1` to PyPI (your keystroke; dry-run first). +3. PR 2 — flip the three consumers' `pyproject.toml` from path source to the published + `threadplane-middleware>=0.0.1`; regen locks; smoke green. + +`threadplane-client-tools` 0.0.1 stays published but receives no further releases. No alias +shim (the package is a day old with no known external consumers). + +## Verification ladder + +1. **Unit (vitest)** — pure fakes (plain objects for state, a stub model recording + `bindTools` args), mirroring the Python `test_middleware.py` case-for-case: catalog read + with/without fallback, name set, bind merges server + client tools, the three predicates, + and every `routeAfterAgent` branch including the mixed-call precedence rule. +2. **In-process integration (vitest)** — build a real `StateGraph` with + `Annotation.Root({ ...MessagesAnnotation.spec, ...clientToolsChannel() })`, an `agent` + node using `bindClientTools` over a **scripted fake chat model**, `clientToolsRouter()` + edges, and a no-op `tools` node. Assert the full loop in-process (no server): bind → + model emits a client-only tool call → router → `END` → caller appends a `ToolMessage` → + re-invoke → model produces the final content. Dev-deps `@langchain/langgraph` + + `@langchain/core`. +3. **Demo server + reused frontend (manual live smoke)** — a minimal Node/TS LangGraph.js + server added as a **language sibling** at `examples/ag-ui/node/` (alongside the existing + `examples/ag-ui/python/` and `examples/ag-ui/angular/`), exposing `/agent` via + `@ag-ui/langgraph` (npm `0.0.41`) with a client-tools graph built on this middleware. + Because the catalog is frontend-declared, the same generic bind→route-to-END graph drives + the existing itinerary client tools unchanged. For the smoke, point the existing + `examples/ag-ui/angular` itinerary demo's dev proxy at the `node` backend instead of + `python` (identical `/agent` contract) and drive the function/view/ask loop in Chrome with + a real `OPENAI_API_KEY` — satisfying the standing live-LLM-before-merge gate. No second + frontend ships: the TS backend is a third runtime behind the same demo. + +## npm publishing + +A new **staged** `workflow_dispatch` workflow (`publish-middleware-npm.yml`) for +`@threadplane/middleware`, mirroring the Python staged workflow: `dry-run` input defaulting +to `true`, Node 24 / npm 11+ for trusted publishing + provenance, `nx build middleware` then +`npm publish dist/libs/middleware`. Not tag-triggered and not part of the Angular `nx release` lockstep group. +The first real publish is bootstrapped by you after the dry run is clean. + +## Out of scope + +- Non-LangGraph frameworks (Mastra, Vercel AI SDK) — the vendor-first layout leaves room, + but no sibling subpath is built now. +- Additional middleware features (auth, telemetry, guardrails) — layout anticipates them; + none implemented here. +- Any change to the frontend adapters or `@threadplane/chat` — the TS backend speaks the + existing AG-UI client-tools contract unchanged. +- A second Angular frontend for the TS demo — the `node` backend is a third runtime behind + the existing `examples/ag-ui/angular` itinerary demo. + +## Risks & mitigations + +- **`bindTools` typing across LangChain model classes** — type `bindClientTools` against the + `@langchain/core` `Runnable`/`BindableModel` surface and return the same model type; + validate against `ChatOpenAI` in the integration test. +- **Annotation API drift between `@langchain/langgraph` minor versions** — pin a tested peer + range; the integration test (real `StateGraph`) catches incompatibilities CI-side. +- **PyPI rename leaving dangling references** — a repo-wide `grep` for `threadplane.client_tools` + / `threadplane_client_tools` / `threadplane-client-tools` is part of PR 1's acceptance + (zero hits outside `.venv`/historical dated docs). +- **Deploy drift guards** — regenerate vendored deploy deps and confirm both deploy configs + pass their drift checks before merge (these guards have bitten before). + +## Implementation phases (detail deferred to the plan) + +1. Python clean-cut rename + consumer migration (path sources) + workflow rename + docs. +2. JS package scaffold (`libs/middleware`: @nx/js:tsc / @nx/vitest / @nx/eslint, `/langgraph` export) + mirror API + + extras, with the unit suite. +3. JS in-process integration test. +4. TS demo server + live smoke against the reused cockpit frontend. +5. npm staged publish workflow. +6. (Your keystrokes, out of band) publish `threadplane-middleware` to PyPI, then PR 2 to + flip Python consumers to the published version; bootstrap the first npm publish.