4242from langgraph_sdk import get_client
4343from langsmith import traceable
4444
45+ from threadplane .middleware .langgraph import bind_client_tools , client_tool_names
46+
4547from src .streaming .a2ui_partial_handler import A2uiPartialHandler
4648from src .streaming .envelope_tool import render_a2ui_surface
4749from 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
372386async 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
429449def 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
439469def after_tools (state : State ) -> Literal ["emit_generated_surface" , "generate" ]:
0 commit comments