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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any