threadplane-client-tools 0.0.1__py3-none-any.whl
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,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
|
|
@@ -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,5 @@
|
|
|
1
|
+
threadplane/client_tools/__init__.py,sha256=o9PTqR8_iYDok-xm1UDBZr-kuP7TgjoZxcTa1hS8AfY,520
|
|
2
|
+
threadplane/client_tools/middleware.py,sha256=THGkNAsNIySCC07blfMSyumsG6JQC-sc-Tu3ldUArCg,4364
|
|
3
|
+
threadplane_client_tools-0.0.1.dist-info/METADATA,sha256=jMJkfR45nC99WECSfeCtrdD9wR7xIB2axV-AaNY1A_s,2900
|
|
4
|
+
threadplane_client_tools-0.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
threadplane_client_tools-0.0.1.dist-info/RECORD,,
|