Skip to content

Commit 74d45e2

Browse files
bloveclaude
andcommitted
feat(chat-graph): spike client-tool binding + client-tool-aware routing (#itinerary)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3c24c09 commit 74d45e2

4 files changed

Lines changed: 74 additions & 7 deletions

File tree

examples/chat/python/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ dependencies = [
77
"langchain-openai>=0.3",
88
"langgraph-api>=0.8.7",
99
"python-dotenv>=1.0",
10+
"threadplane-middleware>=0.0.1",
1011
]
1112

1213
[tool.uv]

examples/chat/python/src/graph.py

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
from langgraph_sdk import get_client
4343
from langsmith import traceable
4444

45+
from threadplane.middleware.langgraph import bind_client_tools, client_tool_names
46+
4547
from src.streaming.a2ui_partial_handler import A2uiPartialHandler
4648
from src.streaming.envelope_tool import render_a2ui_surface
4749
from src.streaming.envelope_normalizer import normalize_envelope_args
@@ -367,6 +369,18 @@ class State(TypedDict):
367369
model: Optional[str]
368370
reasoning_effort: Optional[str]
369371
gen_ui_mode: Optional[str]
372+
# Frontend client-tool catalog. The @threadplane/langgraph SDK path
373+
# (mergeClientTools) sends the browser-declared tool stubs under
374+
# ``client_tools`` in the run payload; the threadplane-middleware
375+
# ``_catalog`` reads ``state["tools"]`` first and falls back to
376+
# ``state["client_tools"]`` — so this channel name feeds the middleware
377+
# directly with no normalization needed. The channel must exist here so
378+
# the catalog survives the generate → should_continue path.
379+
client_tools: Optional[list]
380+
# Trip itinerary the frontend client tools (add_stop/move_stop/...)
381+
# mutate. Declared now so the channel exists; typed properly in a
382+
# later task.
383+
itinerary: list
370384

371385

372386
async def generate(state: State, config: RunnableConfig) -> dict:
@@ -402,8 +416,14 @@ async def generate(state: State, config: RunnableConfig) -> dict:
402416
# libs/chat/src/lib/a2ui/envelope-normalizer.ts) to canonicalize the
403417
# four observed argument shapes (envelopes / envelope / positional /
404418
# flat). The spike showed 80-93% canonical even without strict.
405-
llm = ChatOpenAI(**kwargs).bind_tools(
419+
# bind_client_tools appends the frontend client-tool catalog stubs (read
420+
# from state["client_tools"], sent by the @threadplane/langgraph SDK
421+
# mergeClientTools path) to the server tool list so the model can call any
422+
# browser-declared tool by name (add_stop/move_stop/clear_day/day_card).
423+
llm = bind_client_tools(
424+
ChatOpenAI(**kwargs),
406425
[search_documents, request_approval, research, gen_ui_tool],
426+
state,
407427
)
408428
# Append A2UI v1 schema to system prompt when in a2ui mode, so the parent
409429
# LLM knows how to construct the envelopes directly.
@@ -427,13 +447,23 @@ async def generate(state: State, config: RunnableConfig) -> dict:
427447

428448

429449
def should_continue(state: State) -> Literal["tools", "attach_citations"]:
430-
"""Conditional edge from generate: route to tools node when any
431-
tool_call is present (GenUI tools, search, approval, research),
432-
otherwise route to attach_citations terminal post-process."""
450+
"""Conditional edge from generate: route to the server ToolNode when any
451+
SERVER tool_call is present (GenUI tools, search, approval, research).
452+
453+
A turn whose calls are ALL frontend client tools must END (route to the
454+
terminal attach_citations post-process) so the browser can execute them
455+
and re-run with the resulting ToolMessage — the server ToolNode has no
456+
implementation for client tools and would otherwise error. Turns with no
457+
tool calls also terminate at attach_citations (a no-op without search
458+
results). Mixed server+client turns keep the 'tools' route.
459+
"""
433460
last = state["messages"][-1]
434-
if isinstance(last, AIMessage) and last.tool_calls:
435-
return "tools"
436-
return "attach_citations"
461+
if not (isinstance(last, AIMessage) and last.tool_calls):
462+
return "attach_citations"
463+
client = client_tool_names(state)
464+
if all(tc["name"] in client for tc in last.tool_calls):
465+
return "attach_citations"
466+
return "tools"
437467

438468

439469
def after_tools(state: State) -> Literal["emit_generated_surface", "generate"]:
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import pytest
2+
3+
4+
@pytest.mark.smoke
5+
def test_state_has_client_tools_channel():
6+
from src.graph import State
7+
ann = State.__annotations__
8+
assert "client_tools" in ann, "State must carry the frontend client-tool catalog channel"
9+
10+
11+
@pytest.mark.smoke
12+
def test_all_client_tool_turn_routes_away_from_server_tools():
13+
from langchain_core.messages import AIMessage
14+
from src.graph import should_continue
15+
state = {
16+
"messages": [AIMessage(content="", tool_calls=[
17+
{"name": "add_stop", "args": {"day": 1, "place": "Louvre"}, "id": "t1"},
18+
])],
19+
"client_tools": [{"name": "add_stop"}],
20+
}
21+
assert should_continue(state) != "tools"

examples/chat/python/uv.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)