threadplane-client-tools 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ # Worktrees
2
+ .worktrees/
3
+ .superpowers/
4
+ .claude/worktrees/
5
+
6
+ # Node
7
+ node_modules/
8
+ dist/
9
+ .next/
10
+ deploy/
11
+
12
+ # Nx
13
+ .nx/cache/
14
+ .nx/workspace-data
15
+ tmp/
16
+ apps/website/test-results/
17
+ # Demo media is hosted on Vercel Blob, not committed (see DemoShowcase.tsx /
18
+ # apps/website/scripts/upload-demo-media.md). Keep these binaries out of git.
19
+ apps/website/public/demo/
20
+ cockpit/**/angular/test-results/
21
+ /test-results/
22
+
23
+ # Marketing channel dry-run simulation outputs (ephemeral; see "simulatedAt")
24
+ marketing/channels/marketing/cowork/outbox/dry-runs/
25
+
26
+ # Env
27
+ .env
28
+ .env.local
29
+
30
+ # OS
31
+ .DS_Store
32
+
33
+ .angular
34
+
35
+ # Next.js
36
+ .next
37
+ out
38
+ .vercel
39
+
40
+ # Whitepaper signup data
41
+ apps/website/data/
42
+
43
+ # Python
44
+ __pycache__/
45
+ *.pyc
46
+ *.pyo
47
+ .venv/
48
+
49
+ # LangGraph local runtime
50
+ .langgraph_api/
51
+
52
+ # Local LangGraph deployment deps
53
+ deployments/*/deps/
54
+ # ...but ag-ui-dev commits generated deps/ (Docker build copies them in)
55
+ !deployments/ag-ui-dev/deps/
56
+ !deployments/ag-ui-dev/deps/**
57
+
58
+ # Generated license public key (produced by libs/licensing/scripts/generate-public-key.mjs)
59
+ libs/licensing/src/lib/license-public-key.generated.ts
60
+
61
+ # superpowers brainstorming output
62
+ .superpowers/
63
+ # TypeScript incremental build caches (any project)
64
+ *.tsbuildinfo
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: threadplane-client-tools
3
+ Version: 0.0.1
4
+ Summary: LangGraph middleware for binding client-declared tool stubs and routing client tool calls to END so the browser executes them.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: langchain-core>=0.3.0
8
+ Requires-Dist: langgraph>=0.3.0
9
+ Provides-Extra: test
10
+ Requires-Dist: pytest>=8; extra == 'test'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # threadplane-client-tools
14
+
15
+ LangGraph middleware for binding client-declared tool stubs and routing
16
+ client tool calls to `END` so the browser executes them.
17
+
18
+ ## How it works
19
+
20
+ When a browser client sends a tool catalog (`{name, description, parameters}`
21
+ dicts) along with a run request, the graph can expose those tools to the LLM
22
+ and route their calls back to the browser instead of executing them
23
+ server-side. The browser then executes the call and re-runs the graph with a
24
+ `ToolMessage` carrying the result.
25
+
26
+ The catalog is read from `state["tools"]`, falling back to
27
+ `state["client_tools"]` if `tools` is absent.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install threadplane-client-tools
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ```python
38
+ from langgraph.graph import END, StateGraph
39
+ from threadplane.client_tools import bind_client_tools, route_after_agent
40
+
41
+ # Server-side tools your graph owns
42
+ SERVER_TOOLS = [search_tool, calculator_tool]
43
+ base_llm = ChatOpenAI(model="gpt-4o")
44
+
45
+ def agent_node(state):
46
+ # bind_client_tools must be called per-run inside the node because
47
+ # the client catalog arrives in state and may differ between runs.
48
+ llm = bind_client_tools(base_llm, SERVER_TOOLS, state)
49
+ response = llm.invoke(state["messages"])
50
+ return {"messages": [response]}
51
+
52
+ def router(state):
53
+ # Returns "tools" for server tool calls, "__end__" otherwise.
54
+ # Map "__end__" to LangGraph's END in add_conditional_edges.
55
+ return route_after_agent(state, [t.name for t in SERVER_TOOLS])
56
+
57
+ graph = StateGraph(...)
58
+ graph.add_node("agent", agent_node)
59
+ graph.add_node("tools", ToolNode(SERVER_TOOLS))
60
+ graph.add_conditional_edges("agent", router, {"tools": "tools", "__end__": END})
61
+ ```
62
+
63
+ ### What happens with a client tool call
64
+
65
+ 1. The LLM emits a tool call whose name matches a client-declared tool.
66
+ 2. `route_after_agent` returns `"__end__"` — the graph run ends.
67
+ 3. The browser receives the partial output, executes the tool locally, and
68
+ re-runs the graph with a `ToolMessage` containing the result.
69
+ 4. The LLM continues from there as if it had called a server tool.
70
+
71
+ ### Lower-level helpers
72
+
73
+ ```python
74
+ from threadplane.client_tools import (
75
+ client_tool_specs, # → list of OpenAI function-tool dicts
76
+ client_tool_names, # → set[str] of client tool names
77
+ has_client_tool_call, # → bool
78
+ has_server_tool_call, # → bool
79
+ last_message, # → last message from state["messages"]
80
+ )
81
+ ```
82
+
83
+ ## Development
84
+
85
+ ```bash
86
+ uv venv
87
+ uv pip install -e '.[test]'
88
+ uv run pytest -q
89
+ ```
@@ -0,0 +1,77 @@
1
+ # threadplane-client-tools
2
+
3
+ LangGraph middleware for binding client-declared tool stubs and routing
4
+ client tool calls to `END` so the browser executes them.
5
+
6
+ ## How it works
7
+
8
+ When a browser client sends a tool catalog (`{name, description, parameters}`
9
+ dicts) along with a run request, the graph can expose those tools to the LLM
10
+ and route their calls back to the browser instead of executing them
11
+ server-side. The browser then executes the call and re-runs the graph with a
12
+ `ToolMessage` carrying the result.
13
+
14
+ The catalog is read from `state["tools"]`, falling back to
15
+ `state["client_tools"]` if `tools` is absent.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pip install threadplane-client-tools
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```python
26
+ from langgraph.graph import END, StateGraph
27
+ from threadplane.client_tools import bind_client_tools, route_after_agent
28
+
29
+ # Server-side tools your graph owns
30
+ SERVER_TOOLS = [search_tool, calculator_tool]
31
+ base_llm = ChatOpenAI(model="gpt-4o")
32
+
33
+ def agent_node(state):
34
+ # bind_client_tools must be called per-run inside the node because
35
+ # the client catalog arrives in state and may differ between runs.
36
+ llm = bind_client_tools(base_llm, SERVER_TOOLS, state)
37
+ response = llm.invoke(state["messages"])
38
+ return {"messages": [response]}
39
+
40
+ def router(state):
41
+ # Returns "tools" for server tool calls, "__end__" otherwise.
42
+ # Map "__end__" to LangGraph's END in add_conditional_edges.
43
+ return route_after_agent(state, [t.name for t in SERVER_TOOLS])
44
+
45
+ graph = StateGraph(...)
46
+ graph.add_node("agent", agent_node)
47
+ graph.add_node("tools", ToolNode(SERVER_TOOLS))
48
+ graph.add_conditional_edges("agent", router, {"tools": "tools", "__end__": END})
49
+ ```
50
+
51
+ ### What happens with a client tool call
52
+
53
+ 1. The LLM emits a tool call whose name matches a client-declared tool.
54
+ 2. `route_after_agent` returns `"__end__"` — the graph run ends.
55
+ 3. The browser receives the partial output, executes the tool locally, and
56
+ re-runs the graph with a `ToolMessage` containing the result.
57
+ 4. The LLM continues from there as if it had called a server tool.
58
+
59
+ ### Lower-level helpers
60
+
61
+ ```python
62
+ from threadplane.client_tools import (
63
+ client_tool_specs, # → list of OpenAI function-tool dicts
64
+ client_tool_names, # → set[str] of client tool names
65
+ has_client_tool_call, # → bool
66
+ has_server_tool_call, # → bool
67
+ last_message, # → last message from state["messages"]
68
+ )
69
+ ```
70
+
71
+ ## Development
72
+
73
+ ```bash
74
+ uv venv
75
+ uv pip install -e '.[test]'
76
+ uv run pytest -q
77
+ ```
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "threadplane-client-tools"
7
+ version = "0.0.1"
8
+ description = "LangGraph middleware for binding client-declared tool stubs and routing client tool calls to END so the browser executes them."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "langchain-core>=0.3.0",
14
+ "langgraph>=0.3.0",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ test = ["pytest>=8"]
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/threadplane"]
@@ -0,0 +1,22 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """threadplane-client-tools — LangGraph middleware for client-declared tools."""
3
+
4
+ from threadplane.client_tools.middleware import (
5
+ bind_client_tools,
6
+ client_tool_names,
7
+ client_tool_specs,
8
+ has_client_tool_call,
9
+ has_server_tool_call,
10
+ last_message,
11
+ route_after_agent,
12
+ )
13
+
14
+ __all__ = [
15
+ "bind_client_tools",
16
+ "client_tool_names",
17
+ "client_tool_specs",
18
+ "has_client_tool_call",
19
+ "has_server_tool_call",
20
+ "last_message",
21
+ "route_after_agent",
22
+ ]
@@ -0,0 +1,128 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """client-tools middleware: bind client-declared tool stubs and route their
3
+ calls to END so the browser executes them and re-runs with a ToolMessage."""
4
+ from __future__ import annotations
5
+ from typing import Any, Iterable
6
+
7
+
8
+ def _catalog(state: dict) -> list[dict]:
9
+ raw = state.get("tools")
10
+ if not raw:
11
+ raw = state.get("client_tools")
12
+ return [t for t in (raw or []) if isinstance(t, dict) and t.get("name")]
13
+
14
+
15
+ def client_tool_specs(state: dict) -> list[dict]:
16
+ """The client catalog as OpenAI function-tool dicts for ``llm.bind_tools``.
17
+
18
+ Each entry in ``state["tools"]`` (or ``state["client_tools"]`` as a
19
+ fallback) is converted to the explicit
20
+ ``{"type": "function", "function": {...}}`` form that is accepted by
21
+ ``ChatOpenAI(...).bind_tools([...])`` regardless of LangChain version.
22
+ """
23
+ return [
24
+ {
25
+ "type": "function",
26
+ "function": {
27
+ "name": t["name"],
28
+ "description": t.get("description", ""),
29
+ "parameters": t.get("parameters", {}) or {},
30
+ },
31
+ }
32
+ for t in _catalog(state)
33
+ ]
34
+
35
+
36
+ def client_tool_names(state: dict) -> set[str]:
37
+ """Return the set of tool names declared by the client in this run."""
38
+ return {t["name"] for t in _catalog(state)}
39
+
40
+
41
+ def _tool_calls(message: Any) -> list:
42
+ tc = (
43
+ message.get("tool_calls")
44
+ if isinstance(message, dict)
45
+ else getattr(message, "tool_calls", None)
46
+ )
47
+ return list(tc or [])
48
+
49
+
50
+ def _call_name(call: Any) -> str | None:
51
+ if isinstance(call, dict):
52
+ return call.get("name") or (call.get("function") or {}).get("name")
53
+ return getattr(call, "name", None)
54
+
55
+
56
+ def last_message(state: dict) -> Any:
57
+ """Return the last message from ``state["messages"]``, or None."""
58
+ msgs = state.get("messages") or []
59
+ return msgs[-1] if msgs else None
60
+
61
+
62
+ def has_client_tool_call(state: dict) -> bool:
63
+ """True if the last message calls at least one tool that is a client tool."""
64
+ names = client_tool_names(state)
65
+ return any(_call_name(c) in names for c in _tool_calls(last_message(state)))
66
+
67
+
68
+ def has_server_tool_call(state: dict, server_tool_names: Iterable[str]) -> bool:
69
+ """True if the last message calls at least one server (non-client) tool.
70
+
71
+ A call is treated as a server call when its name appears in
72
+ ``server_tool_names`` OR when its name is not a known client tool name
73
+ (unknown tools are assumed to be server-side).
74
+ """
75
+ server = set(server_tool_names)
76
+ client = client_tool_names(state)
77
+ for c in _tool_calls(last_message(state)):
78
+ n = _call_name(c)
79
+ if n in server or (n is not None and n not in client):
80
+ return True
81
+ return False
82
+
83
+
84
+ def bind_client_tools(llm: Any, server_tools: list, state: dict) -> Any:
85
+ """Bind server tools + the client catalog stubs onto ``llm``.
86
+
87
+ Call this *inside* the agent node (per-run) because the client catalog
88
+ arrives in state and may differ between runs.
89
+
90
+ Example::
91
+
92
+ def agent_node(state):
93
+ bound = bind_client_tools(base_llm, SERVER_TOOLS, state)
94
+ response = bound.invoke(state["messages"])
95
+ return {"messages": [response]}
96
+ """
97
+ return llm.bind_tools([*server_tools, *client_tool_specs(state)])
98
+
99
+
100
+ def route_after_agent(
101
+ state: dict,
102
+ server_tool_names: Iterable[str],
103
+ *,
104
+ tools_node: str = "tools",
105
+ end: str = "__end__",
106
+ ) -> str:
107
+ """Routing helper to call from a LangGraph conditional edge.
108
+
109
+ Returns:
110
+ ``tools_node`` when the last message contains a server tool call
111
+ (so LangGraph dispatches to the server-side ToolNode).
112
+ ``end`` when the last message contains only client tool calls
113
+ (the browser executes the call; map ``end`` to LangGraph's ``END``).
114
+ ``end`` when the last message has no tool calls at all.
115
+
116
+ Note: this helper is LangGraph-free — it returns plain strings so callers
117
+ can map the return value to ``END`` themselves::
118
+
119
+ from langgraph.graph import END
120
+ graph.add_conditional_edges(
121
+ "agent",
122
+ lambda s: route_after_agent(s, ["search"]),
123
+ {"tools": "tools", "__end__": END},
124
+ )
125
+ """
126
+ if has_server_tool_call(state, server_tool_names):
127
+ return tools_node
128
+ return end