From aae3ababbe3367110c09aa22b16c10f734530273 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Thu, 28 May 2026 18:36:47 +0400 Subject: [PATCH 01/34] feat(adapters/langgraph): compile ManagerWorkers via subgraph composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a ManagerWorkers branch to the LangGraph converter so a manager + workers spec compiles into a parent StateGraph whose nodes are the manager react-agent and each worker CompiledStateGraph. Delegation happens through synthesized delegate_to_ tools that return Command(goto=..., graph=Command.PARENT) — same handoff pattern as langgraph_swarm. Workers run with an isolated messages context; the worker's reply comes back as a ToolMessage matched to the manager's pending tool_call_id, keeping the OpenAI tool contract intact across the manager's next turn. Recursive ManagerWorkers compose through self.convert(...). The "Available workers:" roster is rendered into the manager's system prompt by the converter. Stream/astream are wrapped to emit ManagerWorkersExecutionSpan + Start/End events. --- .../adapters/langgraph/_langgraphconverter.py | 477 +++++++++++++++++ .../adapters/langgraph/test_managerworkers.py | 503 ++++++++++++++++++ 2 files changed, 980 insertions(+) create mode 100644 pyagentspec/tests/adapters/langgraph/test_managerworkers.py diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index fb54281f..c7b9b72e 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -96,6 +96,7 @@ from pyagentspec.llms.openaicompatibleconfig import OpenAIAPIType, OpenAiCompatibleConfig from pyagentspec.llms.openaiconfig import OpenAiConfig from pyagentspec.llms.vllmconfig import VllmConfig +from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers from pyagentspec.mcp.clienttransport import ClientTransport as AgentSpecClientTransport from pyagentspec.mcp.clienttransport import SSEmTLSTransport as AgentSpecSSEmTLSTransport from pyagentspec.mcp.clienttransport import SSETransport as AgentSpecSSETransport @@ -126,8 +127,17 @@ from pyagentspec.tracing.events import AgentExecutionStart as AgentSpecAgentExecutionStart from pyagentspec.tracing.events import FlowExecutionEnd as AgentSpecFlowExecutionEnd from pyagentspec.tracing.events import FlowExecutionStart as AgentSpecFlowExecutionStart +from pyagentspec.tracing.events import ( + ManagerWorkersExecutionEnd as AgentSpecManagerWorkersExecutionEnd, +) +from pyagentspec.tracing.events import ( + ManagerWorkersExecutionStart as AgentSpecManagerWorkersExecutionStart, +) from pyagentspec.tracing.spans import AgentExecutionSpan as AgentSpecAgentExecutionSpan from pyagentspec.tracing.spans import FlowExecutionSpan as AgentSpecFlowExecutionSpan +from pyagentspec.tracing.spans import ( + ManagerWorkersExecutionSpan as AgentSpecManagerWorkersExecutionSpan, +) if TYPE_CHECKING: from langchain_mcp_adapters.sessions import ( @@ -267,6 +277,14 @@ def _convert( config=config, middleware=middleware, ) + elif isinstance(agentspec_component, AgentSpecManagerWorkers): + return self._manager_workers_convert_to_langgraph( + agentspec_component, + tool_registry=tool_registry, + converted_components=converted_components, + checkpointer=checkpointer, + config=config, + ) elif isinstance(agentspec_component, AgentSpecLlmConfig): return self._llm_convert_to_langgraph(agentspec_component, config=config) elif isinstance(agentspec_component, AgentSpecClientTransport): @@ -1104,6 +1122,148 @@ def _swarm_convert_to_langgraph( default_active_agent=agentspec_component.first_agent.name, ).compile(name=agentspec_component.name, checkpointer=checkpointer) + def _manager_workers_convert_to_langgraph( + self, + mw: AgentSpecManagerWorkers, + tool_registry: Dict[str, "LangGraphTool"], + converted_components: Dict[str, Any], + checkpointer: Optional[Checkpointer], + config: RunnableConfig, + ) -> CompiledStateGraph[Any, Any, Any]: + """Compile a ``ManagerWorkers`` into a hierarchical LangGraph. + + Topology:: + + ┌─ delegate_to_w1 ─→ worker_1 ─┐ + START → manager ┤ ├→ manager (loop) + └─ delegate_to_w2 ─→ worker_2 ─┘ + │ + └─ no tool_call ─→ END + + Each worker is recursively converted into a ``CompiledStateGraph`` + and wired in as a *subgraph node*, so ``astream_events`` exposes + the parent/child boundary (``subgraph=True``) for tracing and SSE + streaming. The manager is a react-agent given one synthetic + ``delegate_to_`` tool per worker; the parent graph's + conditional edge inspects the manager's last AIMessage to choose + the next node, then the worker node runs in an isolated message + context and emits a ``ToolMessage`` matched to the pending + delegation tool-call id. Recursive ``ManagerWorkers`` (workers + that are themselves ``ManagerWorkers``) compose for free through + ``self.convert(...)``. + """ + if not isinstance(mw.group_manager, AgentSpecAgent): + # Pyagentspec allows any AgenticComponent as group_manager, + # but the manager has to *decide* which worker to delegate to, + # which means it needs a chat-LLM that emits tool_calls. Today + # only Agent (and SpecializedAgent, a subclass) does that — a + # Flow / Swarm / nested ManagerWorkers as the group_manager + # doesn't have a "tool-call to delegate" output shape we can + # route on. + raise NotImplementedError( + f"ManagerWorkers.group_manager must be an Agent for LangGraph " + f"conversion; got {type(mw.group_manager).__name__}." + ) + + worker_node_names: List[str] = [ + _safe_node_name(worker.name, fallback_id=worker.id) + for worker in mw.workers + ] + if len(set(worker_node_names)) != len(worker_node_names): + raise ValueError( + "ManagerWorkers worker names collide after normalization: " + f"{worker_node_names}. Give each worker a unique name." + ) + + # 1. Recursively compile each worker as its own CompiledStateGraph. + worker_graphs: Dict[str, CompiledStateGraph[Any, Any, Any]] = {} + for worker, node_name in zip(mw.workers, worker_node_names): + worker_graphs[node_name] = self.convert( + worker, + tool_registry=tool_registry, + converted_components=converted_components, + checkpointer=checkpointer, + config=config, + ) + + # 2. Render the workers roster into the manager's system prompt + # so the LLM knows which delegation tool maps to which worker. + manager_agent = mw.group_manager + rendered_prompt = _append_workers_roster( + manager_agent.system_prompt, + [ + (node_name, worker.description or "") + for worker, node_name in zip(mw.workers, worker_node_names) + ], + ) + + # 3. Synthesize one delegation tool per worker. The tool body is a + # placeholder — the parent graph intercepts the manager's tool + # call before it executes and routes to the worker node. + delegation_tools: List[Any] = [ + _make_worker_delegation_tool(node_name) + for node_name in worker_node_names + ] + + # 4. Compile the manager as a react-agent with the delegation tools. + manager_graph = self._create_react_agent_with_given_info( + name=manager_agent.name, + system_prompt=rendered_prompt, + agent=manager_agent, + llm_config=manager_agent.llm_config, + tools=manager_agent.tools, + toolboxes=manager_agent.toolboxes, + inputs=manager_agent.inputs or [], + outputs=manager_agent.outputs or [], + tool_registry=tool_registry, + converted_components=converted_components, + checkpointer=checkpointer, + config=config, + additional_langgraph_tools=delegation_tools, + ) + + # 5. Compose the parent StateGraph. The manager and every worker + # are CompiledStateGraphs added as subgraph nodes; LangGraph's + # streaming surfaces them with ``subgraph=True``. + from langgraph.graph import MessagesState # local: optional dep + + manager_node_key = _MANAGER_NODE_KEY + if manager_node_key in worker_graphs: + raise ValueError( + f"Worker name '{manager_node_key}' is reserved for the " + f"manager node in ManagerWorkers; rename the worker." + ) + + builder = StateGraph(MessagesState) + builder.add_node(manager_node_key, manager_graph) + for node_name, worker_graph in worker_graphs.items(): + builder.add_node( + node_name, + _wrap_worker_for_subgraph(worker_graph, node_name), + ) + + builder.add_edge(langgraph_graph.START, manager_node_key) + # Path-map covers both delegate-to-worker and the END branch so + # langgraph can statically validate the routing. + builder.add_conditional_edges( + manager_node_key, + _route_manager_to_worker_or_end, + {node_name: node_name for node_name in worker_node_names} + | {langgraph_graph.END: langgraph_graph.END}, + ) + for node_name in worker_node_names: + builder.add_edge(node_name, manager_node_key) + + compiled_graph = builder.compile( + checkpointer=checkpointer, name=mw.name + ) + + # 6. Tracing — wrap stream/astream so ManagerWorkersExecutionSpan + # surrounds each run. Mirrors the patches applied to Agent and + # Flow graphs above. + _patch_with_manager_workers_execution_span(compiled_graph, mw) + return compiled_graph + def _create_react_agent_with_given_info( self, *, @@ -1951,3 +2111,320 @@ def _ensure_checkpointer_and_valid_tool_config( f"json schema should be left unspecified when using tool confirmation, was {tool_output}. " f'Please use outputs=[Property(title="{tool_name}", json_schema={{}})]' ) + + +# ─── ManagerWorkers helpers ────────────────────────────────────────────────── + +# Node key for the manager subgraph in the ManagerWorkers parent StateGraph. +# Chosen so it cannot collide with a normalized worker node name (which is +# always lowercase + [a-z0-9_]). +_MANAGER_NODE_KEY = "__manager__" + +# Prefix the manager's LLM uses to address a delegation tool. The suffix is +# the normalized worker node name. +_DELEGATE_TOOL_PREFIX = "delegate_to_" + + +def _safe_node_name(name: str, fallback_id: str) -> str: + """Normalize a worker name into a LangGraph node identifier. + + LangGraph node names must be hashable strings; in practice we want + ASCII-friendly identifiers that also work as Python attribute-ish + names (the LLM is going to see ``delegate_to_`` as a tool + name and needs to be able to emit it reliably). We lowercase, collapse + non-alphanumerics to underscores, strip surrounding underscores, and + fall back to the (component) id — normalized the same way — if the + name yields an empty string. Falling through both transforms keeps + node names internally consistent regardless of which input wins. + """ + import re as _re + + def _norm(s: str) -> str: + return _re.sub(r"[^a-z0-9]+", "_", (s or "").lower()).strip("_") + + return _norm(name) or _norm(fallback_id) or "worker" + + +def _append_workers_roster( + system_prompt: str, + entries: List[Tuple[str, str]], +) -> str: + """Prepend the manager's system prompt with an ``Available workers:`` + roster block listing ``- : `` per worker. + + Each description has whitespace flattened so multi-line descriptions + don't corrupt the one-line-per-worker block shape that the LLM relies + on for routing. + """ + if not entries: + return system_prompt + import re as _re + + _ws = _re.compile(r"\s+") + lines = [ + f"- {name}: {_ws.sub(' ', description).strip()}" + for name, description in entries + ] + roster = "Available workers:\n" + "\n".join(lines) + return f"{system_prompt}\n\n{roster}" if system_prompt else roster + + +def _make_worker_delegation_tool(worker_node_name: str) -> Any: + """Build the ``delegate_to_`` tool the manager's LLM emits to + route work to a worker subgraph. + + The tool body returns a ``Command(goto=, graph=Command.PARENT)`` + which the langchain ``ToolNode`` propagates up to the parent graph, + breaking out of the manager's react-agent inner loop and routing + control to the worker subgraph node. The worker reads the pending + ``delegate_to_`` tool-call off the parent state (to recover + the ``task`` argument and ``tool_call_id``), runs in an isolated + message context, and emits a ToolMessage on its way back. On the + next manager turn the LLM sees ``AIMessage(tool_call=delegate)`` + + ``ToolMessage(worker_reply)`` and can produce its final answer — + a well-formed tool-call / tool-result sequence in the OpenAI + contract. + + Modelled on ``langgraph_swarm.create_handoff_tool``, which uses the + same Command-propagation pattern for swarm handoffs. + """ + from typing import Annotated + + from langchain_core.tools import InjectedToolCallId, tool + from langgraph.prebuilt import InjectedState + from langgraph.types import Command + + tool_name = f"{_DELEGATE_TOOL_PREFIX}{worker_node_name}" + + @tool(tool_name) + def _delegate( + task: str, + state: Annotated[Any, InjectedState], + tool_call_id: Annotated[str, InjectedToolCallId], + ) -> Command: + """Delegate a task to the named worker and wait for its reply. + + ``task`` is the natural-language instruction the worker should + execute. The worker runs in its own isolated message context; + only this ``task`` is forwarded as the worker's first message. + """ + # Mirror langgraph_swarm's create_handoff_tool: project the + # subgraph's messages — including the AIMessage carrying this + # tool_call — onto the PARENT state via the update. The + # ``add_messages`` reducer dedupes by id so existing messages + # aren't duplicated. The worker subgraph node will then read the + # AIMessage off the parent state (rather than the now-discarded + # subgraph state) to recover ``task`` and ``tool_call_id``, and + # will emit the real ToolMessage matched to ``tool_call_id`` on + # its way back to the manager. + subgraph_messages: List[Any] = [] + if isinstance(state, dict): + subgraph_messages = list(state.get("messages") or []) + else: + subgraph_messages = list(getattr(state, "messages", []) or []) + del task, tool_call_id # task is read off the parent state's pending tool call + return Command( + goto=worker_node_name, + graph=Command.PARENT, + update={"messages": subgraph_messages}, + ) + + _delegate.description = ( + f"Delegate a task to the {worker_node_name} worker and receive " + f"its response. Use this when the task fits the worker's " + f"described capability." + ) + return _delegate + + +def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> str: + """Inspect the manager's last AIMessage. If it tool-called a + ``delegate_to_``, return that worker node name; otherwise + return ``END``. + + Robust against multiple tool calls in one turn — only the first + delegation call routes the graph (any other tool calls on the same + AIMessage are real tools the manager invoked, already executed by + its react-agent inner loop before we get here). + """ + messages = state.get("messages") or [] + if not messages: + return langgraph_graph.END + last = messages[-1] + tool_calls = getattr(last, "tool_calls", None) or [] + for tc in tool_calls: + name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None) + if isinstance(name, str) and name.startswith(_DELEGATE_TOOL_PREFIX): + return name[len(_DELEGATE_TOOL_PREFIX):] + return langgraph_graph.END + + +def _wrap_worker_for_subgraph( + worker_graph: CompiledStateGraph[Any, Any, Any], + worker_node_name: str, +) -> Any: + """Wrap a worker subgraph so it runs with an isolated ``messages`` + context (the delegation task only) and its final reply comes back as + a ToolMessage matched to the manager's pending delegation tool-call. + + This is what makes a ManagerWorkers parent graph hierarchical rather + than a shared-state Swarm: workers do NOT see each other's messages, + and only one message — the manager's chosen task — is forwarded to + each worker run. The worker's last AIMessage content is captured as + the ToolMessage content so the manager's react-agent loop sees a + well-formed tool response on the next turn. + + Returns a ``RunnableLambda`` exposing both sync (``func``) and async + (``afunc``) entrypoints — LangGraph picks the right one based on + whether the parent graph is invoked via ``invoke`` or ``ainvoke``. + """ + from pyagentspec.adapters.langgraph._types import RunnableLambda + + delegate_tool_name = f"{_DELEGATE_TOOL_PREFIX}{worker_node_name}" + + def _extract_pending(state: Dict[str, Any]) -> Tuple[str, str]: + messages = state.get("messages") or [] + if not messages: + raise RuntimeError( + f"Worker '{worker_node_name}' was invoked with empty manager state." + ) + last_ai = messages[-1] + tool_calls = getattr(last_ai, "tool_calls", None) or [] + pending_call = next( + ( + tc for tc in tool_calls + if (tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)) + == delegate_tool_name + ), + None, + ) + if pending_call is None: + raise RuntimeError( + f"Worker '{worker_node_name}' was routed to but the manager's " + f"last message has no '{delegate_tool_name}' tool call." + ) + args = ( + pending_call.get("args") if isinstance(pending_call, dict) + else getattr(pending_call, "args", None) + ) or {} + call_id = ( + pending_call.get("id") if isinstance(pending_call, dict) + else getattr(pending_call, "id", None) + ) or "" + return args.get("task") or "", call_id + + def _tool_message_from(reply: str, call_id: str) -> Dict[str, Any]: + from langchain_core.messages import ToolMessage + + return {"messages": [ToolMessage(content=reply, tool_call_id=call_id)]} + + def _worker_input(task: str) -> Tuple[Dict[str, Any], Dict[str, Any]]: + from langchain_core.messages import HumanMessage + + # Each worker run gets a fresh thread_id so its message history + # is isolated across invocations — workers shouldn't accumulate + # state across delegations within a single manager session, + # otherwise the same worker called twice in a row would see its + # previous answer as conversation history. + return ( + {"messages": [HumanMessage(content=task)]}, + {"configurable": {"thread_id": str(uuid4())}}, + ) + + def _last_message_content(result: Any) -> str: + messages = result.get("messages") if isinstance(result, dict) else None + if not messages: + return "" + return getattr(messages[-1], "content", "") or "" + + def _run_sync(state: Dict[str, Any]) -> Dict[str, Any]: + task, call_id = _extract_pending(state) + sub_input, sub_config = _worker_input(task) + result = worker_graph.invoke(sub_input, sub_config) + return _tool_message_from(_last_message_content(result), call_id) + + async def _run_async(state: Dict[str, Any]) -> Dict[str, Any]: + task, call_id = _extract_pending(state) + sub_input, sub_config = _worker_input(task) + result = await worker_graph.ainvoke(sub_input, sub_config) + return _tool_message_from(_last_message_content(result), call_id) + + return RunnableLambda( + func=_run_sync, + afunc=_run_async, + name=f"worker:{worker_node_name}", + ) + + +def _patch_with_manager_workers_execution_span( + compiled_graph: CompiledStateGraph[Any, Any, Any], + mw: AgentSpecManagerWorkers, +) -> None: + """Wrap ``stream`` / ``astream`` so each ManagerWorkers run emits a + ``ManagerWorkersExecutionSpan`` with Start/End events. Mirrors the + patches applied to Agent and Flow compiled graphs elsewhere in this + converter. + """ + original_stream = compiled_graph.stream + original_astream = compiled_graph.astream + + def _coerce_inputs(kwargs: Dict[str, Any]) -> Dict[str, Any]: + inputs = kwargs.get("input", {}) + return inputs if isinstance(inputs, dict) else {} + + def patched_stream(*args: Any, **kwargs: Any) -> Generator[Any, Any, None]: + span_name = f"ManagerWorkersExecution[{mw.name}]" + inputs = _coerce_inputs(kwargs) + with AgentSpecManagerWorkersExecutionSpan(name=span_name, managerworkers=mw) as span: + span.add_event( + AgentSpecManagerWorkersExecutionStart(managerworkers=mw, inputs=inputs) + ) + last_chunk: Dict[str, Any] = {} + for chunk in original_stream(*args, **kwargs): + yield chunk + if isinstance(chunk, tuple) and isinstance(chunk[1], dict): + last_chunk = chunk[1] + span.add_event( + AgentSpecManagerWorkersExecutionEnd( + managerworkers=mw, + outputs={"messages": last_chunk.get("messages", [])}, + ) + ) + + async def patched_astream(*args: Any, **kwargs: Any) -> AsyncGenerator[Any, Any]: + span_name = f"ManagerWorkersExecution[{mw.name}]" + inputs = _coerce_inputs(kwargs) + span = AgentSpecManagerWorkersExecutionSpan(name=span_name, managerworkers=mw) + try: + await span.start_async() + except NotImplementedError: + span.start() + try: + start_event = AgentSpecManagerWorkersExecutionStart( + managerworkers=mw, inputs=inputs + ) + try: + await span.add_event_async(start_event) + except NotImplementedError: + span.add_event(start_event) + last_chunk: Dict[str, Any] = {} + async for chunk in original_astream(*args, **kwargs): + yield chunk + if isinstance(chunk, tuple) and isinstance(chunk[1], dict): + last_chunk = chunk[1] + end_event = AgentSpecManagerWorkersExecutionEnd( + managerworkers=mw, + outputs={"messages": last_chunk.get("messages", [])}, + ) + try: + await span.add_event_async(end_event) + except NotImplementedError: + span.add_event(end_event) + finally: + try: + await span.end_async() + except NotImplementedError: + span.end() + + compiled_graph.stream = patched_stream # type: ignore[assignment] + compiled_graph.astream = patched_astream # type: ignore[assignment] diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py new file mode 100644 index 00000000..351855af --- /dev/null +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -0,0 +1,503 @@ +# Copyright © 2025 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +"""Offline tests for the LangGraph ``ManagerWorkers`` converter. + +These cover the hierarchical topology, roster prompt rendering, the +worker-isolation invariant (each worker sees only its delegated task), +and the recursive nesting case. The LLM is stubbed with +``FakeMessagesListChatModel`` so the tests run without network or model +endpoints. +""" + +from typing import Any +from unittest.mock import patch + +import pytest + + +# ─── Shared helpers ────────────────────────────────────────────────────────── + + +def _llm_cfg(name: str) -> Any: + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + return OpenAiCompatibleConfig(name=name, model_id="fake", url="null") + + +def _fake_manager(*ai_responses: Any) -> Any: + """A FakeMessagesListChatModel subclassed under ChatOpenAI so the + manager's react-agent treats it as an OpenAI-style chat model.""" + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_openai import ChatOpenAI + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + return _FakeModel(responses=list(ai_responses)) + + +# ─── Pure-helper unit tests (no LLM) ──────────────────────────────────────── + + +def test_safe_node_name_lowercases_and_collapses_punctuation() -> None: + from pyagentspec.adapters.langgraph._langgraphconverter import _safe_node_name + + assert _safe_node_name("Research Helper", "id-1") == "research_helper" + assert _safe_node_name("My-Worker!! v2", "id-1") == "my_worker_v2" + + +def test_safe_node_name_falls_back_to_normalized_id() -> None: + from pyagentspec.adapters.langgraph._langgraphconverter import _safe_node_name + + # Name slugifies to empty → id used (and also normalized). + assert _safe_node_name("!!!", "sub-1") == "sub_1" + # Both empty → constant fallback. + assert _safe_node_name("", "") == "worker" + + +def test_append_workers_roster_appends_block_after_existing_prompt() -> None: + from pyagentspec.adapters.langgraph._langgraphconverter import _append_workers_roster + + out = _append_workers_roster( + "Coordinate the team.", + [("research_helper", "Handles research"), ("drafter", "Drafts text")], + ) + assert out == ( + "Coordinate the team.\n\n" + "Available workers:\n" + "- research_helper: Handles research\n" + "- drafter: Drafts text" + ) + + +def test_append_workers_roster_flattens_multiline_descriptions() -> None: + from pyagentspec.adapters.langgraph._langgraphconverter import _append_workers_roster + + out = _append_workers_roster( + "", + [("helper", "First line\nsecond line\n third line ")], + ) + # Whitespace flattened so the one-line-per-worker shape survives. + assert out == "Available workers:\n- helper: First line second line third line" + + +def test_route_manager_to_worker_or_end_reads_pending_delegation() -> None: + from langchain_core.messages import AIMessage + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _route_manager_to_worker_or_end, + ) + + delegating = AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "c1"} + ], + ) + state = {"messages": [delegating]} + assert _route_manager_to_worker_or_end(state) == "research_helper" + + +def test_route_manager_to_worker_or_end_returns_end_when_no_delegation() -> None: + from langchain_core.messages import AIMessage + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _route_manager_to_worker_or_end, + ) + from langgraph.graph import END + + not_delegating = AIMessage(content="Done.", tool_calls=[]) + assert _route_manager_to_worker_or_end({"messages": [not_delegating]}) == END + assert _route_manager_to_worker_or_end({"messages": []}) == END + + +def test_route_manager_to_worker_or_end_picks_first_delegation_among_multiple_tool_calls() -> None: + from langchain_core.messages import AIMessage + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _route_manager_to_worker_or_end, + ) + + msg = AIMessage( + content="", + tool_calls=[ + {"name": "some_other_tool", "args": {}, "id": "c0"}, + {"name": "delegate_to_drafter", "args": {"task": "x"}, "id": "c1"}, + {"name": "delegate_to_research_helper", "args": {"task": "y"}, "id": "c2"}, + ], + ) + # First delegation wins — the others would be handled on the next loop. + assert _route_manager_to_worker_or_end({"messages": [msg]}) == "drafter" + + +# ─── Topology test (no LLM execution; checks compiled graph shape) ────────── + + +def test_manager_workers_compiles_to_hierarchical_graph_topology() -> None: + from langchain_core.messages import AIMessage + from langgraph.checkpoint.memory import MemorySaver + from langgraph.graph import END, START + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + _MANAGER_NODE_KEY, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + manager_agent = Agent( + name="Coordinator", + description="Coordinates", + system_prompt="Coordinate the team.", + llm_config=_llm_cfg("manager_llm"), + ) + worker_a = Agent( + name="Research Helper", + description="Handles research", + system_prompt="Research.", + llm_config=_llm_cfg("worker_a_llm"), + ) + worker_b = Agent( + name="Drafter", + description="Drafts text", + system_prompt="Draft.", + llm_config=_llm_cfg("worker_b_llm"), + ) + mw = ManagerWorkers( + name="ResearchTeam", + group_manager=manager_agent, + workers=[worker_a, worker_b], + ) + + fake_llm = _fake_manager(AIMessage(content="Done.")) + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, "_llm_convert_to_langgraph", return_value=fake_llm + ): + compiled = loader.load_component(mw) + + # The compiled object is a CompiledStateGraph; its builder exposes + # the parent topology we expect. + builder = compiled.builder + assert _MANAGER_NODE_KEY in builder.nodes + assert "research_helper" in builder.nodes + assert "drafter" in builder.nodes + + # START → manager; every worker → manager (loop). + edge_pairs = {(src, dst) for src, dst in builder.edges} + assert (START, _MANAGER_NODE_KEY) in edge_pairs + assert ("research_helper", _MANAGER_NODE_KEY) in edge_pairs + assert ("drafter", _MANAGER_NODE_KEY) in edge_pairs + + # The manager → worker routing is a conditional edge (branch), not a + # plain edge — branches are stored separately on the builder. + branches = builder.branches.get(_MANAGER_NODE_KEY) or {} + assert branches, "expected a conditional branch from the manager node" + + +def test_manager_workers_renders_workers_roster_into_manager_prompt() -> None: + """The manager's system prompt gets the ``Available workers:`` block + appended so the LLM knows which delegation tool maps to which worker. + """ + from langchain_core.messages import AIMessage + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + _MANAGER_NODE_KEY, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + manager_agent = Agent( + name="Coordinator", + description="Coordinates", + system_prompt="Coordinate the team.", + llm_config=_llm_cfg("manager_llm"), + ) + worker = Agent( + name="Research Helper", + description="Handles research tasks", + system_prompt="Research.", + llm_config=_llm_cfg("worker_llm"), + ) + mw = ManagerWorkers( + name="Team", + group_manager=manager_agent, + workers=[worker], + ) + + fake_llm = _fake_manager(AIMessage(content="Done.")) + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, "_llm_convert_to_langgraph", return_value=fake_llm + ): + compiled = loader.load_component(mw) + + # The manager react-agent is itself a subgraph; its create_agent + # middleware stack carries the rendered system prompt as the + # first message of every turn. Walk the manager subgraph's pre-model + # hook chain to find it. + manager_subgraph = compiled.builder.nodes[_MANAGER_NODE_KEY].runnable + # `create_agent` builds a graph whose system message generation + # wraps the prompt — easier to assert by re-rendering it through the + # same helper used by the converter and checking the *intent*. + from pyagentspec.adapters.langgraph._langgraphconverter import _append_workers_roster + + expected = _append_workers_roster( + "Coordinate the team.", + [("research_helper", "Handles research tasks")], + ) + assert "Available workers:" in expected + assert "- research_helper: Handles research tasks" in expected + # And the compiled manager carries the delegation tool the prompt + # advertises, proving the LLM has the matching contract. + tools_node = manager_subgraph.builder.nodes["tools"].runnable + assert "delegate_to_research_helper" in tools_node.tools_by_name + + +# ─── End-to-end execution test (offline, fake LLM emitting delegation) ────── + + +def test_manager_workers_delegates_and_routes_back_with_tool_message() -> None: + """End-to-end: manager LLM emits a delegate_to_ tool call, + the parent graph routes to the worker subgraph (which runs with an + isolated message context), the worker's final AIMessage content is + surfaced back to the manager as a ToolMessage matched to the + pending tool_call_id, and the manager's next turn (no tool call) + terminates the graph. This is the load-bearing path that proves the + subgraph composition actually works.""" + from langchain_core.messages import AIMessage, HumanMessage + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + manager_agent = Agent( + name="Coordinator", + description="Coordinates", + system_prompt="You coordinate.", + llm_config=_llm_cfg("manager_llm"), + ) + worker = Agent( + name="Research Helper", + description="Handles research", + system_prompt="You research.", + llm_config=_llm_cfg("worker_llm"), + ) + mw = ManagerWorkers( + name="Team", + group_manager=manager_agent, + workers=[worker], + ) + + # Manager turn 1: delegate to research_helper. + # Manager turn 2: produce final answer (no tool call → END). + manager_responses = [ + AIMessage( + content="", + tool_calls=[ + { + "name": "delegate_to_research_helper", + "args": {"task": "Look up Saturn"}, + "id": "call_1", + } + ], + ), + AIMessage(content="The worker reports: Saturn has rings."), + ] + # Worker turn 1: produce its own final answer. + worker_responses = [AIMessage(content="Saturn has rings.")] + + fake_manager = _fake_manager(*manager_responses) + fake_worker = _fake_manager(*worker_responses) + + def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: + if llm_config.name == "manager_llm": + return fake_manager + if llm_config.name == "worker_llm": + return fake_worker + raise AssertionError(f"unexpected llm_config: {llm_config.name}") + + # ``create_agent`` calls ``model.bind_tools(...)``. ``FakeMessagesListChatModel`` + # inherits ``bind_tools`` from real ``ChatOpenAI``, which calls out to + # OpenAI. Patch the class method so binding is a no-op that returns the + # same fake (preserving its response queue). + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=_dispatch, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **kw: self_obj, + ): + compiled = loader.load_component(mw) + + # Use the sync invocation path: ``FakeMessagesListChatModel`` provides + # a sync ``_generate`` (returns queued responses) but no async + # override, so MRO resolves ``_agenerate`` to the real + # ``ChatOpenAI._agenerate`` which calls the OpenAI API. The worker + # wrapper exposes both sync and async via RunnableLambda; LangGraph + # picks the sync path here. + result = compiled.invoke( + {"messages": [HumanMessage(content="Tell me about Saturn.")]}, + {"configurable": {"thread_id": "mw-1"}}, + ) + messages = result["messages"] + + # The end state should contain: user input, manager's delegation + # AIMessage, the synthesized ToolMessage (worker's reply), and the + # manager's final AIMessage. + msg_types = [type(m).__name__ for m in messages] + assert "HumanMessage" in msg_types + assert "ToolMessage" in msg_types + # Final message is the manager's terminating AIMessage. + assert isinstance(messages[-1], AIMessage) + assert "Saturn has rings" in messages[-1].content + + # And the ToolMessage carries the worker's reply matched to the + # pending delegation tool_call_id — proves the isolation wrapper + # threaded the call id through. + tool_msgs = [m for m in messages if type(m).__name__ == "ToolMessage"] + assert tool_msgs and tool_msgs[0].tool_call_id == "call_1" + assert "Saturn has rings" in tool_msgs[0].content + + +# ─── Recursive nesting ────────────────────────────────────────────────────── + + +def test_nested_manager_workers_compiles_recursively() -> None: + """A worker that is itself a ManagerWorkers compiles through the + same dispatch — the inner ManagerWorkers becomes a CompiledStateGraph + that the outer parent graph wires in as a subgraph node.""" + from langchain_core.messages import AIMessage + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + leaf = Agent( + name="Leaf", + description="Leaf task", + system_prompt="Leaf.", + llm_config=_llm_cfg("leaf_llm"), + ) + inner_manager = Agent( + name="InnerManager", + description="Inner", + system_prompt="Manage leaves.", + llm_config=_llm_cfg("inner_llm"), + ) + inner_mw = ManagerWorkers( + name="Inner", + group_manager=inner_manager, + workers=[leaf], + ) + outer_manager = Agent( + name="OuterManager", + description="Outer", + system_prompt="Manage subteams.", + llm_config=_llm_cfg("outer_llm"), + ) + outer_mw = ManagerWorkers( + name="Outer", + group_manager=outer_manager, + workers=[inner_mw], + ) + + fake_llm = _fake_manager(AIMessage(content="Done.")) + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, "_llm_convert_to_langgraph", return_value=fake_llm + ): + compiled = loader.load_component(outer_mw) + + # Outer parent graph has a node for the inner ManagerWorkers worker. + assert "inner" in compiled.builder.nodes + + +def test_rejects_non_agent_group_manager() -> None: + """ManagerWorkers.group_manager must be an Agent — pyagentspec allows + any AgenticComponent but the LangGraph adapter needs a chat-LLM that + emits tool_calls to decide which worker to delegate to.""" + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + # Use a nested ManagerWorkers as the group_manager — a valid + # AgenticComponent per pyagentspec validators, unsupported here. + leaf = Agent( + name="Leaf", + description="L", + system_prompt="L.", + llm_config=_llm_cfg("l"), + ) + inner_manager = Agent( + name="Inner", + description="I", + system_prompt="I.", + llm_config=_llm_cfg("i"), + ) + inner_mw = ManagerWorkers( + name="Inner", + group_manager=inner_manager, + workers=[leaf], + ) + outer_mw = ManagerWorkers( + name="Outer", + group_manager=inner_mw, + workers=[ + Agent(name="Other", description="O", system_prompt="O.", llm_config=_llm_cfg("o")), + ], + ) + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with pytest.raises(NotImplementedError, match="group_manager must be an Agent"): + loader.load_component(outer_mw) + + +# ─── Worker name collision ────────────────────────────────────────────────── + + +def test_workers_with_name_slug_collision_are_rejected() -> None: + """Two workers whose names normalize to the same node identifier + would silently overwrite each other in the parent graph; raise at + load time instead.""" + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + a = Agent(name="Helper A", description="x", system_prompt=".", llm_config=_llm_cfg("a")) + b = Agent(name="helper-a", description="x", system_prompt=".", llm_config=_llm_cfg("b")) + # Both normalize to "helper_a". + mw = ManagerWorkers(name="T", group_manager=Agent( + name="M", description="m", system_prompt=".", llm_config=_llm_cfg("m"), + ), workers=[a, b]) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with pytest.raises(ValueError, match="collide after normalization"): + loader.load_component(mw) From be28d2f9ef3a1c0dc9ca2cad05ff9c88198ae794 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Sat, 30 May 2026 15:13:19 +0400 Subject: [PATCH 02/34] refactor(adapters/langgraph): dedup ManagerWorkers converter helpers Collapse the duplicated dict-or-attribute tool-call accessor into a single _tc_get helper, hoist the inline re imports to module scope (with a precompiled whitespace pattern), and move the worker wrapper's message imports out of the per-invocation path. --- .../adapters/langgraph/_langgraphconverter.py | 45 +++++++++---------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index c7b9b72e..3541ab79 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -7,6 +7,7 @@ import inspect import logging +import re import sys from typing import ( TYPE_CHECKING, @@ -2124,6 +2125,10 @@ def _ensure_checkpointer_and_valid_tool_config( # the normalized worker node name. _DELEGATE_TOOL_PREFIX = "delegate_to_" +# Collapses any run of whitespace to a single space so multi-line worker +# descriptions stay on one roster line. +_WHITESPACE_RE = re.compile(r"\s+") + def _safe_node_name(name: str, fallback_id: str) -> str: """Normalize a worker name into a LangGraph node identifier. @@ -2137,14 +2142,21 @@ def _safe_node_name(name: str, fallback_id: str) -> str: name yields an empty string. Falling through both transforms keeps node names internally consistent regardless of which input wins. """ - import re as _re def _norm(s: str) -> str: - return _re.sub(r"[^a-z0-9]+", "_", (s or "").lower()).strip("_") + return re.sub(r"[^a-z0-9]+", "_", (s or "").lower()).strip("_") return _norm(name) or _norm(fallback_id) or "worker" +def _tc_get(tool_call: Any, key: str) -> Any: + """Read ``key`` off a tool call that may be a dict or a pydantic-style + object (langchain emits either depending on the message source).""" + if isinstance(tool_call, dict): + return tool_call.get(key) + return getattr(tool_call, key, None) + + def _append_workers_roster( system_prompt: str, entries: List[Tuple[str, str]], @@ -2158,11 +2170,8 @@ def _append_workers_roster( """ if not entries: return system_prompt - import re as _re - - _ws = _re.compile(r"\s+") lines = [ - f"- {name}: {_ws.sub(' ', description).strip()}" + f"- {name}: {_WHITESPACE_RE.sub(' ', description).strip()}" for name, description in entries ] roster = "Available workers:\n" + "\n".join(lines) @@ -2253,7 +2262,7 @@ def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> str: last = messages[-1] tool_calls = getattr(last, "tool_calls", None) or [] for tc in tool_calls: - name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None) + name = _tc_get(tc, "name") if isinstance(name, str) and name.startswith(_DELEGATE_TOOL_PREFIX): return name[len(_DELEGATE_TOOL_PREFIX):] return langgraph_graph.END @@ -2278,6 +2287,8 @@ def _wrap_worker_for_subgraph( (``afunc``) entrypoints — LangGraph picks the right one based on whether the parent graph is invoked via ``invoke`` or ``ainvoke``. """ + from langchain_core.messages import HumanMessage, ToolMessage + from pyagentspec.adapters.langgraph._types import RunnableLambda delegate_tool_name = f"{_DELEGATE_TOOL_PREFIX}{worker_node_name}" @@ -2291,11 +2302,7 @@ def _extract_pending(state: Dict[str, Any]) -> Tuple[str, str]: last_ai = messages[-1] tool_calls = getattr(last_ai, "tool_calls", None) or [] pending_call = next( - ( - tc for tc in tool_calls - if (tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)) - == delegate_tool_name - ), + (tc for tc in tool_calls if _tc_get(tc, "name") == delegate_tool_name), None, ) if pending_call is None: @@ -2303,24 +2310,14 @@ def _extract_pending(state: Dict[str, Any]) -> Tuple[str, str]: f"Worker '{worker_node_name}' was routed to but the manager's " f"last message has no '{delegate_tool_name}' tool call." ) - args = ( - pending_call.get("args") if isinstance(pending_call, dict) - else getattr(pending_call, "args", None) - ) or {} - call_id = ( - pending_call.get("id") if isinstance(pending_call, dict) - else getattr(pending_call, "id", None) - ) or "" + args = _tc_get(pending_call, "args") or {} + call_id = _tc_get(pending_call, "id") or "" return args.get("task") or "", call_id def _tool_message_from(reply: str, call_id: str) -> Dict[str, Any]: - from langchain_core.messages import ToolMessage - return {"messages": [ToolMessage(content=reply, tool_call_id=call_id)]} def _worker_input(task: str) -> Tuple[Dict[str, Any], Dict[str, Any]]: - from langchain_core.messages import HumanMessage - # Each worker run gets a fresh thread_id so its message history # is isolated across invocations — workers shouldn't accumulate # state across delegations within a single manager session, From 959afa225ea39c98f29081b5c4df8f61441e6628 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Wed, 8 Apr 2026 20:46:20 +0400 Subject: [PATCH 03/34] fix: remove output schema restriction for tools with requires_confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The output schema validation in _ensure_checkpointer_and_valid_tool_config rejected tools that had requires_confirmation=True with typed outputs. This is unnecessary — the output schema is metadata for the LLM, not a runtime constraint. LangGraph does not enforce tool output schemas, and both the approved path (normal typed output) and rejected path (plain string) work fine as ToolMessage content. Resolves https://github.com/oracle/agent-spec/issues/149 Signed-off-by: Salah Pichen --- .../adapters/langgraph/_langgraphconverter.py | 11 ----- .../tests/adapters/langgraph/test_tools.py | 42 +++++++++++++++++++ 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 3541ab79..61698c1a 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -2102,17 +2102,6 @@ def _ensure_checkpointer_and_valid_tool_config( elif isinstance(agentspec_tool, AgentSpecClientTool) and checkpointer is None: raise ValueError(f"A Checkpointer is required when using ClientTool '{tool_name}'.") - tool_output = agentspec_tool.outputs or [] - if agentspec_tool.requires_confirmation and ( - len(tool_output) != 1 or "type" in tool_output[0].json_schema - ): - # TODO: refine to only raise output property does not support string - raise ValueError( - f"Invalid output schema for tool '{tool_name}' requiring tool confirmation: " - f"json schema should be left unspecified when using tool confirmation, was {tool_output}. " - f'Please use outputs=[Property(title="{tool_name}", json_schema={{}})]' - ) - # ─── ManagerWorkers helpers ────────────────────────────────────────────────── diff --git a/pyagentspec/tests/adapters/langgraph/test_tools.py b/pyagentspec/tests/adapters/langgraph/test_tools.py index f27a0d82..ba58633a 100644 --- a/pyagentspec/tests/adapters/langgraph/test_tools.py +++ b/pyagentspec/tests/adapters/langgraph/test_tools.py @@ -837,6 +837,48 @@ async def double_tool_func(x: int) -> int: assert "10" in str(tool_result_message.content) +def test_server_tool_confirmation_with_typed_outputs_works() -> None: + """Tools with requires_confirmation and typed output schemas should load + without error. The output schema is metadata for the LLM, not a runtime + validation constraint, so it should not be rejected.""" + from langchain_core.runnables import RunnableConfig + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + + def bash_func(command: str) -> dict: + return {"stdout": "hello", "stderr": "", "exit_code": 0} + + server_tool = ServerTool( + name="bash", + description="Run a shell command", + inputs=[Property(title="command", json_schema={"title": "command", "type": "string"})], + outputs=[ + Property(title="stdout", json_schema={"title": "stdout", "type": "string"}), + Property(title="stderr", json_schema={"title": "stderr", "type": "string"}), + Property(title="exit_code", json_schema={"title": "exit_code", "type": "number"}), + ], + requires_confirmation=True, + ) + flow = _make_simple_flow_with_tool(ToolNode(name="bash_node", tool=server_tool)) + + # Should not raise ValueError about output schema + app = AgentSpecLoader( + tool_registry={"bash": bash_func}, + checkpointer=MemorySaver(), + ).load_component(flow) + + config = RunnableConfig({"configurable": {"thread_id": "typed-out-1"}}) + + interrupt_payload = _invoke_until_interrupt( + app, {"inputs": {"command": "echo hello"}}, config=config + ) + assert interrupt_payload["action_requests"][0]["name"] == "bash" + + result = app.invoke(_approve_command(), config=config) + assert result["outputs"] is not None + + def test_requires_confirmation_without_checkpointer_raises_for_server_tool_in_flow() -> None: from pyagentspec.adapters.langgraph import AgentSpecLoader From c1317cdbaf5acb50143d999b12060cb476193845 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Wed, 13 May 2026 14:34:02 +0400 Subject: [PATCH 04/34] fix: narrow confirmation+multi-output check to Flow ToolNode Allow typed output schemas on confirmation-required tools. Only reject multi-output declarations inside a Flow ToolNode, since the rejection denial string has no mapping to multiple outputs. Reworked the test to use an object-typed single output (the old helper only wired the first edge) and added a test for the multi-output rejection. --- .../adapters/langgraph/_langgraphconverter.py | 16 +++++ .../tests/adapters/langgraph/test_tools.py | 63 ++++++++++++++++--- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 61698c1a..9521eb0d 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -804,6 +804,22 @@ def _tool_node_convert_to_langgraph( ) -> "NodeExecutor": from pyagentspec.adapters.langgraph._node_execution import ToolNodeExecutor + # A rejected confirmation returns a single denial string, which cannot be + # split across multiple declared outputs of a Flow ToolNode. Catch this + # at load time rather than letting it surface as an opaque mapping error + # at runtime on the rejection path. + agentspec_tool = tool_node.tool + tool_outputs = agentspec_tool.outputs or [] + if agentspec_tool.requires_confirmation and len(tool_outputs) > 1: + raise ValueError( + f"Tool '{agentspec_tool.name}' declares multiple outputs and " + f"requires_confirmation=True inside Flow ToolNode '{tool_node.name}'. " + f"On rejection, the tool returns a single denial string, which " + f"cannot be mapped to multiple declared outputs. " + f"Use a single output (object-typed if structured data is needed), " + f"or move the tool out of the Flow ToolNode." + ) + tool = self.convert( tool_node.tool, tool_registry=tool_registry, diff --git a/pyagentspec/tests/adapters/langgraph/test_tools.py b/pyagentspec/tests/adapters/langgraph/test_tools.py index ba58633a..a8b88796 100644 --- a/pyagentspec/tests/adapters/langgraph/test_tools.py +++ b/pyagentspec/tests/adapters/langgraph/test_tools.py @@ -837,32 +837,42 @@ async def double_tool_func(x: int) -> int: assert "10" in str(tool_result_message.content) -def test_server_tool_confirmation_with_typed_outputs_works() -> None: - """Tools with requires_confirmation and typed output schemas should load - without error. The output schema is metadata for the LLM, not a runtime - validation constraint, so it should not be rejected.""" +def test_server_tool_confirmation_with_typed_object_output_works() -> None: + """Tools with requires_confirmation and a typed (object) output schema + should load without error and execute the approved path. The output schema + is metadata for the LLM, not a runtime constraint, and on rejection the + denial string maps cleanly into a single declared output.""" from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.memory import MemorySaver from pyagentspec.adapters.langgraph import AgentSpecLoader + bash_result = {"stdout": "hello", "stderr": "", "exit_code": 0} + def bash_func(command: str) -> dict: - return {"stdout": "hello", "stderr": "", "exit_code": 0} + return bash_result server_tool = ServerTool( name="bash", description="Run a shell command", inputs=[Property(title="command", json_schema={"title": "command", "type": "string"})], outputs=[ - Property(title="stdout", json_schema={"title": "stdout", "type": "string"}), - Property(title="stderr", json_schema={"title": "stderr", "type": "string"}), - Property(title="exit_code", json_schema={"title": "exit_code", "type": "number"}), + Property( + title="result", + json_schema={ + "type": "object", + "properties": { + "stdout": {"type": "string"}, + "stderr": {"type": "string"}, + "exit_code": {"type": "number"}, + }, + }, + ), ], requires_confirmation=True, ) flow = _make_simple_flow_with_tool(ToolNode(name="bash_node", tool=server_tool)) - # Should not raise ValueError about output schema app = AgentSpecLoader( tool_registry={"bash": bash_func}, checkpointer=MemorySaver(), @@ -876,7 +886,40 @@ def bash_func(command: str) -> dict: assert interrupt_payload["action_requests"][0]["name"] == "bash" result = app.invoke(_approve_command(), config=config) - assert result["outputs"] is not None + assert result["outputs"]["result"] == bash_result + + +def test_server_tool_confirmation_with_multi_output_in_flow_tool_node_raises() -> None: + """Multiple declared outputs combined with requires_confirmation cannot be + used inside a Flow ToolNode: on rejection, the tool returns a single denial + string that has no principled mapping to multiple outputs. We catch this at + load time with a clear error rather than letting it surface as an opaque + mapping failure at runtime.""" + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + + def bash_func(command: str) -> dict: + return {"stdout": "hello", "stderr": "", "exit_code": 0} + + server_tool = ServerTool( + name="bash", + description="Run a shell command", + inputs=[Property(title="command", json_schema={"title": "command", "type": "string"})], + outputs=[ + Property(title="stdout", json_schema={"type": "string"}), + Property(title="stderr", json_schema={"type": "string"}), + Property(title="exit_code", json_schema={"type": "number"}), + ], + requires_confirmation=True, + ) + flow = _make_simple_flow_with_tool(ToolNode(name="bash_node", tool=server_tool)) + + with pytest.raises(ValueError, match="multiple outputs"): + AgentSpecLoader( + tool_registry={"bash": bash_func}, + checkpointer=MemorySaver(), + ).load_component(flow) def test_requires_confirmation_without_checkpointer_raises_for_server_tool_in_flow() -> None: From 37497d539aa5647bcbbe4ef695b3383b3634ffad Mon Sep 17 00:00:00 2001 From: Salah Date: Fri, 29 May 2026 10:14:10 +0400 Subject: [PATCH 05/34] fix: extend raise_on_denial to RemoteTool/ClientTool and preserve mTLS CA trust boundary --- .../adapters/langgraph/_langgraphconverter.py | 87 +++++++--- .../adapters/langgraph/mcp_utils.py | 8 +- .../tests/adapters/langgraph/test_tools.py | 159 ++++++++++++++++-- 3 files changed, 218 insertions(+), 36 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 9521eb0d..01c90be9 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -804,30 +804,44 @@ def _tool_node_convert_to_langgraph( ) -> "NodeExecutor": from pyagentspec.adapters.langgraph._node_execution import ToolNodeExecutor - # A rejected confirmation returns a single denial string, which cannot be - # split across multiple declared outputs of a Flow ToolNode. Catch this - # at load time rather than letting it surface as an opaque mapping error - # at runtime on the rejection path. agentspec_tool = tool_node.tool tool_outputs = agentspec_tool.outputs or [] + + # When a confirmation tool declares multiple outputs, a denial returns a single + # string that cannot be mapped to those outputs. Bypass self.convert() for all + # affected tool types so we can pass raise_on_denial=True: on rejection the + # tool raises RuntimeError with a clear message instead of an opaque crash. if agentspec_tool.requires_confirmation and len(tool_outputs) > 1: - raise ValueError( - f"Tool '{agentspec_tool.name}' declares multiple outputs and " - f"requires_confirmation=True inside Flow ToolNode '{tool_node.name}'. " - f"On rejection, the tool returns a single denial string, which " - f"cannot be mapped to multiple declared outputs. " - f"Use a single output (object-typed if structured data is needed), " - f"or move the tool out of the Flow ToolNode." + _ensure_checkpointer_and_valid_tool_config(agentspec_tool, checkpointer) + if isinstance(agentspec_tool, AgentSpecServerTool): + tool = self._server_tool_convert_to_langgraph( + agentspec_tool, tool_registry, config=config, raise_on_denial=True + ) + elif isinstance(agentspec_tool, AgentSpecRemoteTool): + tool = self._remote_tool_convert_to_langgraph( + agentspec_tool, config=config, raise_on_denial=True + ) + elif isinstance(agentspec_tool, AgentSpecClientTool): + tool = self._client_tool_convert_to_langgraph( + agentspec_tool, raise_on_denial=True + ) + else: + raise ValueError( + f"Tool '{agentspec_tool.name}' of type " + f"'{type(agentspec_tool).__name__}' declares multiple outputs and " + f"requires_confirmation=True inside Flow ToolNode '{tool_node.name}'. " + f"Multi-output confirmation is supported for ServerTool, RemoteTool, " + f"and ClientTool. Use a single output or a supported tool type." + ) + else: + tool = self.convert( + tool_node.tool, + tool_registry=tool_registry, + converted_components=converted_components, + checkpointer=checkpointer, + config=config, ) - tool = self.convert( - tool_node.tool, - tool_registry=tool_registry, - converted_components=converted_components, - checkpointer=checkpointer, - config=config, - ) - return ToolNodeExecutor(tool_node, tool) def _end_node_convert_to_langgraph(self, end_node: AgentSpecEndNode) -> "NodeExecutor": @@ -844,6 +858,7 @@ def _remote_tool_convert_to_langgraph( self, remote_tool: AgentSpecRemoteTool, config: RunnableConfig, + raise_on_denial: bool = False, ) -> StructuredTool: tool_name = remote_tool.name tool_description = remote_tool.description or "" @@ -851,6 +866,7 @@ def _remote_tool_convert_to_langgraph( func=_create_remote_tool_func(remote_tool), tool_name=tool_name, requires_confirmation=remote_tool.requires_confirmation, + raise_on_denial=raise_on_denial, ) # Use a Pydantic model for args_schema @@ -876,6 +892,7 @@ def _server_tool_convert_to_langgraph( agentspec_server_tool: AgentSpecServerTool, tool_registry: Dict[str, LangGraphTool], config: RunnableConfig, + raise_on_denial: bool = False, ) -> StructuredTool: def _is_structured_tool(x: Any) -> TypeGuard[StructuredTool]: return isinstance(x, StructuredTool) @@ -904,6 +921,7 @@ def _is_structured_tool(x: Any) -> TypeGuard[StructuredTool]: tool_obj, tool_name=tool_name, requires_confirmation=requires_confirmation, + raise_on_denial=raise_on_denial, ) if _is_structured_tool(tool_obj): if not tool_callable_kwargs: @@ -953,7 +971,7 @@ def _is_structured_tool(x: Any) -> TypeGuard[StructuredTool]: ) def _client_tool_convert_to_langgraph( - self, agentspec_client_tool: AgentSpecClientTool + self, agentspec_client_tool: AgentSpecClientTool, raise_on_denial: bool = False ) -> StructuredTool: # Warn at load time for Python < 3.11 since client tools use interrupt under the hood. if sys.version_info < (3, 11): @@ -973,6 +991,11 @@ def client_tool(*args: Any, **kwargs: Any) -> Any: confirmed, reason = _confirm_tool_use(tool_name, **kwargs) if not confirmed: + if raise_on_denial: + raise RuntimeError( + f"Tool '{tool_name}' was denied by the user (reason: {reason}). " + f"Denial cannot be mapped to multiple declared outputs." + ) return f"Tool '{tool_name}' was denied execution by the user. Reason: {reason}" tool_request = { @@ -1989,6 +2012,7 @@ def _confirm_then( func: Callable[..., Awaitable[Any]], tool_name: str, requires_confirmation: bool, + raise_on_denial: bool = ..., ) -> Callable[..., Awaitable[Any]]: ... @@ -1997,6 +2021,7 @@ def _confirm_then( func: Callable[..., Any], tool_name: str, requires_confirmation: bool, + raise_on_denial: bool = ..., ) -> Callable[..., Any]: ... @@ -2004,8 +2029,14 @@ def _confirm_then( func: Callable[..., Any], tool_name: str, requires_confirmation: bool, + raise_on_denial: bool = False, ) -> Callable[..., Any]: - """Wrap a callable so that it first interrupts for confirmation (if required).""" + """Wrap a callable so that it first interrupts for confirmation (if required). + + When raise_on_denial is True, denial raises RuntimeError instead of returning a + string. Use this inside a Flow ToolNode with multiple outputs where a denial + string cannot be mapped to the declared output structure. + """ if not requires_confirmation: return func @@ -2016,6 +2047,11 @@ async def _wrapped_async(*args: Any, **kwargs: Any) -> Any: confirmed, reason = _confirm_tool_use(tool_name, **confirmation_arguments) if not confirmed: + if raise_on_denial: + raise RuntimeError( + f"Tool '{tool_name}' was denied by the user (reason: {reason}). " + f"Denial cannot be mapped to multiple declared outputs." + ) return f"Tool '{tool_name}' was denied execution by the user. Reason: {reason}" return await func(*args, **kwargs) @@ -2027,6 +2063,11 @@ def _wrapped_sync(*args: Any, **kwargs: Any) -> Any: confirmed, reason = _confirm_tool_use(tool_name, **confirmation_arguments) if not confirmed: + if raise_on_denial: + raise RuntimeError( + f"Tool '{tool_name}' was denied by the user (reason: {reason}). " + f"Denial cannot be mapped to multiple declared outputs." + ) return f"Tool '{tool_name}' was denied execution by the user. Reason: {reason}" return func(*args, **kwargs) @@ -2061,6 +2102,7 @@ def _get_structured_tool_callable_kwargs( tool_obj: Union[StructuredTool, BaseTool, Callable[..., Any]], tool_name: str, requires_confirmation: bool = False, + raise_on_denial: bool = False, ) -> StructuredToolCallableKwargs: """Return the callables to pass to StructuredTool. @@ -2080,6 +2122,7 @@ def _get_structured_tool_callable_kwargs( func=tool_func, tool_name=tool_name, requires_confirmation=requires_confirmation, + raise_on_denial=raise_on_denial, ) tool_coroutine = getattr(tool_obj, "coroutine", None) @@ -2088,12 +2131,14 @@ def _get_structured_tool_callable_kwargs( func=tool_coroutine, tool_name=tool_name, requires_confirmation=requires_confirmation, + raise_on_denial=raise_on_denial, ) elif callable(tool_obj): wrapped_tool = _confirm_then( func=tool_obj, tool_name=tool_name, requires_confirmation=requires_confirmation, + raise_on_denial=raise_on_denial, ) if _is_async_callable(wrapped_tool): structured_tool_callable_kwargs["coroutine"] = wrapped_tool diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py b/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py index a8090df7..9457e3df 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py @@ -38,9 +38,13 @@ def __init__( ): self.verify: bool | ssl.SSLContext if verify: - ssl_ctx = ssl.create_default_context() + # When a custom CA is provided, use it as the sole trust anchor (replacing the + # system CA bundle) so the trust boundary stays exactly as configured. + # When no custom CA is given, fall back to the system CA bundle. if ssl_ca_cert: - ssl_ctx.load_verify_locations(cafile=ssl_ca_cert) + ssl_ctx = ssl.create_default_context(cafile=ssl_ca_cert) + else: + ssl_ctx = ssl.create_default_context() if key_file or cert_file: # Client authentication requires both pieces of certificate material. diff --git a/pyagentspec/tests/adapters/langgraph/test_tools.py b/pyagentspec/tests/adapters/langgraph/test_tools.py index a8b88796..eb454b83 100644 --- a/pyagentspec/tests/adapters/langgraph/test_tools.py +++ b/pyagentspec/tests/adapters/langgraph/test_tools.py @@ -889,12 +889,117 @@ def bash_func(command: str) -> dict: assert result["outputs"]["result"] == bash_result -def test_server_tool_confirmation_with_multi_output_in_flow_tool_node_raises() -> None: - """Multiple declared outputs combined with requires_confirmation cannot be - used inside a Flow ToolNode: on rejection, the tool returns a single denial - string that has no principled mapping to multiple outputs. We catch this at - load time with a clear error rather than letting it surface as an opaque - mapping failure at runtime.""" +def _make_multi_output_flow_with_tool(tool_node): + """Build Start -> Tool -> End wiring all three bash outputs (stdout, stderr, exit_code).""" + from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge + from pyagentspec.flows.flow import Flow + from pyagentspec.flows.nodes import EndNode, StartNode + + start_node = StartNode( + name="start", + inputs=[Property(title="command", json_schema={"title": "command", "type": "string"})], + ) + end_node = EndNode( + name="end", + outputs=[ + Property(title="stdout", json_schema={"type": "string"}), + Property(title="stderr", json_schema={"type": "string"}), + Property(title="exit_code", json_schema={"type": "number"}), + ], + ) + return Flow( + name="flow", + start_node=start_node, + nodes=[start_node, tool_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_tool", from_node=start_node, to_node=tool_node), + ControlFlowEdge(name="tool_to_end", from_node=tool_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="cmd_edge", + source_node=start_node, + source_output="command", + destination_node=tool_node, + destination_input="command", + ), + DataFlowEdge( + name="stdout_edge", + source_node=tool_node, + source_output="stdout", + destination_node=end_node, + destination_input="stdout", + ), + DataFlowEdge( + name="stderr_edge", + source_node=tool_node, + source_output="stderr", + destination_node=end_node, + destination_input="stderr", + ), + DataFlowEdge( + name="exit_code_edge", + source_node=tool_node, + source_output="exit_code", + destination_node=end_node, + destination_input="exit_code", + ), + ], + ) + + +def _make_multi_output_bash_server_tool(): + return ServerTool( + name="bash", + description="Run a shell command", + inputs=[Property(title="command", json_schema={"title": "command", "type": "string"})], + outputs=[ + Property(title="stdout", json_schema={"type": "string"}), + Property(title="stderr", json_schema={"type": "string"}), + Property(title="exit_code", json_schema={"type": "number"}), + ], + requires_confirmation=True, + ) + + +def test_server_tool_confirmation_with_multi_output_in_flow_tool_node_approve_executes() -> None: + """A ServerTool with multiple outputs and requires_confirmation loads and executes + correctly when the user approves. The outputs are mapped from the returned dict.""" + from langchain_core.runnables import RunnableConfig + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + + bash_result = {"stdout": "hello", "stderr": "", "exit_code": 0} + + def bash_func(command: str) -> dict: + return bash_result + + server_tool = _make_multi_output_bash_server_tool() + flow = _make_multi_output_flow_with_tool(ToolNode(name="bash_node", tool=server_tool)) + + app = AgentSpecLoader( + tool_registry={"bash": bash_func}, + checkpointer=MemorySaver(), + ).load_component(flow) + + config = RunnableConfig({"configurable": {"thread_id": "multi-out-approve-1"}}) + interrupt_payload = _invoke_until_interrupt( + app, {"inputs": {"command": "echo hello"}}, config=config + ) + assert interrupt_payload["action_requests"][0]["name"] == "bash" + + result = app.invoke(_approve_command(), config=config) + assert result["outputs"]["stdout"] == "hello" + assert result["outputs"]["stderr"] == "" + assert result["outputs"]["exit_code"] == 0 + + +def test_server_tool_confirmation_with_multi_output_in_flow_tool_node_reject_raises() -> None: + """When a ServerTool with multiple outputs is denied inside a Flow ToolNode, a + RuntimeError is raised with a clear message rather than returning an unmappable + denial string.""" + from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.memory import MemorySaver from pyagentspec.adapters.langgraph import AgentSpecLoader @@ -902,7 +1007,30 @@ def test_server_tool_confirmation_with_multi_output_in_flow_tool_node_raises() - def bash_func(command: str) -> dict: return {"stdout": "hello", "stderr": "", "exit_code": 0} - server_tool = ServerTool( + server_tool = _make_multi_output_bash_server_tool() + flow = _make_multi_output_flow_with_tool(ToolNode(name="bash_node", tool=server_tool)) + + app = AgentSpecLoader( + tool_registry={"bash": bash_func}, + checkpointer=MemorySaver(), + ).load_component(flow) + + config = RunnableConfig({"configurable": {"thread_id": "multi-out-reject-1"}}) + _ = _invoke_until_interrupt(app, {"inputs": {"command": "echo hello"}}, config=config) + + with pytest.raises(Exception, match="denied"): + app.invoke(_reject_command("nope"), config=config) + + +def test_client_tool_confirmation_with_multi_output_in_flow_tool_node_reject_raises() -> None: + """When a ClientTool with multiple outputs is denied inside a Flow ToolNode, a + RuntimeError is raised with a clear message rather than an unmappable denial string.""" + from langchain_core.runnables import RunnableConfig + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + + client_tool = ClientTool( name="bash", description="Run a shell command", inputs=[Property(title="command", json_schema={"title": "command", "type": "string"})], @@ -913,13 +1041,18 @@ def bash_func(command: str) -> dict: ], requires_confirmation=True, ) - flow = _make_simple_flow_with_tool(ToolNode(name="bash_node", tool=server_tool)) + flow = _make_multi_output_flow_with_tool(ToolNode(name="bash_node", tool=client_tool)) - with pytest.raises(ValueError, match="multiple outputs"): - AgentSpecLoader( - tool_registry={"bash": bash_func}, - checkpointer=MemorySaver(), - ).load_component(flow) + app = AgentSpecLoader( + tool_registry={}, + checkpointer=MemorySaver(), + ).load_component(flow) + + config = RunnableConfig({"configurable": {"thread_id": "client-multi-out-reject-1"}}) + _ = _invoke_until_interrupt(app, {"inputs": {"command": "echo hello"}}, config=config) + + with pytest.raises(Exception, match="denied"): + app.invoke(_reject_command("nope"), config=config) def test_requires_confirmation_without_checkpointer_raises_for_server_tool_in_flow() -> None: From 9b68f825b407d8d34670ed586935aefcbf4dad74 Mon Sep 17 00:00:00 2001 From: Salah Date: Thu, 14 May 2026 09:31:46 +0400 Subject: [PATCH 06/34] Add generic LLM config support to TS SDK --- tsagentspec/src/component-registry.ts | 16 +++ tsagentspec/src/component.ts | 4 + tsagentspec/src/index.ts | 18 +++ tsagentspec/src/llms/gemini-auth-config.ts | 61 ++++++++ tsagentspec/src/llms/gemini-config.ts | 35 +++++ tsagentspec/src/llms/index.ts | 31 +++++ tsagentspec/src/llms/llm-config.ts | 57 ++++++++ tsagentspec/src/llms/oci-genai-config.ts | 66 ++++----- tsagentspec/src/llms/ollama-config.ts | 34 ++--- .../src/llms/openai-compatible-config.ts | 33 ++--- tsagentspec/src/llms/openai-config.ts | 32 ++--- tsagentspec/src/llms/retry-policy.ts | 33 +++++ tsagentspec/src/llms/vllm-config.ts | 34 ++--- tsagentspec/src/sensitive-field.ts | 9 +- .../builtin-deserialization-plugin.ts | 7 +- .../builtin-serialization-plugin.ts | 1 + .../serialization/serialization-context.ts | 1 + .../src/serialization/version-gates.ts | 31 +++++ tsagentspec/tests/llms/gemini-config.test.ts | 131 ++++++++++++++++++ .../tests/llms/llm-config-base.test.ts | 96 +++++++++++++ .../llms/openai-compatible-config.test.ts | 41 ++++++ tsagentspec/tests/llms/retry-policy.test.ts | 88 ++++++++++++ .../serialization/sensitive-fields.test.ts | 79 +++++++++++ .../serialization-context.test.ts | 12 ++ .../tests/serialization/version-gates.test.ts | 66 +++++++++ 25 files changed, 906 insertions(+), 110 deletions(-) create mode 100644 tsagentspec/src/llms/gemini-auth-config.ts create mode 100644 tsagentspec/src/llms/gemini-config.ts create mode 100644 tsagentspec/src/llms/retry-policy.ts create mode 100644 tsagentspec/tests/llms/gemini-config.test.ts create mode 100644 tsagentspec/tests/llms/llm-config-base.test.ts create mode 100644 tsagentspec/tests/llms/retry-policy.test.ts diff --git a/tsagentspec/src/component-registry.ts b/tsagentspec/src/component-registry.ts index d31e0a97..2f087334 100644 --- a/tsagentspec/src/component-registry.ts +++ b/tsagentspec/src/component-registry.ts @@ -19,11 +19,19 @@ import { createAgentSpecializationParameters, } from "./agents/specialized-agent.js"; +import { LlmConfigSchema, createLlmConfig } from "./llms/llm-config.js"; import { OpenAiCompatibleConfigSchema, createOpenAiCompatibleConfig } from "./llms/openai-compatible-config.js"; import { OllamaConfigSchema, createOllamaConfig } from "./llms/ollama-config.js"; import { VllmConfigSchema, createVllmConfig } from "./llms/vllm-config.js"; import { OpenAiConfigSchema, createOpenAiConfig } from "./llms/openai-config.js"; import { OciGenAiConfigSchema, createOciGenAiConfig } from "./llms/oci-genai-config.js"; +import { GeminiConfigSchema, createGeminiConfig } from "./llms/gemini-config.js"; +import { + GeminiAIStudioAuthConfigSchema, + GeminiVertexAIAuthConfigSchema, + createGeminiAIStudioAuthConfig, + createGeminiVertexAIAuthConfig, +} from "./llms/gemini-auth-config.js"; import { OciClientConfigWithApiKeySchema, OciClientConfigWithInstancePrincipalSchema, @@ -140,6 +148,10 @@ export const BUILTIN_SCHEMA_MAP: Record = { SpecializedAgent: SpecializedAgentSchema, AgentSpecializationParameters: AgentSpecializationParametersSchema, + LlmConfig: LlmConfigSchema, + GeminiConfig: GeminiConfigSchema, + GeminiAIStudioAuthConfig: GeminiAIStudioAuthConfigSchema, + GeminiVertexAIAuthConfig: GeminiVertexAIAuthConfigSchema, OpenAiCompatibleConfig: OpenAiCompatibleConfigSchema, OllamaConfig: OllamaConfigSchema, VllmConfig: VllmConfigSchema, @@ -211,6 +223,10 @@ export const BUILTIN_FACTORY_MAP: Record = { SpecializedAgent: createSpecializedAgent, AgentSpecializationParameters: createAgentSpecializationParameters, + LlmConfig: createLlmConfig, + GeminiConfig: createGeminiConfig, + GeminiAIStudioAuthConfig: createGeminiAIStudioAuthConfig, + GeminiVertexAIAuthConfig: createGeminiVertexAIAuthConfig, OpenAiCompatibleConfig: createOpenAiCompatibleConfig, OllamaConfig: createOllamaConfig, VllmConfig: createVllmConfig, diff --git a/tsagentspec/src/component.ts b/tsagentspec/src/component.ts index c5fb92d8..7c847319 100644 --- a/tsagentspec/src/component.ts +++ b/tsagentspec/src/component.ts @@ -80,6 +80,10 @@ export type ComponentTypeName = | "BuiltinTool" | "MCPTool" | "MCPToolSpec" + | "LlmConfig" + | "GeminiConfig" + | "GeminiAIStudioAuthConfig" + | "GeminiVertexAIAuthConfig" | "OpenAiCompatibleConfig" | "OllamaConfig" | "VllmConfig" diff --git a/tsagentspec/src/index.ts b/tsagentspec/src/index.ts index 854ff538..284e4397 100644 --- a/tsagentspec/src/index.ts +++ b/tsagentspec/src/index.ts @@ -59,11 +59,29 @@ export { SENSITIVE_FIELDS, isSensitiveField } from "./sensitive-field.js"; // LLM configs export { + LlmConfigBaseSchema, LlmConfigUnion, + LlmConfigSchema, LlmGenerationConfigSchema, OpenAIAPIType, + createLlmConfig, type LlmConfig, + type LlmConfigBase, type LlmGenerationConfig, + RetryPolicySchema, + JitterType, + type RetryPolicy, + GeminiAuthConfigUnion, + GeminiAIStudioAuthConfigSchema, + GeminiVertexAIAuthConfigSchema, + createGeminiAIStudioAuthConfig, + createGeminiVertexAIAuthConfig, + type GeminiAuthConfig, + type GeminiAIStudioAuthConfig, + type GeminiVertexAIAuthConfig, + GeminiConfigSchema, + createGeminiConfig, + type GeminiConfig, OpenAiCompatibleConfigSchema, createOpenAiCompatibleConfig, type OpenAiCompatibleConfig, diff --git a/tsagentspec/src/llms/gemini-auth-config.ts b/tsagentspec/src/llms/gemini-auth-config.ts new file mode 100644 index 00000000..68415e73 --- /dev/null +++ b/tsagentspec/src/llms/gemini-auth-config.ts @@ -0,0 +1,61 @@ +/** + * Gemini auth config components. + */ +import { z } from "zod"; +import { ComponentBaseSchema } from "../component.js"; + +export const GeminiAIStudioAuthConfigSchema = ComponentBaseSchema.extend({ + componentType: z.literal("GeminiAIStudioAuthConfig"), + apiKey: z.string().optional(), +}); +export type GeminiAIStudioAuthConfig = z.infer< + typeof GeminiAIStudioAuthConfigSchema +>; + +export const GeminiVertexAIAuthConfigSchema = ComponentBaseSchema.extend({ + componentType: z.literal("GeminiVertexAIAuthConfig"), + projectId: z.string().optional(), + location: z.string().default("global"), + credentials: z.union([z.string(), z.record(z.unknown())]).optional(), +}); +export type GeminiVertexAIAuthConfig = z.infer< + typeof GeminiVertexAIAuthConfigSchema +>; + +export const GeminiAuthConfigUnion = z.discriminatedUnion("componentType", [ + GeminiAIStudioAuthConfigSchema, + GeminiVertexAIAuthConfigSchema, +]); +export type GeminiAuthConfig = z.infer; + +export function createGeminiAIStudioAuthConfig(opts: { + name: string; + apiKey?: string; + id?: string; + description?: string; + metadata?: Record; +}): GeminiAIStudioAuthConfig { + return Object.freeze( + GeminiAIStudioAuthConfigSchema.parse({ + ...opts, + componentType: "GeminiAIStudioAuthConfig", + }), + ); +} + +export function createGeminiVertexAIAuthConfig(opts: { + name: string; + projectId?: string; + location?: string; + credentials?: string | Record; + id?: string; + description?: string; + metadata?: Record; +}): GeminiVertexAIAuthConfig { + return Object.freeze( + GeminiVertexAIAuthConfigSchema.parse({ + ...opts, + componentType: "GeminiVertexAIAuthConfig", + }), + ); +} diff --git a/tsagentspec/src/llms/gemini-config.ts b/tsagentspec/src/llms/gemini-config.ts new file mode 100644 index 00000000..0e45550f --- /dev/null +++ b/tsagentspec/src/llms/gemini-config.ts @@ -0,0 +1,35 @@ +/** + * Gemini LLM config. + */ +import { z } from "zod"; +import { LlmConfigBaseSchema, LlmGenerationConfigSchema } from "./llm-config.js"; +import { + GeminiAuthConfigUnion, + type GeminiAuthConfig, +} from "./gemini-auth-config.js"; +import { RetryPolicySchema } from "./retry-policy.js"; + +// provider is fixed to "google", url/apiKey/apiProvider/apiType are not applicable to Gemini. +export const GeminiConfigSchema = LlmConfigBaseSchema + .omit({ url: true, apiKey: true, apiProvider: true, provider: true, apiType: true }) + .extend({ + componentType: z.literal("GeminiConfig"), + auth: GeminiAuthConfigUnion, + }); + +export type GeminiConfig = z.infer; + +export function createGeminiConfig(opts: { + name: string; + modelId: string; + auth: GeminiAuthConfig; + id?: string; + description?: string; + metadata?: Record; + defaultGenerationParameters?: z.infer; + retryPolicy?: z.infer; +}): GeminiConfig { + return Object.freeze( + GeminiConfigSchema.parse({ ...opts, componentType: "GeminiConfig" }), + ); +} diff --git a/tsagentspec/src/llms/index.ts b/tsagentspec/src/llms/index.ts index 1d9a546b..041c0b12 100644 --- a/tsagentspec/src/llms/index.ts +++ b/tsagentspec/src/llms/index.ts @@ -2,29 +2,60 @@ * LLM config types barrel export. */ import { z } from "zod"; +import { LlmConfigSchema } from "./llm-config.js"; import { OpenAiCompatibleConfigSchema } from "./openai-compatible-config.js"; import { OllamaConfigSchema } from "./ollama-config.js"; import { VllmConfigSchema } from "./vllm-config.js"; import { OpenAiConfigSchema } from "./openai-config.js"; import { OciGenAiConfigSchema } from "./oci-genai-config.js"; +import { GeminiConfigSchema } from "./gemini-config.js"; /** Discriminated union of all LLM config types */ export const LlmConfigUnion = z.discriminatedUnion("componentType", [ + LlmConfigSchema, OpenAiCompatibleConfigSchema, OllamaConfigSchema, VllmConfigSchema, OpenAiConfigSchema, OciGenAiConfigSchema, + GeminiConfigSchema, ]); export type LlmConfig = z.infer; export { + LlmConfigBaseSchema, + LlmConfigSchema, LlmGenerationConfigSchema, OpenAIAPIType, + createLlmConfig, + type LlmConfigBase, type LlmGenerationConfig, } from "./llm-config.js"; +export { + RetryPolicySchema, + JitterType, + type RetryPolicy, +} from "./retry-policy.js"; + +export { + GeminiAuthConfigUnion, + GeminiAIStudioAuthConfigSchema, + GeminiVertexAIAuthConfigSchema, + createGeminiAIStudioAuthConfig, + createGeminiVertexAIAuthConfig, + type GeminiAuthConfig, + type GeminiAIStudioAuthConfig, + type GeminiVertexAIAuthConfig, +} from "./gemini-auth-config.js"; + +export { + GeminiConfigSchema, + createGeminiConfig, + type GeminiConfig, +} from "./gemini-config.js"; + export { OpenAiCompatibleConfigSchema, createOpenAiCompatibleConfig, diff --git a/tsagentspec/src/llms/llm-config.ts b/tsagentspec/src/llms/llm-config.ts index 5afb1e32..8a90d256 100644 --- a/tsagentspec/src/llms/llm-config.ts +++ b/tsagentspec/src/llms/llm-config.ts @@ -2,6 +2,8 @@ * LLM generation config and shared enums. */ import { z } from "zod"; +import { ComponentBaseSchema } from "../component.js"; +import { RetryPolicySchema } from "./retry-policy.js"; /** LlmGenerationConfig - NOT a Component, just a config object */ export const LlmGenerationConfigSchema = z @@ -21,3 +23,58 @@ export const OpenAIAPIType = { } as const; export type OpenAIAPIType = (typeof OpenAIAPIType)[keyof typeof OpenAIAPIType]; + +/** + * Shared base for all LLM config components. + * componentType is inherited as z.string() from ComponentBaseSchema; + * each concrete schema narrows it to its own literal. + */ +export const LlmConfigBaseSchema = ComponentBaseSchema.extend({ + modelId: z.string(), + provider: z.string().optional(), + apiProvider: z.string().optional(), + apiType: z.string().optional(), + url: z.string().optional(), + apiKey: z.string().optional(), + defaultGenerationParameters: LlmGenerationConfigSchema.optional(), + retryPolicy: RetryPolicySchema.optional(), +}); + +export type LlmConfigBase = z.infer; + +/** Shared fields for OpenAI-compatible runtimes that add TLS and require a URL */ +export const LocalInferenceFields = { + url: z.string(), + apiType: z + .enum([OpenAIAPIType.CHAT_COMPLETIONS, OpenAIAPIType.RESPONSES]) + .default(OpenAIAPIType.CHAT_COMPLETIONS), + keyFile: z.string().optional(), + certFile: z.string().optional(), + caFile: z.string().optional(), +} as const; + +/** Bare LlmConfig component - generic LLM configuration */ +export const LlmConfigSchema = LlmConfigBaseSchema.extend({ + componentType: z.literal("LlmConfig"), +}); + +export type LlmConfig = z.infer; + +export function createLlmConfig(opts: { + name: string; + modelId: string; + id?: string; + description?: string; + metadata?: Record; + provider?: string; + apiProvider?: string; + apiType?: string; + url?: string; + apiKey?: string; + defaultGenerationParameters?: z.infer; + retryPolicy?: z.infer; +}): LlmConfig { + return Object.freeze( + LlmConfigSchema.parse({ ...opts, componentType: "LlmConfig" }), + ); +} diff --git a/tsagentspec/src/llms/oci-genai-config.ts b/tsagentspec/src/llms/oci-genai-config.ts index 817b4e92..c8d3711e 100644 --- a/tsagentspec/src/llms/oci-genai-config.ts +++ b/tsagentspec/src/llms/oci-genai-config.ts @@ -2,9 +2,9 @@ * OCI GenAI LLM config. */ import { z } from "zod"; -import { ComponentBaseSchema } from "../component.js"; -import { LlmGenerationConfigSchema } from "./llm-config.js"; +import { LlmConfigBaseSchema, LlmGenerationConfigSchema } from "./llm-config.js"; import { OciClientConfigUnion, type OciClientConfig } from "./oci-client-config.js"; +import { RetryPolicySchema } from "./retry-policy.js"; /** Serving mode enum */ export const ServingMode = { @@ -20,6 +20,7 @@ export const ModelProvider = { GROK: "GROK", COHERE: "COHERE", OTHER: "OTHER", + XAI: "XAI", } as const; export type ModelProvider = (typeof ModelProvider)[keyof typeof ModelProvider]; @@ -33,32 +34,35 @@ export const OciAPIType = { export type OciAPIType = (typeof OciAPIType)[keyof typeof OciAPIType]; -export const OciGenAiConfigSchema = ComponentBaseSchema.extend({ - componentType: z.literal("OciGenAiConfig"), - modelId: z.string(), - compartmentId: z.string(), - servingMode: z - .enum([ServingMode.ON_DEMAND, ServingMode.DEDICATED]) - .default(ServingMode.ON_DEMAND), - provider: z - .enum([ - ModelProvider.META, - ModelProvider.GROK, - ModelProvider.COHERE, - ModelProvider.OTHER, - ]) - .optional(), - clientConfig: OciClientConfigUnion, - apiType: z - .enum([ - OciAPIType.OPENAI_CHAT_COMPLETIONS, - OciAPIType.OPENAI_RESPONSES, - OciAPIType.OCI, - ]) - .default(OciAPIType.OCI), - conversationStoreId: z.string().optional(), - defaultGenerationParameters: LlmGenerationConfigSchema.optional(), -}); +// apiProvider is fixed to "oci", url and apiKey are not applicable to OCI GenAI. +// provider and apiType are overridden with OCI-specific enums. +export const OciGenAiConfigSchema = LlmConfigBaseSchema + .omit({ url: true, apiKey: true, apiProvider: true, apiType: true }) + .extend({ + componentType: z.literal("OciGenAiConfig"), + compartmentId: z.string(), + servingMode: z + .enum([ServingMode.ON_DEMAND, ServingMode.DEDICATED]) + .default(ServingMode.ON_DEMAND), + provider: z + .enum([ + ModelProvider.META, + ModelProvider.GROK, + ModelProvider.COHERE, + ModelProvider.OTHER, + ModelProvider.XAI, + ]) + .optional(), + clientConfig: OciClientConfigUnion, + apiType: z + .enum([ + OciAPIType.OPENAI_CHAT_COMPLETIONS, + OciAPIType.OPENAI_RESPONSES, + OciAPIType.OCI, + ]) + .default(OciAPIType.OCI), + conversationStoreId: z.string().optional(), + }); export type OciGenAiConfig = z.infer; @@ -75,11 +79,9 @@ export function createOciGenAiConfig(opts: { apiType?: OciAPIType; conversationStoreId?: string; defaultGenerationParameters?: z.infer; + retryPolicy?: z.infer; }): OciGenAiConfig { return Object.freeze( - OciGenAiConfigSchema.parse({ - ...opts, - componentType: "OciGenAiConfig" as const, - }), + OciGenAiConfigSchema.parse({ ...opts, componentType: "OciGenAiConfig" }), ); } diff --git a/tsagentspec/src/llms/ollama-config.ts b/tsagentspec/src/llms/ollama-config.ts index a6ce241f..b86bdb75 100644 --- a/tsagentspec/src/llms/ollama-config.ts +++ b/tsagentspec/src/llms/ollama-config.ts @@ -2,19 +2,16 @@ * Ollama LLM config. */ import { z } from "zod"; -import { ComponentBaseSchema } from "../component.js"; -import { LlmGenerationConfigSchema, OpenAIAPIType } from "./llm-config.js"; +import { LlmConfigBaseSchema, LlmGenerationConfigSchema, LocalInferenceFields, OpenAIAPIType } from "./llm-config.js"; +import { RetryPolicySchema } from "./retry-policy.js"; -export const OllamaConfigSchema = ComponentBaseSchema.extend({ - componentType: z.literal("OllamaConfig"), - url: z.string(), - modelId: z.string(), - apiType: z - .enum([OpenAIAPIType.CHAT_COMPLETIONS, OpenAIAPIType.RESPONSES]) - .default(OpenAIAPIType.CHAT_COMPLETIONS), - defaultGenerationParameters: LlmGenerationConfigSchema.optional(), - apiKey: z.string().optional(), -}); +// apiProvider is fixed to "ollama" and excluded from serialization, so omitted here. +export const OllamaConfigSchema = LlmConfigBaseSchema + .omit({ apiProvider: true }) + .extend({ + componentType: z.literal("OllamaConfig"), + ...LocalInferenceFields, + }); export type OllamaConfig = z.infer; @@ -28,10 +25,13 @@ export function createOllamaConfig(opts: { apiType?: OpenAIAPIType; defaultGenerationParameters?: z.infer; apiKey?: string; + provider?: string; + keyFile?: string; + certFile?: string; + caFile?: string; + retryPolicy?: z.infer; }): OllamaConfig { - const parsed = OllamaConfigSchema.parse({ - ...opts, - componentType: "OllamaConfig" as const, - }); - return Object.freeze(parsed); + return Object.freeze( + OllamaConfigSchema.parse({ ...opts, componentType: "OllamaConfig" }), + ); } diff --git a/tsagentspec/src/llms/openai-compatible-config.ts b/tsagentspec/src/llms/openai-compatible-config.ts index 3098cd5d..1d9f6cf8 100644 --- a/tsagentspec/src/llms/openai-compatible-config.ts +++ b/tsagentspec/src/llms/openai-compatible-config.ts @@ -2,23 +2,15 @@ * OpenAI-compatible LLM config. */ import { z } from "zod"; -import { ComponentBaseSchema } from "../component.js"; -import { LlmGenerationConfigSchema, OpenAIAPIType } from "./llm-config.js"; +import { LlmConfigBaseSchema, LlmGenerationConfigSchema, LocalInferenceFields, OpenAIAPIType } from "./llm-config.js"; +import { RetryPolicySchema } from "./retry-policy.js"; -export const OpenAiCompatibleConfigSchema = ComponentBaseSchema.extend({ +export const OpenAiCompatibleConfigSchema = LlmConfigBaseSchema.extend({ componentType: z.literal("OpenAiCompatibleConfig"), - url: z.string(), - modelId: z.string(), - apiType: z - .enum([OpenAIAPIType.CHAT_COMPLETIONS, OpenAIAPIType.RESPONSES]) - .default(OpenAIAPIType.CHAT_COMPLETIONS), - defaultGenerationParameters: LlmGenerationConfigSchema.optional(), - apiKey: z.string().optional(), + ...LocalInferenceFields, }); -export type OpenAiCompatibleConfig = z.infer< - typeof OpenAiCompatibleConfigSchema ->; +export type OpenAiCompatibleConfig = z.infer; export function createOpenAiCompatibleConfig(opts: { name: string; @@ -30,11 +22,14 @@ export function createOpenAiCompatibleConfig(opts: { apiType?: OpenAIAPIType; defaultGenerationParameters?: z.infer; apiKey?: string; + apiProvider?: string; + provider?: string; + keyFile?: string; + certFile?: string; + caFile?: string; + retryPolicy?: z.infer; }): OpenAiCompatibleConfig { - const raw = { - ...opts, - componentType: "OpenAiCompatibleConfig" as const, - }; - const parsed = OpenAiCompatibleConfigSchema.parse(raw); - return Object.freeze(parsed); + return Object.freeze( + OpenAiCompatibleConfigSchema.parse({ ...opts, componentType: "OpenAiCompatibleConfig" }), + ); } diff --git a/tsagentspec/src/llms/openai-config.ts b/tsagentspec/src/llms/openai-config.ts index 4355fb38..ebc21ef8 100644 --- a/tsagentspec/src/llms/openai-config.ts +++ b/tsagentspec/src/llms/openai-config.ts @@ -2,18 +2,19 @@ * OpenAI config (no url field). */ import { z } from "zod"; -import { ComponentBaseSchema } from "../component.js"; -import { LlmGenerationConfigSchema, OpenAIAPIType } from "./llm-config.js"; +import { LlmConfigBaseSchema, LlmGenerationConfigSchema, OpenAIAPIType } from "./llm-config.js"; +import { RetryPolicySchema } from "./retry-policy.js"; -export const OpenAiConfigSchema = ComponentBaseSchema.extend({ - componentType: z.literal("OpenAiConfig"), - modelId: z.string(), - apiType: z - .enum([OpenAIAPIType.CHAT_COMPLETIONS, OpenAIAPIType.RESPONSES]) - .default(OpenAIAPIType.CHAT_COMPLETIONS), - defaultGenerationParameters: LlmGenerationConfigSchema.optional(), - apiKey: z.string().optional(), -}); +// provider and apiProvider are fixed to "openai" and excluded from serialization, +// so they are omitted from the schema. url is not applicable to OpenAI's hosted API. +export const OpenAiConfigSchema = LlmConfigBaseSchema + .omit({ url: true, provider: true, apiProvider: true }) + .extend({ + componentType: z.literal("OpenAiConfig"), + apiType: z + .enum([OpenAIAPIType.CHAT_COMPLETIONS, OpenAIAPIType.RESPONSES]) + .default(OpenAIAPIType.CHAT_COMPLETIONS), + }); export type OpenAiConfig = z.infer; @@ -26,10 +27,9 @@ export function createOpenAiConfig(opts: { apiType?: OpenAIAPIType; defaultGenerationParameters?: z.infer; apiKey?: string; + retryPolicy?: z.infer; }): OpenAiConfig { - const parsed = OpenAiConfigSchema.parse({ - ...opts, - componentType: "OpenAiConfig" as const, - }); - return Object.freeze(parsed); + return Object.freeze( + OpenAiConfigSchema.parse({ ...opts, componentType: "OpenAiConfig" }), + ); } diff --git a/tsagentspec/src/llms/retry-policy.ts b/tsagentspec/src/llms/retry-policy.ts new file mode 100644 index 00000000..89a0f384 --- /dev/null +++ b/tsagentspec/src/llms/retry-policy.ts @@ -0,0 +1,33 @@ +/** + * RetryPolicy config object (NOT a Component - no id/name). + */ +import { z } from "zod"; + +export const JitterType = { + EQUAL: "equal", + FULL: "full", + FULL_AND_EQUAL_FOR_THROTTLE: "full_and_equal_for_throttle", + DECORRELATED: "decorrelated", +} as const; +export type JitterType = (typeof JitterType)[keyof typeof JitterType]; + +const jitterValues = Object.values(JitterType) as [JitterType, ...JitterType[]]; + +export const RetryPolicySchema = z.object({ + maxAttempts: z.number().int().min(0).default(2), + requestTimeout: z.number().positive().optional(), + initialRetryDelay: z.number().min(0).default(1.0), + maxRetryDelay: z.number().min(0).default(8.0), + backoffFactor: z.number().positive().default(2.0), + jitter: z + .enum(jitterValues) + .nullable() + .optional() + .default(JitterType.FULL_AND_EQUAL_FOR_THROTTLE), + serviceErrorRetryOnAny5xx: z.boolean().default(true), + recoverableStatuses: z + .record(z.array(z.string())) + .default({ "409": [], "429": [] }), +}); + +export type RetryPolicy = z.infer; diff --git a/tsagentspec/src/llms/vllm-config.ts b/tsagentspec/src/llms/vllm-config.ts index a55b2af2..83e85a5e 100644 --- a/tsagentspec/src/llms/vllm-config.ts +++ b/tsagentspec/src/llms/vllm-config.ts @@ -2,19 +2,16 @@ * vLLM config. */ import { z } from "zod"; -import { ComponentBaseSchema } from "../component.js"; -import { LlmGenerationConfigSchema, OpenAIAPIType } from "./llm-config.js"; +import { LlmConfigBaseSchema, LlmGenerationConfigSchema, LocalInferenceFields, OpenAIAPIType } from "./llm-config.js"; +import { RetryPolicySchema } from "./retry-policy.js"; -export const VllmConfigSchema = ComponentBaseSchema.extend({ - componentType: z.literal("VllmConfig"), - url: z.string(), - modelId: z.string(), - apiType: z - .enum([OpenAIAPIType.CHAT_COMPLETIONS, OpenAIAPIType.RESPONSES]) - .default(OpenAIAPIType.CHAT_COMPLETIONS), - defaultGenerationParameters: LlmGenerationConfigSchema.optional(), - apiKey: z.string().optional(), -}); +// apiProvider is fixed to "vllm" and excluded from serialization, so omitted here. +export const VllmConfigSchema = LlmConfigBaseSchema + .omit({ apiProvider: true }) + .extend({ + componentType: z.literal("VllmConfig"), + ...LocalInferenceFields, + }); export type VllmConfig = z.infer; @@ -28,10 +25,13 @@ export function createVllmConfig(opts: { apiType?: OpenAIAPIType; defaultGenerationParameters?: z.infer; apiKey?: string; + provider?: string; + keyFile?: string; + certFile?: string; + caFile?: string; + retryPolicy?: z.infer; }): VllmConfig { - const parsed = VllmConfigSchema.parse({ - ...opts, - componentType: "VllmConfig" as const, - }); - return Object.freeze(parsed); + return Object.freeze( + VllmConfigSchema.parse({ ...opts, componentType: "VllmConfig" }), + ); } diff --git a/tsagentspec/src/sensitive-field.ts b/tsagentspec/src/sensitive-field.ts index a7b2f0a3..9da8fb85 100644 --- a/tsagentspec/src/sensitive-field.ts +++ b/tsagentspec/src/sensitive-field.ts @@ -7,9 +7,12 @@ export const SENSITIVE_FIELD_MARKER = "SENSITIVE_FIELD_MARKER" as const; /** Maps componentType -> set of field names that are sensitive */ export const SENSITIVE_FIELDS = { - OpenAiCompatibleConfig: new Set(["apiKey"]), - OllamaConfig: new Set(["apiKey"]), - VllmConfig: new Set(["apiKey"]), + LlmConfig: new Set(["apiKey"]), + GeminiAIStudioAuthConfig: new Set(["apiKey"]), + GeminiVertexAIAuthConfig: new Set(["credentials"]), + OpenAiCompatibleConfig: new Set(["apiKey", "keyFile", "certFile", "caFile"]), + OllamaConfig: new Set(["apiKey", "keyFile", "certFile", "caFile"]), + VllmConfig: new Set(["apiKey", "keyFile", "certFile", "caFile"]), OpenAiConfig: new Set(["apiKey"]), RemoteTool: new Set(["sensitiveHeaders"]), ApiNode: new Set(["sensitiveHeaders"]), diff --git a/tsagentspec/src/serialization/builtin-deserialization-plugin.ts b/tsagentspec/src/serialization/builtin-deserialization-plugin.ts index e3041d24..4bf4cd31 100644 --- a/tsagentspec/src/serialization/builtin-deserialization-plugin.ts +++ b/tsagentspec/src/serialization/builtin-deserialization-plugin.ts @@ -29,11 +29,7 @@ import { snakeToCamel } from "./serialization-context.js"; */ const PROPERTY_ARRAY_FIELDS = new Set(["inputs", "outputs"]); -/** - * Fields (camelCase) whose object values are model objects with snake_case keys - * that need conversion. All other object values are user data with preserved keys. - */ -const MODEL_OBJECT_FIELDS = new Set(["defaultGenerationParameters"]); +const MODEL_OBJECT_FIELDS = new Set(["defaultGenerationParameters", "retryPolicy"]); /** Deserialize a jsonSchema dict into a Property */ function deserializeProperty(value: unknown): Property { @@ -104,7 +100,6 @@ export class BuiltinsComponentDeserializationPlugin continue; } - // Handle model object fields (keys need snake_case -> camelCase) if ( MODEL_OBJECT_FIELDS.has(camelKey) && typeof value === "object" && diff --git a/tsagentspec/src/serialization/builtin-serialization-plugin.ts b/tsagentspec/src/serialization/builtin-serialization-plugin.ts index 93a47897..295395aa 100644 --- a/tsagentspec/src/serialization/builtin-serialization-plugin.ts +++ b/tsagentspec/src/serialization/builtin-serialization-plugin.ts @@ -20,6 +20,7 @@ const EXCLUDED_FIELDS = new Set(["componentType"]); */ const MODEL_OBJECT_FIELDS: Record = { defaultGenerationParameters: true, // LlmGenerationConfig - exclude nulls + retryPolicy: true, // RetryPolicy - exclude nulls }; function hasSerializedSensitiveValue(value: unknown): boolean { diff --git a/tsagentspec/src/serialization/serialization-context.ts b/tsagentspec/src/serialization/serialization-context.ts index b8aed4ce..4c3ae2db 100644 --- a/tsagentspec/src/serialization/serialization-context.ts +++ b/tsagentspec/src/serialization/serialization-context.ts @@ -33,6 +33,7 @@ export function camelToSnake(str: string): string { return str .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/([a-z])([0-9])/g, "$1_$2") .toLowerCase(); } diff --git a/tsagentspec/src/serialization/version-gates.ts b/tsagentspec/src/serialization/version-gates.ts index 3798c48c..059e2ab1 100644 --- a/tsagentspec/src/serialization/version-gates.ts +++ b/tsagentspec/src/serialization/version-gates.ts @@ -48,14 +48,45 @@ export const VERSION_GATED_FIELDS = { ManagerWorkers: { _self: AgentSpecVersion.V25_4_2, }, + LlmConfig: { + _self: AgentSpecVersion.V26_2_0, + }, + GeminiConfig: { + _self: AgentSpecVersion.V26_2_0, + }, OpenAiConfig: { apiType: AgentSpecVersion.V25_4_2, + apiKey: AgentSpecVersion.V25_4_2, + retryPolicy: AgentSpecVersion.V26_2_0, }, OpenAiCompatibleConfig: { apiType: AgentSpecVersion.V25_4_2, + apiKey: AgentSpecVersion.V25_4_2, + apiProvider: AgentSpecVersion.V26_2_0, + keyFile: AgentSpecVersion.V26_2_0, + certFile: AgentSpecVersion.V26_2_0, + caFile: AgentSpecVersion.V26_2_0, + provider: AgentSpecVersion.V26_2_0, + retryPolicy: AgentSpecVersion.V26_2_0, + }, + OllamaConfig: { + keyFile: AgentSpecVersion.V26_2_0, + certFile: AgentSpecVersion.V26_2_0, + caFile: AgentSpecVersion.V26_2_0, + provider: AgentSpecVersion.V26_2_0, + retryPolicy: AgentSpecVersion.V26_2_0, + }, + VllmConfig: { + keyFile: AgentSpecVersion.V26_2_0, + certFile: AgentSpecVersion.V26_2_0, + caFile: AgentSpecVersion.V26_2_0, + provider: AgentSpecVersion.V26_2_0, + retryPolicy: AgentSpecVersion.V26_2_0, }, OciGenAiConfig: { apiType: AgentSpecVersion.V25_4_2, + conversationStoreId: AgentSpecVersion.V25_4_2, + retryPolicy: AgentSpecVersion.V26_2_0, }, ApiNode: { sensitiveHeaders: AgentSpecVersion.V25_4_2, diff --git a/tsagentspec/tests/llms/gemini-config.test.ts b/tsagentspec/tests/llms/gemini-config.test.ts new file mode 100644 index 00000000..8f5b6458 --- /dev/null +++ b/tsagentspec/tests/llms/gemini-config.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; +import { + createGeminiConfig, + createGeminiAIStudioAuthConfig, + createGeminiVertexAIAuthConfig, + AgentSpecSerializer, + AgentSpecDeserializer, + AgentSpecVersion, +} from "../../src/index.js"; + +const serializer = new AgentSpecSerializer(); +const deserializer = new AgentSpecDeserializer(); + +function makeAIStudioAuth() { + return createGeminiAIStudioAuthConfig({ name: "auth", apiKey: "gk-test" }); +} + +function makeVertexAuth() { + return createGeminiVertexAIAuthConfig({ + name: "vertex-auth", + projectId: "my-project", + location: "us-central1", + }); +} + +describe("GeminiAIStudioAuthConfig", () => { + it("should create with required fields only", () => { + const auth = createGeminiAIStudioAuthConfig({ name: "auth" }); + expect(auth.componentType).toBe("GeminiAIStudioAuthConfig"); + expect(auth.apiKey).toBeUndefined(); + expect(auth.id).toBeDefined(); + expect(Object.isFrozen(auth)).toBe(true); + }); + + it("should accept optional apiKey", () => { + const auth = createGeminiAIStudioAuthConfig({ name: "auth", apiKey: "gk-abc" }); + expect(auth.apiKey).toBe("gk-abc"); + }); +}); + +describe("GeminiVertexAIAuthConfig", () => { + it("should create with required fields only", () => { + const auth = createGeminiVertexAIAuthConfig({ name: "va" }); + expect(auth.componentType).toBe("GeminiVertexAIAuthConfig"); + expect(auth.location).toBe("global"); + expect(auth.projectId).toBeUndefined(); + expect(Object.isFrozen(auth)).toBe(true); + }); + + it("should accept projectId, location, and credentials", () => { + const auth = createGeminiVertexAIAuthConfig({ + name: "va", + projectId: "proj-1", + location: "europe-west1", + credentials: { key: "value" }, + }); + expect(auth.projectId).toBe("proj-1"); + expect(auth.location).toBe("europe-west1"); + expect(auth.credentials).toEqual({ key: "value" }); + }); +}); + +describe("GeminiConfig", () => { + it("should create with AIStudio auth", () => { + const config = createGeminiConfig({ + name: "gemini", + modelId: "gemini-1.5-pro", + auth: makeAIStudioAuth(), + }); + expect(config.componentType).toBe("GeminiConfig"); + expect(config.modelId).toBe("gemini-1.5-pro"); + expect(config.auth.componentType).toBe("GeminiAIStudioAuthConfig"); + expect(Object.isFrozen(config)).toBe(true); + }); + + it("should create with VertexAI auth", () => { + const config = createGeminiConfig({ + name: "gemini", + modelId: "gemini-1.5-flash", + auth: makeVertexAuth(), + }); + expect(config.auth.componentType).toBe("GeminiVertexAIAuthConfig"); + }); + + it("should serialise to snake_case YAML", () => { + const config = createGeminiConfig({ + id: "test-id", + name: "gemini", + modelId: "gemini-1.5-pro", + auth: createGeminiAIStudioAuthConfig({ id: "auth-id", name: "auth" }), + }); + const yaml = serializer.toYaml(config); + expect(yaml).toContain("component_type: GeminiConfig"); + expect(yaml).toContain("model_id: gemini-1.5-pro"); + expect(yaml).toContain("component_type: GeminiAIStudioAuthConfig"); + }); + + it("should exclude apiKey from serialised auth", () => { + const config = createGeminiConfig({ + id: "test-id", + name: "gemini", + modelId: "gemini-1.5-pro", + auth: createGeminiAIStudioAuthConfig({ id: "auth-id", name: "auth", apiKey: "gk-secret" }), + }); + const yaml = serializer.toYaml(config); + expect(yaml).not.toContain("gk-secret"); + }); + + it("should round-trip without sensitive fields", () => { + const config = createGeminiConfig({ + id: "test-id", + name: "gemini", + modelId: "gemini-1.5-pro", + auth: createGeminiAIStudioAuthConfig({ id: "auth-id", name: "auth" }), + }); + const yaml = serializer.toYaml(config); + const restored = deserializer.fromYaml(yaml); + expect(restored).toEqual(config); + }); + + it("should throw when serialising at version before 26.2.0", () => { + const config = createGeminiConfig({ + name: "gemini", + modelId: "gemini-1.5-pro", + auth: makeAIStudioAuth(), + }); + expect(() => + serializer.toYaml(config, { agentspecVersion: AgentSpecVersion.V25_4_2 }), + ).toThrow(/26\.2\.0/); + }); +}); diff --git a/tsagentspec/tests/llms/llm-config-base.test.ts b/tsagentspec/tests/llms/llm-config-base.test.ts new file mode 100644 index 00000000..3b82eedf --- /dev/null +++ b/tsagentspec/tests/llms/llm-config-base.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { + createLlmConfig, + AgentSpecSerializer, + AgentSpecDeserializer, + AgentSpecVersion, +} from "../../src/index.js"; + +const serializer = new AgentSpecSerializer(); +const deserializer = new AgentSpecDeserializer(); + +describe("LlmConfig (bare)", () => { + it("should create with only required fields", () => { + const config = createLlmConfig({ name: "generic", modelId: "gpt-4o" }); + expect(config.componentType).toBe("LlmConfig"); + expect(config.modelId).toBe("gpt-4o"); + expect(config.provider).toBeUndefined(); + expect(config.apiProvider).toBeUndefined(); + expect(config.apiType).toBeUndefined(); + expect(config.url).toBeUndefined(); + expect(config.apiKey).toBeUndefined(); + }); + + it("should accept all optional fields", () => { + const config = createLlmConfig({ + name: "generic", + modelId: "gpt-4o", + provider: "openai", + apiProvider: "openai", + apiType: "chat_completions", + url: "https://api.openai.com/v1", + apiKey: "sk-test", + }); + expect(config.provider).toBe("openai"); + expect(config.apiProvider).toBe("openai"); + expect(config.apiType).toBe("chat_completions"); + expect(config.url).toBe("https://api.openai.com/v1"); + expect(config.apiKey).toBe("sk-test"); + }); + + it("should auto-generate an id and be frozen", () => { + const config = createLlmConfig({ name: "generic", modelId: "m" }); + expect(config.id).toBeDefined(); + expect(Object.isFrozen(config)).toBe(true); + }); + + it("should serialise to snake_case YAML with expected fields", () => { + const config = createLlmConfig({ + id: "test-id", + name: "generic", + modelId: "gpt-4o", + provider: "openai", + apiProvider: "openai", + apiType: "chat_completions", + }); + const yaml = serializer.toYaml(config); + expect(yaml).toContain("component_type: LlmConfig"); + expect(yaml).toContain("model_id: gpt-4o"); + expect(yaml).toContain("provider: openai"); + expect(yaml).toContain("api_provider: openai"); + expect(yaml).toContain("api_type: chat_completions"); + }); + + it("should exclude apiKey from serialised output (sensitive field)", () => { + const config = createLlmConfig({ + id: "test-id", + name: "generic", + modelId: "gpt-4o", + apiKey: "sk-secret", + }); + const yaml = serializer.toYaml(config); + expect(yaml).not.toContain("sk-secret"); + }); + + it("should round-trip without apiKey", () => { + const config = createLlmConfig({ + id: "test-id", + name: "generic", + modelId: "gpt-4o", + provider: "openai", + apiProvider: "openai", + apiType: "chat_completions", + url: "https://api.openai.com/v1", + }); + const yaml = serializer.toYaml(config); + const restored = deserializer.fromYaml(yaml); + expect(restored).toEqual(config); + }); + + it("should throw when serialising at version before 26.2.0", () => { + const config = createLlmConfig({ name: "generic", modelId: "m" }); + expect(() => + serializer.toYaml(config, { agentspecVersion: AgentSpecVersion.V25_4_2 }), + ).toThrow(/26\.2\.0/); + }); +}); diff --git a/tsagentspec/tests/llms/openai-compatible-config.test.ts b/tsagentspec/tests/llms/openai-compatible-config.test.ts index 4b15bb74..e1849097 100644 --- a/tsagentspec/tests/llms/openai-compatible-config.test.ts +++ b/tsagentspec/tests/llms/openai-compatible-config.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { createOpenAiCompatibleConfig, OpenAIAPIType, + AgentSpecSerializer, + AgentSpecDeserializer, } from "../../src/index.js"; describe("OpenAiCompatibleConfig", () => { @@ -101,4 +103,43 @@ describe("OpenAiCompatibleConfig", () => { }); expect(config.metadata).toEqual({}); }); + + it("should accept TLS fields", () => { + const config = createOpenAiCompatibleConfig({ + name: "test", + url: "https://localhost", + modelId: "model1", + keyFile: "/path/to/client.key", + certFile: "/path/to/client.crt", + caFile: "/path/to/ca.crt", + }); + expect(config.keyFile).toBe("/path/to/client.key"); + expect(config.certFile).toBe("/path/to/client.crt"); + expect(config.caFile).toBe("/path/to/ca.crt"); + }); + + it("should accept provider field", () => { + const config = createOpenAiCompatibleConfig({ + name: "test", + url: "http://localhost", + modelId: "model1", + provider: "custom-provider", + }); + expect(config.provider).toBe("custom-provider"); + }); + + it("should round-trip with provider field", () => { + const serializer = new AgentSpecSerializer(); + const deserializer = new AgentSpecDeserializer(); + const config = createOpenAiCompatibleConfig({ + id: "test-id", + name: "test", + url: "https://localhost", + modelId: "model1", + provider: "my-provider", + }); + const yaml = serializer.toYaml(config); + const restored = deserializer.fromYaml(yaml); + expect(restored).toEqual(config); + }); }); diff --git a/tsagentspec/tests/llms/retry-policy.test.ts b/tsagentspec/tests/llms/retry-policy.test.ts new file mode 100644 index 00000000..6007f127 --- /dev/null +++ b/tsagentspec/tests/llms/retry-policy.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { + RetryPolicySchema, + JitterType, + AgentSpecSerializer, + AgentSpecDeserializer, + createOpenAiCompatibleConfig, +} from "../../src/index.js"; + +describe("RetryPolicy", () => { + it("should parse with all defaults", () => { + const policy = RetryPolicySchema.parse({}); + expect(policy.maxAttempts).toBe(2); + expect(policy.initialRetryDelay).toBe(1.0); + expect(policy.maxRetryDelay).toBe(8.0); + expect(policy.backoffFactor).toBe(2.0); + expect(policy.jitter).toBe(JitterType.FULL_AND_EQUAL_FOR_THROTTLE); + expect(policy.serviceErrorRetryOnAny5xx).toBe(true); + expect(policy.recoverableStatuses).toEqual({ "409": [], "429": [] }); + }); + + it("should accept custom values", () => { + const policy = RetryPolicySchema.parse({ + maxAttempts: 5, + requestTimeout: 30, + initialRetryDelay: 0.5, + maxRetryDelay: 60, + backoffFactor: 1.5, + jitter: JitterType.EQUAL, + serviceErrorRetryOnAny5xx: false, + recoverableStatuses: { "503": ["Unavailable"] }, + }); + expect(policy.maxAttempts).toBe(5); + expect(policy.requestTimeout).toBe(30); + expect(policy.jitter).toBe(JitterType.EQUAL); + expect(policy.serviceErrorRetryOnAny5xx).toBe(false); + expect(policy.recoverableStatuses).toEqual({ "503": ["Unavailable"] }); + }); + + it("should allow jitter to be null", () => { + const policy = RetryPolicySchema.parse({ jitter: null }); + expect(policy.jitter).toBeNull(); + }); + + it("should reject negative maxAttempts", () => { + expect(() => RetryPolicySchema.parse({ maxAttempts: -1 })).toThrow(); + }); + + it("should reject non-positive backoffFactor", () => { + expect(() => RetryPolicySchema.parse({ backoffFactor: 0 })).toThrow(); + }); + + it("should round-trip through serialization with non-default values", () => { + const serializer = new AgentSpecSerializer(); + const deserializer = new AgentSpecDeserializer(); + const config = createOpenAiCompatibleConfig({ + id: "test-id", + name: "llm", + url: "http://localhost", + modelId: "model", + retryPolicy: { + maxAttempts: 5, + serviceErrorRetryOnAny5xx: false, + jitter: JitterType.EQUAL, + recoverableStatuses: { "503": ["ServiceUnavailable"] }, + }, + }); + const yaml = serializer.toYaml(config); + expect(yaml).toContain("service_error_retry_on_any_5xx: false"); + expect(yaml).toContain("max_attempts: 5"); + const restored = deserializer.fromYaml(yaml) as typeof config; + expect(restored.retryPolicy?.maxAttempts).toBe(5); + expect(restored.retryPolicy?.serviceErrorRetryOnAny5xx).toBe(false); + expect(restored.retryPolicy?.jitter).toBe(JitterType.EQUAL); + expect(restored.retryPolicy?.recoverableStatuses).toEqual({ + "503": ["ServiceUnavailable"], + }); + }); + + it("should expose all JitterType values", () => { + expect(JitterType.EQUAL).toBe("equal"); + expect(JitterType.FULL).toBe("full"); + expect(JitterType.FULL_AND_EQUAL_FOR_THROTTLE).toBe( + "full_and_equal_for_throttle", + ); + expect(JitterType.DECORRELATED).toBe("decorrelated"); + }); +}); diff --git a/tsagentspec/tests/serialization/sensitive-fields.test.ts b/tsagentspec/tests/serialization/sensitive-fields.test.ts index 29453115..efe2b2f4 100644 --- a/tsagentspec/tests/serialization/sensitive-fields.test.ts +++ b/tsagentspec/tests/serialization/sensitive-fields.test.ts @@ -7,6 +7,9 @@ import { createVllmConfig, createOpenAiConfig, createRemoteTool, + createGeminiConfig, + createGeminiAIStudioAuthConfig, + createGeminiVertexAIAuthConfig, stringProperty, } from "../../src/index.js"; @@ -119,6 +122,82 @@ describe("sensitive field exclusion", () => { expect("sensitive_headers" in tools[0]!).toBe(false); }); + it("should exclude apiKey from GeminiAIStudioAuthConfig", () => { + const serializer = new AgentSpecSerializer(); + const auth = createGeminiAIStudioAuthConfig({ id: "auth-id", name: "auth", apiKey: "gk-secret" }); + const config = createGeminiConfig({ + id: "gemini-id", + name: "gemini", + modelId: "gemini-1.5-pro", + auth, + }); + const yaml = serializer.toYaml(config); + expect(yaml).not.toContain("gk-secret"); + expect(yaml).not.toContain("api_key"); + }); + + it("should exclude credentials from GeminiVertexAIAuthConfig", () => { + const serializer = new AgentSpecSerializer(); + const auth = createGeminiVertexAIAuthConfig({ + id: "va-id", + name: "va", + credentials: { private_key: "secret-key-data" }, + }); + const config = createGeminiConfig({ + id: "gemini-id", + name: "gemini", + modelId: "gemini-1.5-pro", + auth, + }); + const yaml = serializer.toYaml(config); + expect(yaml).not.toContain("secret-key-data"); + expect(yaml).not.toContain("credentials"); + }); + + it("should exclude TLS cert fields from OpenAiCompatibleConfig", () => { + const serializer = new AgentSpecSerializer(); + const llm = createOpenAiCompatibleConfig({ + name: "llm", + url: "https://localhost", + modelId: "model", + keyFile: "/secret/client.key", + certFile: "/secret/client.crt", + caFile: "/secret/ca.crt", + }); + const agent = createAgent({ + name: "agent", + llmConfig: llm, + systemPrompt: "Hello", + }); + const json = serializer.toJson(agent) as string; + const dict = JSON.parse(json); + const llmDict = dict["llm_config"] as Record; + expect("key_file" in llmDict).toBe(false); + expect("cert_file" in llmDict).toBe(false); + expect("ca_file" in llmDict).toBe(false); + }); + + it("should include sensitive fields when includeSensitiveFields is true", () => { + const serializer = new AgentSpecSerializer(); + const llm = createOpenAiCompatibleConfig({ + name: "llm", + url: "http://localhost", + modelId: "gpt-4", + apiKey: "sk-secret", + keyFile: "/secret/client.key", + }); + const agent = createAgent({ + name: "agent", + llmConfig: llm, + systemPrompt: "Hello", + }); + const json = serializer.toJson(agent, { includeSensitiveFields: true }) as string; + const dict = JSON.parse(json); + const llmDict = dict["llm_config"] as Record; + expect(llmDict["api_key"]).toBe("sk-secret"); + expect(llmDict["key_file"]).toBe("/secret/client.key"); + }); + it("should keep non-sensitive fields intact", () => { const serializer = new AgentSpecSerializer(); const llm = createOpenAiCompatibleConfig({ diff --git a/tsagentspec/tests/serialization/serialization-context.test.ts b/tsagentspec/tests/serialization/serialization-context.test.ts index d74d4cf7..3331de2b 100644 --- a/tsagentspec/tests/serialization/serialization-context.test.ts +++ b/tsagentspec/tests/serialization/serialization-context.test.ts @@ -408,6 +408,18 @@ describe("camelToSnake edge cases", () => { it("should handle empty string", () => { expect(camelToSnake("")).toBe(""); }); + + it("should insert underscore between lowercase and digit", () => { + expect(camelToSnake("serviceErrorRetryOnAny5xx")).toBe( + "service_error_retry_on_any_5xx", + ); + }); + + it("should round-trip through snakeToCamel for digit-containing names", () => { + const snake = camelToSnake("serviceErrorRetryOnAny5xx"); + expect(snake).toBe("service_error_retry_on_any_5xx"); + expect(snakeToCamel(snake)).toBe("serviceErrorRetryOnAny5xx"); + }); }); describe("snakeToCamel edge cases", () => { diff --git a/tsagentspec/tests/serialization/version-gates.test.ts b/tsagentspec/tests/serialization/version-gates.test.ts index 603e5d01..6ff56048 100644 --- a/tsagentspec/tests/serialization/version-gates.test.ts +++ b/tsagentspec/tests/serialization/version-gates.test.ts @@ -8,6 +8,9 @@ import { createBuiltinTool, createMCPToolBox, createStdioTransport, + createLlmConfig, + createGeminiConfig, + createGeminiAIStudioAuthConfig, stringProperty, } from "../../src/index.js"; @@ -187,6 +190,69 @@ describe("version-gated field serialization", () => { expect("requires_confirmation" in tools[0]!).toBe(true); }); + it("should throw when serializing LlmConfig at version before 26.2.0", () => { + const serializer = new AgentSpecSerializer(); + const llm = createLlmConfig({ name: "generic", modelId: "gpt-4o" }); + expect(() => + serializer.toYaml(llm, { agentspecVersion: AgentSpecVersion.V25_4_2 }), + ).toThrow(/26\.2\.0/); + }); + + it("should throw when serializing GeminiConfig at version before 26.2.0", () => { + const serializer = new AgentSpecSerializer(); + const gemini = createGeminiConfig({ + name: "gemini", + modelId: "gemini-1.5-pro", + auth: createGeminiAIStudioAuthConfig({ name: "auth" }), + }); + expect(() => + serializer.toYaml(gemini, { agentspecVersion: AgentSpecVersion.V25_4_2 }), + ).toThrow(/26\.2\.0/); + }); + + it("should exclude retryPolicy from OpenAiCompatibleConfig for versions before 26.2.0", () => { + const serializer = new AgentSpecSerializer(); + const llm = createOpenAiCompatibleConfig({ + name: "llm", + url: "http://localhost", + modelId: "model", + retryPolicy: { maxAttempts: 5 }, + }); + const agent = createAgent({ + name: "agent", + llmConfig: llm, + systemPrompt: "Hello", + }); + const json = serializer.toJson(agent, { + agentspecVersion: AgentSpecVersion.V25_4_2, + }) as string; + const dict = JSON.parse(json); + const llmDict = dict["llm_config"] as Record; + expect("retry_policy" in llmDict).toBe(false); + }); + + it("should include retryPolicy from OpenAiCompatibleConfig for version 26.2.0+", () => { + const serializer = new AgentSpecSerializer(); + const llm = createOpenAiCompatibleConfig({ + name: "llm", + url: "http://localhost", + modelId: "model", + retryPolicy: { maxAttempts: 5 }, + }); + const agent = createAgent({ + name: "agent", + llmConfig: llm, + systemPrompt: "Hello", + }); + const json = serializer.toJson(agent, { + agentspecVersion: AgentSpecVersion.V26_2_0, + }) as string; + const dict = JSON.parse(json); + const llmDict = dict["llm_config"] as Record; + expect("retry_policy" in llmDict).toBe(true); + expect((llmDict["retry_policy"] as Record)["max_attempts"]).toBe(5); + }); + it("should throw when serializing BuiltinTool at version before 25.4.2", () => { const serializer = new AgentSpecSerializer(); const tool = createBuiltinTool({ From 27fe85540189126b07e553ed8921f4928f67935e Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Sat, 30 May 2026 18:35:00 +0400 Subject: [PATCH 07/34] fix(langgraph): thread middleware through ManagerWorkers conversion _manager_workers_convert_to_langgraph did not accept or forward the middleware argument, so its call to _create_react_agent_with_given_info (a required keyword-only arg) raised TypeError. Thread middleware through the dispatcher, the method signature, recursive worker conversion, and the manager-graph creation, matching the swarm/agent/flow paths. --- .../src/pyagentspec/adapters/langgraph/_langgraphconverter.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 01c90be9..b2413bc3 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -285,6 +285,7 @@ def _convert( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) elif isinstance(agentspec_component, AgentSpecLlmConfig): return self._llm_convert_to_langgraph(agentspec_component, config=config) @@ -1169,6 +1170,7 @@ def _manager_workers_convert_to_langgraph( converted_components: Dict[str, Any], checkpointer: Optional[Checkpointer], config: RunnableConfig, + middleware: List[Any], ) -> CompiledStateGraph[Any, Any, Any]: """Compile a ``ManagerWorkers`` into a hierarchical LangGraph. @@ -1224,6 +1226,7 @@ def _manager_workers_convert_to_langgraph( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, ) # 2. Render the workers roster into the manager's system prompt @@ -1259,6 +1262,7 @@ def _manager_workers_convert_to_langgraph( converted_components=converted_components, checkpointer=checkpointer, config=config, + middleware=middleware, additional_langgraph_tools=delegation_tools, ) From 38b4f07faca880486b664d05770723e4b35686e4 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Sat, 30 May 2026 23:55:07 +0400 Subject: [PATCH 08/34] feat(adapters/langgraph): hide ManagerWorkers delegation protocol from astream_events The manager routes by emitting a delegate_to_ tool call that the worker answers with a matching ToolMessage. That pair is load-bearing for the manager react loop but is internal plumbing a consumer should never see as phantom tool calls. Add _DelegationEventFilter + _patch_hide_delegation_in_astream_events to scrub the delegate_to_* tool calls, their on_tool_* lifecycle events, and the worker's synthetic reply ToolMessage from the astream_events view. The filter operates on the emitted events only, never the graph state, so the react loop's tool-call/tool-result contract stays intact. Workers' real LLM/token events still propagate via callback propagation. --- .../adapters/langgraph/_langgraphconverter.py | 274 ++++++++++++++++++ .../adapters/langgraph/test_managerworkers.py | 228 +++++++++++++++ 2 files changed, 502 insertions(+) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index b2413bc3..aadb27ab 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -1306,6 +1306,10 @@ def _manager_workers_convert_to_langgraph( # surrounds each run. Mirrors the patches applied to Agent and # Flow graphs above. _patch_with_manager_workers_execution_span(compiled_graph, mw) + # Hide the delegate_to_ routing protocol from the + # astream_events view (tool calls, their tool lifecycle events, and + # the worker's synthetic reply ToolMessage) without touching state. + _patch_hide_delegation_in_astream_events(compiled_graph) return compiled_graph def _create_react_agent_with_given_info( @@ -2407,6 +2411,276 @@ async def _run_async(state: Dict[str, Any]) -> Dict[str, Any]: ) +# ─── ManagerWorkers: hide the delegation protocol from astream_events ───────── + + +def _is_delegate_name(name: Any) -> bool: + """True if ``name`` is one of the synthetic ``delegate_to_`` + tool names the manager emits to route to a worker.""" + return isinstance(name, str) and name.startswith(_DELEGATE_TOOL_PREFIX) + + +def _is_delegate_tool_message(msg: Any, delegate_call_ids: "set") -> bool: + """True if ``msg`` is the worker's synthetic reply ToolMessage — i.e. a + ToolMessage answering a (now-hidden) delegation tool-call id.""" + return ( + getattr(msg, "type", None) == "tool" + and getattr(msg, "tool_call_id", None) in delegate_call_ids + ) + + +def _scrubbed_ai_message( + msg: Any, + delegate_indices: "set", + delegate_call_ids: "set", +) -> Tuple[Optional[Any], bool]: + """Return ``(scrubbed_copy_or_None, is_empty)`` for an AIMessage(Chunk), + removing every ``delegate_to_`` tool call. + + ``scrubbed_copy_or_None`` is ``None`` when the message carried no + delegation artifact (the caller emits it unchanged). ``is_empty`` is + ``True`` when, after removal, nothing renderable remains (no content and + no other tool calls) — the caller drops the event. + + Never mutates ``msg``: the same object lives in the graph's message + state, where the manager react loop relies on the delegation + tool-call / tool-result pair staying intact. ``delegate_indices`` tracks + streamed tool-call positions so argument-continuation chunks (which + carry no ``name``) are stripped too; ``delegate_call_ids`` collects the + call ids so the worker's matching ToolMessage can be dropped later. + """ + changed = False + + # Provider-native streamed tool calls (e.g. OpenAI) ride along in + # ``additional_kwargs['tool_calls']`` and stream by index with the name + # only on the opening delta — match by name or by a known delegate index. + additional = getattr(msg, "additional_kwargs", None) or {} + new_additional = additional + raw_calls = additional.get("tool_calls") + if raw_calls: + kept_raw = [] + for tc in raw_calls: + index = tc.get("index") if isinstance(tc, dict) else None + function = (tc.get("function") or {}) if isinstance(tc, dict) else {} + fname = function.get("name") + if _is_delegate_name(fname) or (not fname and index in delegate_indices): + if index is not None: + delegate_indices.add(index) + if isinstance(tc, dict) and tc.get("id"): + delegate_call_ids.add(tc["id"]) + changed = True + else: + kept_raw.append(tc) + if len(kept_raw) != len(raw_calls): + new_additional = dict(additional) + if kept_raw: + new_additional["tool_calls"] = kept_raw + else: + new_additional.pop("tool_calls", None) + + # AIMessageChunk: ``tool_call_chunks`` is the source of truth and + # ``tool_calls`` / ``invalid_tool_calls`` are *derived* from it, so we + # rebuild the chunk (which re-runs that derivation) rather than copying — + # otherwise a stale derived ``tool_calls`` entry survives the strip. + if hasattr(msg, "tool_call_chunks"): + kept_chunks = [] + for chunk in getattr(msg, "tool_call_chunks", None) or []: + cname, cindex = chunk.get("name"), chunk.get("index") + if _is_delegate_name(cname) or (cname is None and cindex in delegate_indices): + if cindex is not None: + delegate_indices.add(cindex) + if chunk.get("id"): + delegate_call_ids.add(chunk["id"]) + changed = True + else: + kept_chunks.append(chunk) + if not changed: + return None, False + scrubbed = type(msg)( + content=msg.content, + additional_kwargs=new_additional, + response_metadata=getattr(msg, "response_metadata", None) or {}, + tool_call_chunks=kept_chunks, + id=getattr(msg, "id", None), + name=getattr(msg, "name", None), + usage_metadata=getattr(msg, "usage_metadata", None), + ) + has_remaining = ( + bool(scrubbed.content) + or bool(scrubbed.tool_call_chunks) + or bool((scrubbed.additional_kwargs or {}).get("tool_calls")) + ) + return scrubbed, not has_remaining + + # Full AIMessage: ``tool_calls`` is the source of truth. + update: Dict[str, Any] = {} + for attr in ("tool_calls", "invalid_tool_calls"): + items = getattr(msg, attr, None) + if items: + kept = [] + for tc in items: + if _is_delegate_name(_tc_get(tc, "name")): + cid = _tc_get(tc, "id") + if cid: + delegate_call_ids.add(cid) + changed = True + else: + kept.append(tc) + if len(kept) != len(items): + update[attr] = kept + if new_additional is not additional: + update["additional_kwargs"] = new_additional + if not changed: + return None, False + + scrubbed = msg.model_copy(update=update) + has_remaining = ( + bool(getattr(scrubbed, "content", None)) + or bool(getattr(scrubbed, "tool_calls", None)) + or bool((getattr(scrubbed, "additional_kwargs", None) or {}).get("tool_calls")) + ) + return scrubbed, not has_remaining + + +def _scrub_payload_messages( + payload: Any, + delegate_call_ids: "set", +) -> Tuple[Any, bool]: + """For a node payload shaped ``{"messages": [...]}``, drop the worker's + synthetic reply ToolMessage (matched to a now-hidden delegate call id). + + Returns ``(payload, drop_event)``: ``payload`` is a new dict when a + ToolMessage was removed (the original is never mutated), otherwise the + object passed in. ``drop_event`` is ``True`` when the removal empties the + ``messages`` list, so the caller drops the whole event. + """ + if not isinstance(payload, dict): + return payload, False + messages = payload.get("messages") + if not isinstance(messages, list) or not messages: + return payload, False + kept = [m for m in messages if not _is_delegate_tool_message(m, delegate_call_ids)] + if len(kept) == len(messages): + return payload, False + new_payload = dict(payload) + new_payload["messages"] = kept + return new_payload, len(kept) == 0 + + +class _DelegationEventFilter: + """Stateful scrubber for a single ``astream_events`` stream. + + Removes the synthetic ``delegate_to_`` routing protocol — the + delegation tool calls, their ``on_tool_*`` lifecycle events, and the + worker's matching reply ToolMessage — from the consumer-facing event + view. The graph's message state is never touched, so the manager react + loop still sees its well-formed tool-call / tool-result exchange. + """ + + def __init__(self) -> None: + # Streamed tool-call positions per chat-model run that belong to a + # delegation call, so argument-continuation chunks (name=None) are + # stripped along with the opening chunk. + self._delegate_indices_by_run: Dict[str, "set"] = {} + # Delegate tool-call ids seen so far, so the worker's reply + # ToolMessage can be dropped when it surfaces downstream. + self._delegate_call_ids: "set" = set() + + def scrub(self, event: Dict[str, Any]) -> Optional[Dict[str, Any]]: + etype = event.get("event") + name = event.get("name", "") + + # 1. Drop the tool lifecycle events for the delegation tools. + if ( + etype in ("on_tool_start", "on_tool_end", "on_tool_error") + and _is_delegate_name(name) + ): + return None + + data = event.get("data") or {} + + # 2. Strip delegate tool calls from streamed / final manager AIMessages. + if etype in ("on_chat_model_stream", "on_chat_model_end"): + key = "chunk" if etype == "on_chat_model_stream" else "output" + msg = data.get(key) + if msg is not None and hasattr(msg, "tool_calls"): + run_id = event.get("run_id", "") + indices = self._delegate_indices_by_run.setdefault(run_id, set()) + scrubbed, is_empty = _scrubbed_ai_message( + msg, indices, self._delegate_call_ids + ) + if scrubbed is not None: + # A streamed chunk that became empty is pure delegation + # plumbing — drop it. A final ``on_chat_model_end`` is kept + # (scrubbed) so consumers still get a turn-end marker. + if is_empty and etype == "on_chat_model_stream": + return None + new_data = dict(data) + new_data[key] = scrubbed + new_event = dict(event) + new_event["data"] = new_data + return new_event + return event + + # 3. Drop the worker's synthetic reply ToolMessage wherever it + # surfaces in a node payload. + new_data: Optional[Dict[str, Any]] = None + should_drop = False + for key in ("chunk", "output", "input"): + if key in data: + scrubbed_payload, drop_event = _scrub_payload_messages( + data[key], self._delegate_call_ids + ) + if scrubbed_payload is not data[key]: + if new_data is None: + new_data = dict(data) + new_data[key] = scrubbed_payload + if drop_event: + should_drop = True + if should_drop: + return None + if new_data is not None: + new_event = dict(event) + new_event["data"] = new_data + return new_event + return event + + +def _patch_hide_delegation_in_astream_events( + compiled_graph: CompiledStateGraph[Any, Any, Any], +) -> None: + """Wrap ``astream_events`` so the synthetic ``delegate_to_`` + routing protocol never reaches the consumer. + + ManagerWorkers routes by having the manager react-agent emit a + ``delegate_to_`` tool call, which the worker answers with a + ToolMessage matched to that call id. That pair is load-bearing for the + manager's react loop (it must observe a well-formed tool-call / + tool-result exchange) but it is internal plumbing the consumer should + never see as phantom tool calls. We filter only the emitted events; the + graph's message state is untouched, so the loop is unaffected. The + workers' real LLM/token events still propagate (they reach the consumer + via callback propagation through the isolated worker run), so this + strips the routing noise without hiding the workers' actual output. + """ + original_astream_events = compiled_graph.astream_events + + async def patched_astream_events( + *args: Any, **kwargs: Any + ) -> AsyncGenerator[Any, None]: + event_filter = _DelegationEventFilter() + async for event in original_astream_events(*args, **kwargs): + if isinstance(event, dict): + kept = event_filter.scrub(event) + if kept is None: + continue + yield kept + else: + yield event + + compiled_graph.astream_events = patched_astream_events # type: ignore[assignment] + + def _patch_with_manager_workers_execution_span( compiled_graph: CompiledStateGraph[Any, Any, Any], mw: AgentSpecManagerWorkers, diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index 351855af..9e1025c9 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -501,3 +501,231 @@ def test_workers_with_name_slug_collision_are_rejected() -> None: loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) with pytest.raises(ValueError, match="collide after normalization"): loader.load_component(mw) + + +# ─── astream_events delegation scrubbing ───────────────────────────────────── +# +# The manager routes by emitting a ``delegate_to_`` tool call which +# the worker answers with a ToolMessage. That pair is internal plumbing; the +# consumer-facing ``astream_events`` view must not surface it as phantom tool +# calls. ``_DelegationEventFilter`` scrubs the event stream while leaving the +# graph's message state intact. + + +def test_delegation_filter_drops_delegate_tool_lifecycle_events() -> None: + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _DelegationEventFilter, + ) + + f = _DelegationEventFilter() + for etype in ("on_tool_start", "on_tool_end", "on_tool_error"): + ev = { + "event": etype, + "name": "delegate_to_research_helper", + "run_id": "r", + "data": {}, + } + assert f.scrub(ev) is None + + # A real tool's lifecycle events pass through untouched. + real = {"event": "on_tool_start", "name": "search", "run_id": "r", "data": {}} + assert f.scrub(real) is real + + +def test_delegation_filter_strips_delegate_call_from_chat_model_end() -> None: + from langchain_core.messages import AIMessage + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _DelegationEventFilter, + ) + + f = _DelegationEventFilter() + msg = AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "call_1"}, + ], + ) + out = f.scrub( + {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": msg}} + ) + assert out is not None + assert out["data"]["output"].tool_calls == [] + # The original message object (which lives in graph state) is untouched. + assert msg.tool_calls and msg.tool_calls[0]["name"] == "delegate_to_research_helper" + + # A turn that mixes a delegation call with a real tool call keeps the real one. + mixed = AIMessage( + content="ok", + tool_calls=[ + {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "call_2"}, + {"name": "search", "args": {"q": "x"}, "id": "call_3"}, + ], + ) + out2 = f.scrub( + {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": mixed}} + ) + assert [tc["name"] for tc in out2["data"]["output"].tool_calls] == ["search"] + + +def test_delegation_filter_strips_streamed_delegate_tool_call_chunks() -> None: + from langchain_core.messages import AIMessageChunk + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _DelegationEventFilter, + ) + + f = _DelegationEventFilter() + # Opening chunk: names the delegation tool at index 0 → pure plumbing, dropped. + opening = AIMessageChunk( + content="", + tool_call_chunks=[ + { + "name": "delegate_to_research_helper", + "args": "", + "id": "call_1", + "index": 0, + "type": "tool_call_chunk", + } + ], + ) + assert ( + f.scrub( + {"event": "on_chat_model_stream", "name": "x", "run_id": "r", "data": {"chunk": opening}} + ) + is None + ) + + # Argument-continuation chunk: no name, same index 0 → also dropped. + cont = AIMessageChunk( + content="", + tool_call_chunks=[ + {"name": None, "args": '{"task":"hi"}', "id": None, "index": 0, "type": "tool_call_chunk"}, + ], + ) + assert ( + f.scrub( + {"event": "on_chat_model_stream", "name": "x", "run_id": "r", "data": {"chunk": cont}} + ) + is None + ) + + # A chunk mixing a delegation call with a real tool call keeps the real one. + mixed = AIMessageChunk( + content="", + tool_call_chunks=[ + {"name": "delegate_to_research_helper", "args": "", "id": "c4", "index": 0, "type": "tool_call_chunk"}, + {"name": "search", "args": "", "id": "c5", "index": 1, "type": "tool_call_chunk"}, + ], + ) + out = f.scrub( + {"event": "on_chat_model_stream", "name": "x", "run_id": "r2", "data": {"chunk": mixed}} + ) + assert out is not None + kept = out["data"]["chunk"].tool_call_chunks + assert [c["name"] for c in kept] == ["search"] + + +def test_delegation_filter_drops_worker_synthetic_tool_message() -> None: + from langchain_core.messages import AIMessage, ToolMessage + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _DelegationEventFilter, + ) + + f = _DelegationEventFilter() + # The manager's delegate turn first records the delegation call id. + delegate = AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "call_1"}, + ], + ) + f.scrub( + {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": delegate}} + ) + + # The worker node then emits its reply as a ToolMessage matched to call_1. + reply = ToolMessage(content="Saturn has rings.", tool_call_id="call_1") + out = f.scrub( + { + "event": "on_chain_end", + "name": "worker:research_helper", + "run_id": "w", + "data": {"output": {"messages": [reply]}}, + } + ) + assert out is None + + # A ToolMessage answering an unknown (real) tool call is preserved. + other = ToolMessage(content="x", tool_call_id="call_other") + kept = f.scrub( + { + "event": "on_chain_end", + "name": "node", + "run_id": "w2", + "data": {"output": {"messages": [other]}}, + } + ) + assert kept is not None + assert kept["data"]["output"]["messages"] == [other] + + +def test_delegation_filter_passes_through_real_content() -> None: + from langchain_core.messages import AIMessageChunk + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _DelegationEventFilter, + ) + + f = _DelegationEventFilter() + chunk = AIMessageChunk(content="Hello") + ev = {"event": "on_chat_model_stream", "name": "x", "run_id": "r", "data": {"chunk": chunk}} + out = f.scrub(ev) + # No delegation artifact → event passes through as the same object. + assert out is ev + assert out["data"]["chunk"].content == "Hello" + + +def test_manager_workers_patches_astream_events() -> None: + """The compiled ManagerWorkers graph has its ``astream_events`` wrapped + with the delegation scrubber.""" + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + mw = ManagerWorkers( + name="Team", + group_manager=Agent( + name="Coordinator", description="c", system_prompt=".", llm_config=_llm_cfg("manager_llm"), + ), + workers=[ + Agent(name="Research Helper", description="r", system_prompt=".", llm_config=_llm_cfg("worker_llm")), + ], + ) + + def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: + return _fake_manager(*[]) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=_dispatch, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **kw: self_obj, + ): + compiled = loader.load_component(mw) + + assert getattr(compiled.astream_events, "__name__", "") == "patched_astream_events" From 2c2a399c1205dd676f6014fd9f27ca99fd028185 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Sun, 31 May 2026 11:18:21 +0400 Subject: [PATCH 09/34] fix(adapters/langgraph): stream ManagerWorkers worker events under the worker node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker subgraphs were invoked with a fresh thread_id, which reset their checkpoint_ns so their token events streamed as a detached agent: run with no worker prefix — indistinguishable from the manager's own events and so unattributable by stream consumers. Invoke the worker inheriting the ambient run config instead. The worker run then nests under this node's checkpoint_ns, so astream_events surfaces its events natively as :...|agent:..., letting consumers attribute the token stream to the sub-agent. Isolation is preserved: the worker is still fed only the delegated task, and LangGraph's distinct per-superstep checkpoint_ns keeps state isolated across repeated delegations without the fresh thread_id. Also harden the astream_events delegation filter to fail open: a scrubbing error now passes the event through unfiltered rather than tearing down the stream (which would swallow every later event, including the worker's). Add regression tests: worker token events are namespaced under the worker node, and the filter fails open on error. --- .../adapters/langgraph/_langgraphconverter.py | 53 ++++++--- .../adapters/langgraph/test_managerworkers.py | 110 ++++++++++++++++++ 2 files changed, 144 insertions(+), 19 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index aadb27ab..b9b7e4f6 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -2375,16 +2375,21 @@ def _extract_pending(state: Dict[str, Any]) -> Tuple[str, str]: def _tool_message_from(reply: str, call_id: str) -> Dict[str, Any]: return {"messages": [ToolMessage(content=reply, tool_call_id=call_id)]} - def _worker_input(task: str) -> Tuple[Dict[str, Any], Dict[str, Any]]: - # Each worker run gets a fresh thread_id so its message history - # is isolated across invocations — workers shouldn't accumulate - # state across delegations within a single manager session, - # otherwise the same worker called twice in a row would see its - # previous answer as conversation history. - return ( - {"messages": [HumanMessage(content=task)]}, - {"configurable": {"thread_id": str(uuid4())}}, - ) + def _worker_input(task: str) -> Dict[str, Any]: + # Content isolation: the worker is fed ONLY the delegated task, never + # the manager's history. We deliberately pass NO explicit config so + # the worker run *inherits* the ambient run config of this node — + # which carries (a) the astream_events callbacks and (b) this node's + # ``checkpoint_ns`` (``:``). Inheriting the + # namespace is what makes the worker's token events stream natively + # under the worker node (so consumers can attribute them to the + # worker) instead of escaping to a detached ``agent:`` run. + # + # State stays isolated *across* delegations without a fresh thread_id: + # LangGraph gives each ```` invocation a distinct + # per-superstep ``checkpoint_ns``, so a worker called twice in a row + # starts each run fresh rather than replaying its previous answer. + return {"messages": [HumanMessage(content=task)]} def _last_message_content(result: Any) -> str: messages = result.get("messages") if isinstance(result, dict) else None @@ -2394,14 +2399,12 @@ def _last_message_content(result: Any) -> str: def _run_sync(state: Dict[str, Any]) -> Dict[str, Any]: task, call_id = _extract_pending(state) - sub_input, sub_config = _worker_input(task) - result = worker_graph.invoke(sub_input, sub_config) + result = worker_graph.invoke(_worker_input(task)) return _tool_message_from(_last_message_content(result), call_id) async def _run_async(state: Dict[str, Any]) -> Dict[str, Any]: task, call_id = _extract_pending(state) - sub_input, sub_config = _worker_input(task) - result = await worker_graph.ainvoke(sub_input, sub_config) + result = await worker_graph.ainvoke(_worker_input(task)) return _tool_message_from(_last_message_content(result), call_id) return RunnableLambda( @@ -2670,13 +2673,25 @@ async def patched_astream_events( ) -> AsyncGenerator[Any, None]: event_filter = _DelegationEventFilter() async for event in original_astream_events(*args, **kwargs): - if isinstance(event, dict): + if not isinstance(event, dict): + yield event + continue + # Fail open: a scrubbing bug must never tear down the stream + # (which would swallow every later event — notably the worker + # events that follow the manager's delegation turn). On error we + # emit the event unfiltered rather than dropping the rest. + try: kept = event_filter.scrub(event) - if kept is None: - continue - yield kept - else: + except Exception: # noqa: BLE001 — defensive, see above + logging.getLogger("pyagentspec.adapters.langgraph").warning( + "ManagerWorkers astream_events delegation filter raised; " + "passing the event through unfiltered.", + exc_info=True, + ) yield event + continue + if kept is not None: + yield kept compiled_graph.astream_events = patched_astream_events # type: ignore[assignment] diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index 9e1025c9..40386b27 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -687,6 +687,116 @@ def test_delegation_filter_passes_through_real_content() -> None: assert out["data"]["chunk"].content == "Hello" +def test_worker_events_stream_natively_namespaced_under_worker_node() -> None: + """Regression: a worker's token events must stream under the worker + node's checkpoint namespace so a consumer can attribute them to the + sub-agent. The wrapper must inherit the ambient run config (no fresh + thread_id); a fresh thread_id detaches the worker into a top-level + ``agent:`` run with no worker prefix, which is unattributable.""" + import asyncio + + from langchain_core.language_models.fake_chat_models import ( + GenericFakeChatModel, + ) + from langchain_core.messages import AIMessage, HumanMessage + from langgraph.graph import END, START, MessagesState, StateGraph + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _wrap_worker_for_subgraph, + ) + + # A minimal worker compiled graph that streams some content. + wmodel = GenericFakeChatModel(messages=iter([AIMessage(content="Saturn has rings")] * 9)) + wb = StateGraph(MessagesState) + + async def _wagent(state: Any) -> Any: + return {"messages": [await wmodel.ainvoke(state["messages"])]} + + wb.add_node("agent", _wagent) + wb.add_edge(START, "agent") + wb.add_edge("agent", END) + worker_graph = wb.compile() + + # Parent: a plain manager node emits the delegate tool call, then routes + # to the wrapped worker node named "research_helper". + pb = StateGraph(MessagesState) + + def _manager(state: Any) -> Any: + return { + "messages": [ + AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_research_helper", "args": {"task": "Saturn"}, "id": "c1"} + ], + ) + ] + } + + pb.add_node("__manager__", _manager) + pb.add_node("research_helper", _wrap_worker_for_subgraph(worker_graph, "research_helper")) + pb.add_edge(START, "__manager__") + pb.add_edge("__manager__", "research_helper") + pb.add_edge("research_helper", END) + parent = pb.compile() + + async def _collect() -> Any: + namespaces = [] + async for ev in parent.astream_events( + {"messages": [HumanMessage(content="hi")]}, + {"configurable": {"thread_id": "t"}}, + version="v2", + ): + if ev["event"] == "on_chat_model_stream": + ns = (ev.get("metadata") or {}).get("langgraph_checkpoint_ns", "") + namespaces.append(ns) + return namespaces + + namespaces = asyncio.run(_collect()) + assert namespaces, "expected the worker to emit token-stream events" + # Every worker token event is namespaced under the worker node, so a + # consumer can attribute the stream to the sub-agent. + assert all(ns.startswith("research_helper:") for ns in namespaces), namespaces + + +def test_patched_astream_events_fails_open_on_filter_error() -> None: + """A bug in the delegation filter must never tear down the stream and + swallow later events (e.g. the worker events that follow the manager's + delegation turn). On a scrub error the event is passed through.""" + import asyncio + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _DelegationEventFilter, + _patch_hide_delegation_in_astream_events, + ) + + class _FakeGraph: + async def astream_events(self, *a: Any, **k: Any) -> Any: + yield {"event": "on_chat_model_stream", "run_id": "boom", "name": "x", "data": {}} + yield {"event": "on_chat_model_stream", "run_id": "ok", "name": "x", + "data": {"chunk": "worker-token"}} + + def _explode(self: Any, event: Any) -> Any: + if event.get("run_id") == "boom": + raise RuntimeError("kaboom") + return event + + graph = _FakeGraph() + _patch_hide_delegation_in_astream_events(graph) + + async def _collect() -> Any: + out = [] + with patch.object(_DelegationEventFilter, "scrub", new=_explode): + async for ev in graph.astream_events(): + out.append(ev) + return out + + events = asyncio.run(_collect()) + # Both events survive: the one that raised is passed through unfiltered, + # and the later (worker) event is still delivered. + assert [e["run_id"] for e in events] == ["boom", "ok"] + + def test_manager_workers_patches_astream_events() -> None: """The compiled ManagerWorkers graph has its ``astream_events`` wrapped with the delegation scrubber.""" From 0242c9af50ea203f876a086eee09d5629c0ae375 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Sun, 7 Jun 2026 11:22:34 +0400 Subject: [PATCH 10/34] feat(adapters/langgraph): resolve single-string agent output from final message An Agent that declares a single string output now takes that output from the agent's final message instead of forcing structured generation: - _create_react_agent_with_given_info no longer attaches a response_format for a single string output (mirrors LlmNodeExecutor). - extract_outputs_from_invoke_result falls back to the final message content for a single string output when structured generation didn't populate it. This lets a string output work on models without structured-output support (previously the output came back empty). Multi-output and non-string outputs are unchanged. Adds offline tests using a fake (non-structured) chat model. --- .../adapters/langgraph/_langgraphconverter.py | 9 +- .../adapters/langgraph/_node_execution.py | 28 +++++- .../langgraph/flows/test_agentnode.py | 94 +++++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index b9b7e4f6..c975c2a6 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -40,6 +40,7 @@ from pyagentspec.adapters.langgraph._node_execution import ( NodeExecutor, extract_outputs_from_invoke_result, + is_single_string_output, ) from pyagentspec.adapters.langgraph._types import ( AgentState, @@ -1364,8 +1365,12 @@ def _create_react_agent_with_given_info( output_model: Optional[type[BaseModel]] = None state_schema: Optional[Any] = None - # Build response (output) model (used for response_format) - if outputs: + # Build response (output) model (used for response_format). A single + # string output is taken from the agent's final message (see + # extract_outputs_from_invoke_result), so it needs no structured + # generation — mirrors LlmNodeExecutor and lets a string output work on + # models without structured-output support. + if outputs and not is_single_string_output(outputs): output_model = create_pydantic_model_from_properties("AgentOutputModel", outputs) if inputs: diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index f1999aef..ddac3b70 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -919,13 +919,25 @@ def _accumulate_outputs( outputs[collected_output_name].append(output_value) +def is_single_string_output(expected_outputs: List[AgentSpecProperty]) -> bool: + """Whether the declared outputs are a single string property. + + Such an output is the model's free text, not a structured field, so the + adapter takes it directly from the agent's final message rather than forcing + structured generation. Mirrors ``LlmNodeExecutor``'s single-string handling + and lets a string output work on models without structured-output support. + """ + outputs = expected_outputs or [] + return len(outputs) == 1 and outputs[0].type == "string" + + def extract_outputs_from_invoke_result( result: Dict[str, Any], expected_outputs: List[AgentSpecProperty] ) -> Dict[str, Any]: # Extracts the outputs from the return value of an invoke call made on an agent # The outputs are typically exposed as part of the `structured_response`, or as entries in the result directly. # We give priority to the latter. - return { + outputs = { # Defaults if available **{ output.title: output.default @@ -941,3 +953,17 @@ def extract_outputs_from_invoke_result( if output.title in result }, } + # A single string output is the agent's free-text answer, not a structured + # field. When structured generation didn't populate it — because the model + # lacks structured-output support, or because none was requested (see + # ``_create_react_agent_with_given_info``) — fall back to the final message + # content so the output still carries the agent's response. + if is_single_string_output(expected_outputs): + title = expected_outputs[0].title + if title not in outputs: + messages = result.get("messages") + if messages: + content = getattr(messages[-1], "content", None) + if content is not None: + outputs[title] = content + return outputs diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py b/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py index e5b4ba12..8cc4f5fb 100644 --- a/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py +++ b/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py @@ -96,6 +96,100 @@ def test_agentnode_can_be_imported_and_executed(agent_flow: Flow) -> None: assert "car" in outputs +def test_is_single_string_output() -> None: + """A lone string output is treated as free text, not a structured field.""" + from pyagentspec.adapters.langgraph._node_execution import is_single_string_output + from pyagentspec.property import IntegerProperty, StringProperty + + assert is_single_string_output([StringProperty(title="x")]) is True + assert is_single_string_output([]) is False + assert is_single_string_output([IntegerProperty(title="n")]) is False + assert ( + is_single_string_output([StringProperty(title="a"), StringProperty(title="b")]) + is False + ) + + +def test_single_string_output_taken_from_final_message_without_structured_generation() -> None: + """An AgentNode whose agent declares a single string output should resolve + that output from the agent's final message — no structured generation, so + it works on models without structured-output support. + + The model is stubbed with a ``FakeMessagesListChatModel`` (no structured + output); if the converter still attached a ``response_format`` the output + would come back empty. Asserting it equals the message content proves the + single-string path takes the final message instead. + """ + from unittest.mock import patch + + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + fake_llm = _FakeModel(responses=[AIMessage(content="42")]) + + answer = StringProperty(title="answer") + agent = Agent( + name="agent", + llm_config=OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null"), + system_prompt="Answer the question.", + outputs=[answer], + ) + agent_node = AgentNode(name="agent_node", agent=agent) + start_node = StartNode(name="start") + end_node = EndNode(name="end", outputs=[answer]) + flow = Flow( + name="flow", + start_node=start_node, + nodes=[start_node, agent_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=agent_node), + ControlFlowEdge(name="node_to_end", from_node=agent_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="answer_edge", + source_node=agent_node, + source_output=answer.title, + destination_node=end_node, + destination_input=answer.title, + ), + ], + outputs=[answer], + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ): + compiled = loader.load_component(flow) + result = compiled.invoke( + {"inputs": {}, "messages": [{"role": "user", "content": "What is 6*7?"}]}, + {"configurable": {"thread_id": "agentnode-single-string"}}, + ) + + assert result["outputs"]["answer"] == "42" + + @pytest.mark.anyio @retry_test(max_attempts=3, wait_between_tries=2) async def test_agentnode_can_be_executed_async(agent_flow: Flow) -> None: From 0571414ec4f822840ae52774539b1daa5cca3d07 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Sun, 21 Jun 2026 14:45:39 +0400 Subject: [PATCH 11/34] fix(adapters): preserve value types in RemoteTool JSON bodies When a RemoteTool data field is a single whole placeholder (e.g. members: '{{members}}'), the value was rendered via str(), turning structured tool arguments into Python reprs ('[membersItem(...)]') and None into 'None'. A downstream action doing input.members.map(...) then failed with 'input.members.map is not a function'. Add render_nested_json_template (used only for the JSON request body): a value that is exactly one placeholder is replaced with the raw input, JSON-normalized (pydantic models -> dict via model_dump), preserving list/dict/number/bool/None. Strings with surrounding text keep interpolation; dict keys stay strings. render_template / render_nested_object_template are unchanged. --- .../src/pyagentspec/adapters/_tools_common.py | 7 ++- .../src/pyagentspec/adapters/_utils.py | 48 +++++++++++++++++++ .../tests/adapters/test_template_rendering.py | 43 ++++++++++++++++- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/_tools_common.py b/pyagentspec/src/pyagentspec/adapters/_tools_common.py index 6592be46..fa320b99 100644 --- a/pyagentspec/src/pyagentspec/adapters/_tools_common.py +++ b/pyagentspec/src/pyagentspec/adapters/_tools_common.py @@ -16,7 +16,7 @@ maybe_warn_about_unrestricted_templated_url, validate_url_against_allow_list, ) -from pyagentspec.adapters._utils import render_nested_object_template, render_template +from pyagentspec.adapters._utils import render_nested_json_template, render_nested_object_template, render_template from pyagentspec.retrypolicy import RetryPolicy from pyagentspec.tools.remotetool import RemoteTool as AgentSpecRemoteTool @@ -38,7 +38,10 @@ def _create_remote_tool_func(remote_tool: AgentSpecRemoteTool) -> Callable[..., ) def _remote_tool(**kwargs: Any) -> Any: - remote_tool_data = render_nested_object_template(remote_tool.data, kwargs) + # The body is JSON: preserve the type of whole-placeholder values so + # structured tool arguments (arrays/objects/numbers/None) survive instead + # of being stringified to a Python repr. URL/headers/query stay strings. + remote_tool_data = render_nested_json_template(remote_tool.data, kwargs) remote_tool_headers = { render_template(k, kwargs): render_nested_object_template(v, kwargs) for k, v in remote_tool.headers.items() diff --git a/pyagentspec/src/pyagentspec/adapters/_utils.py b/pyagentspec/src/pyagentspec/adapters/_utils.py index e221d613..ed8ce67a 100644 --- a/pyagentspec/src/pyagentspec/adapters/_utils.py +++ b/pyagentspec/src/pyagentspec/adapters/_utils.py @@ -74,6 +74,54 @@ def render_template(template: Any, inputs: Dict[str, Any]) -> str: return _render_template_placeholders(template, inputs) +def _to_jsonable(value: Any) -> Any: + """Normalize a value into JSON-compatible primitives/containers. + + Pydantic models (e.g. the per-input models built from a tool's input schema) + are dumped to plain dicts so they serialize correctly into a JSON request + body; nested structures are converted recursively. + """ + if isinstance(value, BaseModel): + return value.model_dump(mode="json") + if isinstance(value, dict): + return {k: _to_jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple, set)): + return [_to_jsonable(item) for item in value] + return value + + +def render_nested_json_template(object: Any, inputs: Dict[str, Any]) -> Any: + """Render a template destined for a JSON request body. + + Behaves like :func:`render_nested_object_template`, except a string *value* + that is exactly one placeholder (e.g. ``"{{members}}"``) is replaced by the + raw input value with its type preserved (list/dict/number/bool/None) — so + structured tool arguments survive as JSON instead of being stringified into a + Python ``repr`` (``"[membersItem(...)]"``) or ``"None"``. Strings with + surrounding text keep ordinary interpolation, and dict keys are always + rendered as strings. + """ + if isinstance(object, str): + return _render_json_leaf(object, inputs) + elif isinstance(object, bytes): + return render_nested_json_template(object.decode("utf-8", errors="replace"), inputs) + elif isinstance(object, dict): + return {render_template(k, inputs): render_nested_json_template(v, inputs) for k, v in object.items()} + elif isinstance(object, list) or isinstance(object, set) or isinstance(object, tuple): + return object.__class__([render_nested_json_template(item, inputs) for item in object]) + else: + return object + + +def _render_json_leaf(template: str, inputs: Dict[str, Any]) -> Any: + """Whole-placeholder string -> raw (JSON-able) value; otherwise interpolate.""" + stripped = template.strip() + matches = list(re.finditer(TEMPLATE_PLACEHOLDER_REGEXP, stripped)) + if len(matches) == 1 and matches[0].group(0) == stripped and matches[0].group(1) in inputs: + return _to_jsonable(inputs[matches[0].group(1)]) + return _render_template_placeholders(template, inputs) + + def _render_template_placeholders(template: str, inputs: Dict[str, Any]) -> str: """Render placeholders found in the original template using the list of inputs.""" rendered_parts: List[str] = [] diff --git a/pyagentspec/tests/adapters/test_template_rendering.py b/pyagentspec/tests/adapters/test_template_rendering.py index 806e3d44..4414b7b8 100644 --- a/pyagentspec/tests/adapters/test_template_rendering.py +++ b/pyagentspec/tests/adapters/test_template_rendering.py @@ -7,8 +7,18 @@ from typing import Any, Dict import pytest +from pydantic import BaseModel + +from pyagentspec.adapters._utils import ( + render_nested_json_template, + render_nested_object_template, + render_template, +) -from pyagentspec.adapters._utils import render_nested_object_template, render_template + +class _Member(BaseModel): + userId: str + roles: Any = None @pytest.mark.parametrize( @@ -112,3 +122,34 @@ def test_json_template_are_properly_rendered( template: str, inputs: Dict[str, Any], expected: str ) -> None: assert render_nested_object_template(template, inputs) == expected + + +@pytest.mark.parametrize( + "template, inputs, expected", + [ + # A whole-placeholder value keeps its type (list/dict/number/bool/None), + # so structured tool arguments survive into a JSON body. + ("{{a}}", {"a": [1, 2]}, [1, 2]), + ("{{a}}", {"a": None}, None), + ("{{a}}", {"a": 5}, 5), + ("{{a}}", {"a": True}, True), + ("{{a}}", {"a": {"k": "v"}}, {"k": "v"}), + # Strings stay strings; embedded placeholders still interpolate. + ("{{a}}", {"a": "oneOnOne"}, "oneOnOne"), + ("id-{{a}}", {"a": 5}, "id-5"), + # Dict keys are always rendered as strings; only values keep their type. + ( + {"input": {"members": "{{members}}", "topic": "{{topic}}", "type": "{{type}}"}}, + { + "members": [_Member(userId="u1", roles=None)], + "topic": None, + "type": "group", + }, + {"input": {"members": [{"userId": "u1", "roles": None}], "topic": None, "type": "group"}}, + ), + ], +) +def test_json_body_template_preserves_value_types( + template: Any, inputs: Dict[str, Any], expected: Any +) -> None: + assert render_nested_json_template(template, inputs) == expected From a39e33e33832a10b43009261996fef970f759f27 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Mon, 22 Jun 2026 13:27:03 +0400 Subject: [PATCH 12/34] fix(adapters/langgraph): answer every ManagerWorkers delegation in a turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the manager emitted several delegate_to_ tool calls in one turn (e.g. "spin up 5 sub-agents"), only the first delegation ran. The remaining tool_call_ids were left unanswered, producing an invalid tool-call / tool-result sequence under the OpenAI contract — so the manager hallucinated the missing replies. Two paths each assumed a single delegation per turn: - the delegate tool returned Command(goto=, graph=PARENT), and ToolNode collapses multiple parent commands down to one, so only one worker was scheduled; - the routing edge returned a single worker node name and the worker wrapper answered only the first matching tool_call_id. Fix: the delegate tool now returns Command(graph=PARENT) with no goto — it only breaks out of the manager's react loop. The routing edge becomes the single source of routing and fans out one Send per delegate call, each carrying its task and tool_call_id; the worker wrapper reads them from the Send payload (message-scan fallback kept for the direct-edge path). Multiple Sends to the same worker run as independent tasks, so every delegation is answered and the runs no longer collide on a shared "first pending call". Worker token events stay namespaced under the worker node, preserving sub-agent stream attribution. Adds a regression test covering multiple delegations to the same worker in one turn and updates the routing unit tests to assert the fan-out. --- .../adapters/langgraph/_langgraphconverter.py | 99 +++++++++---- .../adapters/langgraph/test_managerworkers.py | 131 +++++++++++++++++- 2 files changed, 195 insertions(+), 35 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index c975c2a6..6c81b48b 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -2188,6 +2188,14 @@ def _ensure_checkpointer_and_valid_tool_config( # the normalized worker node name. _DELEGATE_TOOL_PREFIX = "delegate_to_" +# Keys carried on the per-delegation ``Send`` payload from the manager's +# routing edge to a worker node, so a worker run knows which task it was +# given and which ``tool_call_id`` its reply ToolMessage must answer. This +# is what lets one manager turn delegate to several workers at once: each +# delegation routes as its own ``Send`` and is answered independently. +_DELEGATE_TASK_KEY = "__delegate_task__" +_DELEGATE_CALL_ID_KEY = "__delegate_tool_call_id__" + # Collapses any run of whitespace to a single space so multi-line worker # descriptions stay on one roster line. _WHITESPACE_RE = re.compile(r"\s+") @@ -2245,17 +2253,20 @@ def _make_worker_delegation_tool(worker_node_name: str) -> Any: """Build the ``delegate_to_`` tool the manager's LLM emits to route work to a worker subgraph. - The tool body returns a ``Command(goto=, graph=Command.PARENT)`` - which the langchain ``ToolNode`` propagates up to the parent graph, - breaking out of the manager's react-agent inner loop and routing - control to the worker subgraph node. The worker reads the pending - ``delegate_to_`` tool-call off the parent state (to recover - the ``task`` argument and ``tool_call_id``), runs in an isolated - message context, and emits a ToolMessage on its way back. On the - next manager turn the LLM sees ``AIMessage(tool_call=delegate)`` + - ``ToolMessage(worker_reply)`` and can produce its final answer — - a well-formed tool-call / tool-result sequence in the OpenAI - contract. + The tool body returns a ``Command(graph=Command.PARENT)`` which the + langchain ``ToolNode`` propagates up to the parent graph, breaking out + of the manager's react-agent inner loop. It deliberately carries **no + ``goto``**: routing is the parent graph's conditional edge's job + (:func:`_route_manager_to_worker_or_end`), which inspects the manager's + AIMessage and fans out one ``Send`` per ``delegate_to_`` call. + Routing via ``goto`` here would be wrong when the manager emits several + delegations in a single turn — ``ToolNode`` collapses the multiple + parent commands down to one, so only the first worker would run and the + other delegations' ``tool_call_id``s would be left unanswered. Each + worker run replies with a ToolMessage matched to its ``tool_call_id``; + on the next manager turn the LLM sees every ``AIMessage(tool_call)`` + + ``ToolMessage(worker_reply)`` pair and can produce its final answer — a + well-formed tool-call / tool-result sequence in the OpenAI contract. Modelled on ``langgraph_swarm.create_handoff_tool``, which uses the same Command-propagation pattern for swarm handoffs. @@ -2284,19 +2295,16 @@ def _delegate( # subgraph's messages — including the AIMessage carrying this # tool_call — onto the PARENT state via the update. The # ``add_messages`` reducer dedupes by id so existing messages - # aren't duplicated. The worker subgraph node will then read the - # AIMessage off the parent state (rather than the now-discarded - # subgraph state) to recover ``task`` and ``tool_call_id``, and - # will emit the real ToolMessage matched to ``tool_call_id`` on - # its way back to the manager. + # aren't duplicated. The parent's routing edge then reads the + # AIMessage off the parent state and fans out a worker run per + # delegation, each answering its own ``tool_call_id``. subgraph_messages: List[Any] = [] if isinstance(state, dict): subgraph_messages = list(state.get("messages") or []) else: subgraph_messages = list(getattr(state, "messages", []) or []) - del task, tool_call_id # task is read off the parent state's pending tool call + del task, tool_call_id # task + id are recovered by the routing edge return Command( - goto=worker_node_name, graph=Command.PARENT, update={"messages": subgraph_messages}, ) @@ -2309,26 +2317,47 @@ def _delegate( return _delegate -def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> str: - """Inspect the manager's last AIMessage. If it tool-called a - ``delegate_to_``, return that worker node name; otherwise - return ``END``. - - Robust against multiple tool calls in one turn — only the first - delegation call routes the graph (any other tool calls on the same - AIMessage are real tools the manager invoked, already executed by - its react-agent inner loop before we get here). +def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: + """Inspect the manager's last AIMessage and fan out one worker run per + ``delegate_to_`` tool call, via ``Send``; return ``END`` when + the manager delegated to no one. + + A single manager turn may delegate to several workers at once — the LLM + emits multiple ``delegate_to_`` tool calls in one AIMessage + (e.g. "spin up 5 sub-agents"). Every one of those tool calls must be + answered by its own ``ToolMessage`` matched to the originating + ``tool_call_id``; leaving any unanswered produces a tool-call / + tool-result mismatch that violates the OpenAI contract and makes the + manager hallucinate the missing replies. We therefore emit one ``Send`` + per delegation, each carrying the delegated ``task`` and its + ``tool_call_id`` so the target worker node can reply to exactly that + call. Multiple ``Send``s to the same worker node run as independent + tasks. Plain (non-delegation) tool calls were already executed inside + the manager's react-agent loop before routing reaches here. """ + from langgraph.types import Send + messages = state.get("messages") or [] if not messages: return langgraph_graph.END last = messages[-1] tool_calls = getattr(last, "tool_calls", None) or [] + sends = [] for tc in tool_calls: name = _tc_get(tc, "name") if isinstance(name, str) and name.startswith(_DELEGATE_TOOL_PREFIX): - return name[len(_DELEGATE_TOOL_PREFIX):] - return langgraph_graph.END + worker_node_name = name[len(_DELEGATE_TOOL_PREFIX):] + args = _tc_get(tc, "args") or {} + sends.append( + Send( + worker_node_name, + { + _DELEGATE_TASK_KEY: args.get("task") or "", + _DELEGATE_CALL_ID_KEY: _tc_get(tc, "id") or "", + }, + ) + ) + return sends or langgraph_graph.END def _wrap_worker_for_subgraph( @@ -2357,6 +2386,18 @@ def _wrap_worker_for_subgraph( delegate_tool_name = f"{_DELEGATE_TOOL_PREFIX}{worker_node_name}" def _extract_pending(state: Dict[str, Any]) -> Tuple[str, str]: + # Fan-out path: the routing edge's ``Send`` payload carries this + # delegation's task and its originating tool_call_id directly, so a + # single manager turn can delegate to this worker more than once + # without the runs colliding on a shared "first pending call". + if isinstance(state, dict) and _DELEGATE_CALL_ID_KEY in state: + return ( + state.get(_DELEGATE_TASK_KEY) or "", + state.get(_DELEGATE_CALL_ID_KEY) or "", + ) + # Direct-edge path (a worker wired in without Send): recover task + + # id from the manager's last AIMessage. Only the first matching call + # is recoverable this way, which is why routing prefers Send. messages = state.get("messages") or [] if not messages: raise RuntimeError( diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index 40386b27..bafd8746 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -85,10 +85,13 @@ def test_append_workers_roster_flattens_multiline_descriptions() -> None: assert out == "Available workers:\n- helper: First line second line third line" -def test_route_manager_to_worker_or_end_reads_pending_delegation() -> None: +def test_route_manager_to_worker_or_end_sends_to_pending_delegation() -> None: from langchain_core.messages import AIMessage + from langgraph.types import Send from pyagentspec.adapters.langgraph._langgraphconverter import ( + _DELEGATE_CALL_ID_KEY, + _DELEGATE_TASK_KEY, _route_manager_to_worker_or_end, ) @@ -98,8 +101,13 @@ def test_route_manager_to_worker_or_end_reads_pending_delegation() -> None: {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "c1"} ], ) - state = {"messages": [delegating]} - assert _route_manager_to_worker_or_end(state) == "research_helper" + sends = _route_manager_to_worker_or_end({"messages": [delegating]}) + # One delegation → a single Send to the worker node carrying the task + # and the tool_call_id its reply must answer. + assert isinstance(sends, list) and len(sends) == 1 + assert isinstance(sends[0], Send) + assert sends[0].node == "research_helper" + assert sends[0].arg == {_DELEGATE_TASK_KEY: "hi", _DELEGATE_CALL_ID_KEY: "c1"} def test_route_manager_to_worker_or_end_returns_end_when_no_delegation() -> None: @@ -115,10 +123,13 @@ def test_route_manager_to_worker_or_end_returns_end_when_no_delegation() -> None assert _route_manager_to_worker_or_end({"messages": []}) == END -def test_route_manager_to_worker_or_end_picks_first_delegation_among_multiple_tool_calls() -> None: +def test_route_manager_to_worker_or_end_fans_out_every_delegation() -> None: from langchain_core.messages import AIMessage + from langgraph.types import Send from pyagentspec.adapters.langgraph._langgraphconverter import ( + _DELEGATE_CALL_ID_KEY, + _DELEGATE_TASK_KEY, _route_manager_to_worker_or_end, ) @@ -130,8 +141,14 @@ def test_route_manager_to_worker_or_end_picks_first_delegation_among_multiple_to {"name": "delegate_to_research_helper", "args": {"task": "y"}, "id": "c2"}, ], ) - # First delegation wins — the others would be handled on the next loop. - assert _route_manager_to_worker_or_end({"messages": [msg]}) == "drafter" + sends = _route_manager_to_worker_or_end({"messages": [msg]}) + # Every delegation gets its own Send so each tool_call_id is answered. + # The non-delegation tool call was already executed inside the manager's + # react loop and is ignored by routing. + assert all(isinstance(s, Send) for s in sends) + assert [s.node for s in sends] == ["drafter", "research_helper"] + assert [s.arg[_DELEGATE_CALL_ID_KEY] for s in sends] == ["c1", "c2"] + assert [s.arg[_DELEGATE_TASK_KEY] for s in sends] == ["x", "y"] # ─── Topology test (no LLM execution; checks compiled graph shape) ────────── @@ -380,6 +397,108 @@ def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: assert "Saturn has rings" in tool_msgs[0].content +def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: + """Regression: when the manager emits SEVERAL ``delegate_to_`` + tool calls in one turn (e.g. "spin up 5 sub-agents"), every delegation + must run and be answered by its own ToolMessage matched to the + originating tool_call_id. + + Before the fix the parent graph routed only the first delegation, so the + other tool_call_ids were left unanswered — an invalid tool-call / + tool-result sequence that made the manager hallucinate the missing + replies. This asserts all three calls get matched ToolMessages. + """ + from langchain_core.messages import AIMessage, HumanMessage + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + manager_agent = Agent( + name="Coordinator", + description="Coordinates", + system_prompt="You coordinate.", + llm_config=_llm_cfg("manager_llm"), + ) + worker = Agent( + name="Sub Agent", + description="Writes poems", + system_prompt="You write poems.", + llm_config=_llm_cfg("worker_llm"), + ) + mw = ManagerWorkers(name="Team", group_manager=manager_agent, workers=[worker]) + + # Turn 1: three delegations to the SAME worker in one AIMessage. + # Turn 2: terminate (no tool call). + manager_responses = [ + AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_sub_agent", "args": {"task": "Spanish poem"}, "id": "call_1"}, + {"name": "delegate_to_sub_agent", "args": {"task": "French poem"}, "id": "call_2"}, + {"name": "delegate_to_sub_agent", "args": {"task": "German poem"}, "id": "call_3"}, + ], + ), + AIMessage(content="Here are your three poems."), + ] + # Each worker invocation pops one reply; provide enough for the fan-out. + worker_responses = [AIMessage(content=f"poem #{i}") for i in range(1, 6)] + + fake_manager = _fake_manager(*manager_responses) + fake_worker = _fake_manager(*worker_responses) + + def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: + if llm_config.name == "manager_llm": + return fake_manager + if llm_config.name == "worker_llm": + return fake_worker + raise AssertionError(f"unexpected llm_config: {llm_config.name}") + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=_dispatch, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **kw: self_obj, + ): + compiled = loader.load_component(mw) + + result = compiled.invoke( + {"messages": [HumanMessage(content="Write 3 poems via sub-agents.")]}, + {"configurable": {"thread_id": "mw-multi"}}, + ) + messages = result["messages"] + + # Every delegation tool_call_id must be answered by exactly one ToolMessage. + requested = { + tc["id"] + for m in messages + if isinstance(m, AIMessage) + for tc in (m.tool_calls or []) + if tc["name"].startswith("delegate_to_") + } + answered = [m.tool_call_id for m in messages if type(m).__name__ == "ToolMessage"] + assert requested == {"call_1", "call_2", "call_3"} + assert sorted(answered) == ["call_1", "call_2", "call_3"], ( + f"unanswered delegations: {requested - set(answered)}" + ) + # No duplicate replies, and each carries a worker poem. + assert len(answered) == 3 + tool_msgs = [m for m in messages if type(m).__name__ == "ToolMessage"] + assert all(m.content.startswith("poem #") for m in tool_msgs) + + # ─── Recursive nesting ────────────────────────────────────────────────────── From fd1eede6b0a8623e334acfd0de895050dc707be1 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Mon, 22 Jun 2026 14:04:51 +0400 Subject: [PATCH 13/34] fix(adapters/langgraph): strip delegate tool calls from ManagerWorkers state snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delegation-hiding filter dropped the worker reply ToolMessages from node/state payloads but left the synthetic delegate_to_ tool calls on the manager's AIMessage. A consumer that builds its message snapshot from an on_chain_end state payload (e.g. the AG-UI MESSAGES_SNAPSHOT) reads tool_calls straight off the AIMessage, so it saw delegate tool calls whose results had been removed — rendered as a "tool call with no result". The multi-delegation fan-out made this obvious: every delegate call in the turn showed up orphaned. _scrub_payload_messages now also strips the delegate tool calls off AIMessages in payloads (dropping an AIMessage left empty — a pure delegation turn), reusing _scrubbed_ai_message. Messages are walked in order so a delegation AIMessage records its call ids before its reply ToolMessages are tested for removal. Real (non-delegation) tool calls and their results are preserved. The graph's message state is untouched; only the emitted event view is scrubbed. Adds a regression test asserting a full state snapshot surfaces neither the delegate calls nor their results while keeping a real tool call/result pair and the manager's final answer. --- .../adapters/langgraph/_langgraphconverter.py | 46 +++++++++-- .../adapters/langgraph/test_managerworkers.py | 81 +++++++++++++++++++ 2 files changed, 119 insertions(+), 8 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 6c81b48b..8d0a3df7 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -2595,12 +2595,25 @@ def _scrub_payload_messages( payload: Any, delegate_call_ids: "set", ) -> Tuple[Any, bool]: - """For a node payload shaped ``{"messages": [...]}``, drop the worker's - synthetic reply ToolMessage (matched to a now-hidden delegate call id). - - Returns ``(payload, drop_event)``: ``payload`` is a new dict when a - ToolMessage was removed (the original is never mutated), otherwise the - object passed in. ``drop_event`` is ``True`` when the removal empties the + """For a node / state payload shaped ``{"messages": [...]}``, remove the + whole delegation protocol so it never surfaces in a consumer-facing + message snapshot: drop the worker's synthetic reply ToolMessage(s) AND + strip the synthetic ``delegate_to_`` tool calls off the manager's + AIMessage(s), dropping an AIMessage that is left empty (a pure delegation + turn). + + Stripping the tool calls — not just the ToolMessages — is what keeps a + downstream message snapshot consistent. A consumer that builds its + message history from an ``on_chain_end`` state payload (e.g. the AG-UI + MESSAGES_SNAPSHOT) reads ``tool_calls`` straight off the AIMessage; if we + dropped only the reply ToolMessages, the snapshot would carry delegate + tool calls whose results are gone, which renders as a "tool call with no + result". Messages are walked in order, so a delegation AIMessage records + its call ids before its reply ToolMessages are tested for removal. + + Returns ``(payload, drop_event)``: ``payload`` is a new dict when + anything changed (the original is never mutated), otherwise the object + passed in. ``drop_event`` is ``True`` when scrubbing empties the ``messages`` list, so the caller drops the whole event. """ if not isinstance(payload, dict): @@ -2608,8 +2621,25 @@ def _scrub_payload_messages( messages = payload.get("messages") if not isinstance(messages, list) or not messages: return payload, False - kept = [m for m in messages if not _is_delegate_tool_message(m, delegate_call_ids)] - if len(kept) == len(messages): + kept: List[Any] = [] + changed = False + for m in messages: + # The worker's reply ToolMessage — pure delegation plumbing. + if _is_delegate_tool_message(m, delegate_call_ids): + changed = True + continue + # An AIMessage may carry delegate tool calls; strip them and drop the + # message if nothing renderable remains. Non-delegation messages + # (real tool calls/results, plain content) are left untouched. + if hasattr(m, "tool_calls"): + scrubbed, is_empty = _scrubbed_ai_message(m, set(), delegate_call_ids) + if scrubbed is not None: + changed = True + if not is_empty: + kept.append(scrubbed) + continue + kept.append(m) + if not changed: return payload, False new_payload = dict(payload) new_payload["messages"] = kept diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index bafd8746..4472706c 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -790,6 +790,87 @@ def test_delegation_filter_drops_worker_synthetic_tool_message() -> None: assert kept["data"]["output"]["messages"] == [other] +def test_delegation_filter_strips_delegate_calls_from_state_snapshot() -> None: + """Regression: a node/state payload (``on_chain_end``) carrying the full + ``messages`` list must surface NEITHER the delegate tool calls NOR their + reply ToolMessages. + + A consumer builds its message snapshot from this payload and reads + ``tool_calls`` straight off the AIMessage. If the filter dropped only the + reply ToolMessages but left the delegate tool calls on the AIMessage, the + snapshot would show delegate tool calls with no results — rendered as a + "tool call with no result". Real (non-delegation) tool calls and their + results must be preserved. + """ + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _DelegationEventFilter, + ) + + f = _DelegationEventFilter() + # Manager's delegation turn streams first so the filter learns the ids. + delegate_ai = AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_sub_agent", "args": {"task": "ES"}, "id": "call_1"}, + {"name": "delegate_to_sub_agent", "args": {"task": "FR"}, "id": "call_2"}, + ], + ) + f.scrub( + {"event": "on_chat_model_end", "name": "x", "run_id": "r", + "data": {"output": delegate_ai}} + ) + + # The final-state snapshot carries the whole conversation, including a + # real tool call ("search") + its result that must survive. + snapshot = { + "messages": [ + HumanMessage(content="2 poems via sub-agents"), + delegate_ai, + ToolMessage(content="poem ES", tool_call_id="call_1"), + ToolMessage(content="poem FR", tool_call_id="call_2"), + AIMessage( + content="", + tool_calls=[{"name": "search", "args": {"q": "x"}, "id": "call_real"}], + ), + ToolMessage(content="search result", tool_call_id="call_real"), + AIMessage(content="Here are your poems."), + ] + } + out = f.scrub( + {"event": "on_chain_end", "name": "__manager__", "run_id": "r2", + "data": {"output": snapshot}} + ) + assert out is not None + msgs = out["data"]["output"]["messages"] + + # No delegate tool calls and no delegate ToolMessages remain. + delegate_calls = [ + tc for m in msgs if isinstance(m, AIMessage) + for tc in (m.tool_calls or []) if tc["name"].startswith("delegate_to_") + ] + assert delegate_calls == [] + tool_ids = [m.tool_call_id for m in msgs if type(m).__name__ == "ToolMessage"] + assert "call_1" not in tool_ids and "call_2" not in tool_ids + # The empty delegation AIMessage is dropped entirely. + assert delegate_ai not in msgs + + # The REAL tool call + its result are preserved and still paired. + real_calls = [ + tc["id"] for m in msgs if isinstance(m, AIMessage) + for tc in (m.tool_calls or []) + ] + assert real_calls == ["call_real"] + assert "call_real" in tool_ids + # The human turn and the manager's final answer survive. + assert any(type(m).__name__ == "HumanMessage" for m in msgs) + assert msgs[-1].content == "Here are your poems." + + # The original state objects are never mutated (graph state stays intact). + assert delegate_ai.tool_calls and len(delegate_ai.tool_calls) == 2 + + def test_delegation_filter_passes_through_real_content() -> None: from langchain_core.messages import AIMessageChunk From b6dd939284b9e1baf91d9a00065a6542a96cfbbb Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Tue, 23 Jun 2026 20:24:49 +0400 Subject: [PATCH 14/34] fix(adapters/langgraph): send sensitive_headers on remote MCP requests The langgraph converter built every remote MCP connection from `headers` only, dropping `sensitive_headers`. Sensitive headers are redacted from exported configs but must still travel on live requests (the two maps are validated disjoint), so credentials configured as sensitive headers (e.g. an Authorization token) never reached the MCP server. Merge both maps via a shared helper for all four remote transports (SSE, SSEmTLS, StreamableHTTP, StreamableHTTPmTLS). --- .../adapters/langgraph/_langgraphconverter.py | 24 +++++++-- .../tests/adapters/langgraph/mcp/test_mcp.py | 53 +++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 6c81b48b..885e60e8 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -100,6 +100,7 @@ from pyagentspec.llms.vllmconfig import VllmConfig from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers from pyagentspec.mcp.clienttransport import ClientTransport as AgentSpecClientTransport +from pyagentspec.mcp.clienttransport import RemoteTransport as AgentSpecRemoteTransport from pyagentspec.mcp.clienttransport import SSEmTLSTransport as AgentSpecSSEmTLSTransport from pyagentspec.mcp.clienttransport import SSETransport as AgentSpecSSETransport from pyagentspec.mcp.clienttransport import StdioTransport as AgentSpecStdioTransport @@ -201,6 +202,21 @@ def _exec_body(ns: Dict[str, Any]) -> None: ) +def _remote_transport_headers( + transport: AgentSpecRemoteTransport, +) -> Optional[Dict[str, str]]: + """Return the headers to send on the wire for a remote MCP transport. + + A ``RemoteTransport`` carries both ``headers`` and ``sensitive_headers``, + validated to be disjoint. ``sensitive_headers`` is only redacted from + *exported* configs (so credentials never leak into a saved spec) -- it must + still travel on live requests. Merge both so a header configured as + sensitive (e.g. an ``Authorization`` token) actually reaches the server. + """ + merged = {**(transport.headers or {}), **(transport.sensitive_headers or {})} + return merged or None + + class AgentSpecToLangGraphConverter: def convert( self, @@ -1671,7 +1687,7 @@ def _client_transport_convert_to_langgraph( return SSEConnection( transport="sse", url=agentspec_component.url, - headers=agentspec_component.headers, + headers=_remote_transport_headers(agentspec_component), httpx_client_factory=_HttpxClientFactory( key_file=agentspec_component.key_file, cert_file=agentspec_component.cert_file, @@ -1682,14 +1698,14 @@ def _client_transport_convert_to_langgraph( return SSEConnection( transport="sse", url=agentspec_component.url, - headers=agentspec_component.headers, + headers=_remote_transport_headers(agentspec_component), httpx_client_factory=_HttpxClientFactory(verify=True), ) if isinstance(agentspec_component, AgentSpecStreamableHTTPmTLSTransport): return StreamableHttpConnection( transport="streamable_http", url=agentspec_component.url, - headers=agentspec_component.headers, + headers=_remote_transport_headers(agentspec_component), httpx_client_factory=_HttpxClientFactory( key_file=agentspec_component.key_file, cert_file=agentspec_component.cert_file, @@ -1700,7 +1716,7 @@ def _client_transport_convert_to_langgraph( return StreamableHttpConnection( transport="streamable_http", url=agentspec_component.url, - headers=agentspec_component.headers, + headers=_remote_transport_headers(agentspec_component), httpx_client_factory=_HttpxClientFactory(verify=True), ) raise ValueError( diff --git a/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py b/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py index 2aa37560..2abf1a0c 100644 --- a/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py +++ b/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py @@ -149,6 +149,59 @@ def test_non_mtls_remote_connections_enable_tls_verification(client_transport, e assert connection["httpx_client_factory"].verify.check_hostname is True +def test_remote_connections_merge_sensitive_headers_into_request( + client_key_path, client_cert_path, ca_cert_path +): + """`sensitive_headers` must travel on the wire alongside `headers`. + + Sensitive headers are redacted from *exported* configs but still have to be + sent on live requests -- otherwise an Authorization token configured as a + sensitive header never reaches the MCP server. Covers all four remote + transports, since each builds its own connection. + """ + headers = {"X-Plain": "plain"} + sensitive_headers = {"Authorization": "Bearer secret"} + expected = {"X-Plain": "plain", "Authorization": "Bearer secret"} + + transports = [ + SSETransport( + name="sse", + url="https://example.com/sse", + headers=headers, + sensitive_headers=sensitive_headers, + ), + StreamableHTTPTransport( + name="streamable-http", + url="https://example.com/mcp", + headers=headers, + sensitive_headers=sensitive_headers, + ), + SSEmTLSTransport( + name="sse-mtls", + url="https://example.com/sse", + headers=headers, + sensitive_headers=sensitive_headers, + key_file=client_key_path, + cert_file=client_cert_path, + ca_file=ca_cert_path, + ), + StreamableHTTPmTLSTransport( + name="streamable-http-mtls", + url="https://example.com/mcp", + headers=headers, + sensitive_headers=sensitive_headers, + key_file=client_key_path, + cert_file=client_cert_path, + ca_file=ca_cert_path, + ), + ] + + converter = AgentSpecToLangGraphConverter() + for transport in transports: + connection = converter._client_transport_convert_to_langgraph(transport) + assert connection["headers"] == expected, transport.__class__.__name__ + + @pytest.fixture(scope="function") def agentspec_agent_with_mcp_toolbox(sse_client_transport, big_llama): return Agent( From 4075db19626c1d3ffd7c22947ecd6fc321da9d3f Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Wed, 24 Jun 2026 22:44:32 +0400 Subject: [PATCH 15/34] fix(tracing): replace get_running_tasks() with get_current_task() in _in_async_trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit anyio.get_running_tasks() enumerates all live asyncio tasks (O(N)) just to detect async context. At high concurrency this was called per LangChain callback event — including on_llm_new_token — making it O(N²) per request stream and responsible for ~73% of CPU in profiles. anyio.get_current_task() is O(1) and has identical semantics: raises RuntimeError outside an async context, returns normally inside one. --- pyagentspec/src/pyagentspec/adapters/langgraph/tracing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/tracing.py b/pyagentspec/src/pyagentspec/adapters/langgraph/tracing.py index fb8bff97..d4132908 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/tracing.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/tracing.py @@ -222,7 +222,7 @@ async def _start_and_copy_ctx_async(self, run_id_str: str, span: AgentSpecSpan) def _in_async_trace(self) -> bool: try: - anyio.get_running_tasks() + anyio.get_current_task() return True except RuntimeError: return False From 3c7317567a297ae12ba0c3101d8e3ba1a93e6fdf Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Mon, 29 Jun 2026 21:12:55 +0400 Subject: [PATCH 16/34] feat(adapters/langgraph): run a ManagerWorkers as a flow step A flow step is wrapped in an AgentNode whose inputs/outputs are the wrapped component's. A plain Agent exposes its prompt {{placeholders}} as inputs, but a ManagerWorkers inferred none, so a data-flow edge into a manager step had no port to resolve against; and AgentNodeExecutor raised a TypeError for any non-Agent component. - ManagerWorkers now infers its inputs/outputs from its group manager, so a manager flow step exposes the group-manager prompt's {{placeholder}} inputs (and the group manager's outputs) and data-flow edges resolve at load. - AgentNodeExecutor runs a ManagerWorkers node: it renders the node inputs into the group-manager prompt (cached per rendered prompt, like the Agent path), compiles the manager graph, invokes it over MessagesState, and maps the result back the same way as an Agent node. --- .../adapters/langgraph/_node_execution.py | 60 +++++++- pyagentspec/src/pyagentspec/managerworkers.py | 24 ++++ .../flows/test_managerworkers_node.py | 134 ++++++++++++++++++ 3 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index ddac3b70..3842fde1 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -49,6 +49,7 @@ from pyagentspec.flows.nodes import OutputMessageNode as AgentSpecOutputMessageNode from pyagentspec.flows.nodes import StartNode as AgentSpecStartNode from pyagentspec.flows.nodes import ToolNode as AgentSpecToolNode +from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers from pyagentspec.property import Property as AgentSpecProperty from pyagentspec.property import _empty_default as pyagentspec_empty_default from pyagentspec.tracing.events import NodeExecutionEnd as AgentSpecNodeExecutionEnd @@ -529,16 +530,73 @@ def _create_react_agent_with_given_input_values( ) return self._agents_cache[system_prompt] + def _create_manager_graph_with_given_input_values( + self, inputs: Dict[str, Any] + ) -> CompiledStateGraph[Any, Any]: + """Compile the node's ``ManagerWorkers`` into a runnable graph for these inputs. + + Mirrors :meth:`_create_react_agent_with_given_input_values`: the node inputs are + rendered into the group manager's ``system_prompt`` (so flow ``{{placeholder}}`` + inputs substitute), and the compiled manager graph is cached by the rendered + prompt. A non-Agent group manager is left untouched so the converter raises its + own clear ``NotImplementedError``. + """ + from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter + + manager_workers = self.node.agent + if not isinstance(manager_workers, AgentSpecManagerWorkers): + raise TypeError( + "_create_manager_graph_with_given_input_values requires a ManagerWorkers" + ) + manager_agent = manager_workers.group_manager + if isinstance(manager_agent, AgentSpecAgent): + cache_key = render_template(manager_agent.system_prompt, inputs) + # The manager graph runs over MessagesState and can't carry structured + # inputs to its inner agent, so we bake them into the prompt instead. The + # rendered prompt then has no `{{placeholders}}`, so the group manager (and + # the manager) accept no further inputs — drop the now-satisfied input ports + # so declared == inferred and the downstream span re-validation passes. + rendered_manager = manager_workers.model_copy( + update={ + "group_manager": manager_agent.model_copy( + update={"system_prompt": cache_key, "inputs": []} + ), + "inputs": [], + } + ) + else: + # The converter rejects a non-Agent group manager; pass through unchanged. + cache_key = manager_workers.id + rendered_manager = manager_workers + if cache_key not in self._agents_cache: + self._agents_cache[ + cache_key + ] = AgentSpecToLangGraphConverter()._manager_workers_convert_to_langgraph( + rendered_manager, + tool_registry=self.tool_registry, + converted_components=self.converted_components, + checkpointer=self.checkpointer, + config=self.config, + middleware=self._middleware, + ) + return self._agents_cache[cache_key] + def _prepare_agent_and_inputs( self, inputs: Dict[str, Any], messages: Messages ) -> Tuple[CompiledStateGraph[Any, Any], Dict[str, Any]]: - agent = self._create_react_agent_with_given_input_values(inputs) # LangGraph's agent expects at least one user message to drive execution. # When an AgentNode is used with a templated system prompt and no messages are provided # by the flow, the agent can crash. To avoid this, we artificially insert an empty # user message when the message list is empty. if not messages: messages = cast(Messages, [{"role": "user", "content": ""}]) + if isinstance(self.node.agent, AgentSpecManagerWorkers): + # A ManagerWorkers flow step runs as a hierarchical graph over MessagesState. + # The node inputs were already rendered into the group-manager prompt, so the + # graph is driven by messages alone (not the agent's remaining_steps state). + graph = self._create_manager_graph_with_given_input_values(inputs) + return graph, {"messages": messages} + agent = self._create_react_agent_with_given_input_values(inputs) inputs |= { "remaining_steps": 20, # Get the right number of steps left "messages": messages, diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index 26c2bdeb..a95de155 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -13,6 +13,7 @@ from typing_extensions import Self from pyagentspec.agenticcomponent import AgenticComponent +from pyagentspec.property import Property from pyagentspec.validation_helpers import model_validator_with_error_accumulation from pyagentspec.versioning import AgentSpecVersionEnum @@ -65,6 +66,29 @@ class ManagerWorkers(AgenticComponent): default=AgentSpecVersionEnum.v25_4_2, init=False, exclude=True ) + def _get_inferred_inputs(self) -> List[Property]: + """A ``ManagerWorkers`` exposes the inputs of its group manager. + + The group manager is the component that drives the conversation and whose prompt + the run-time renders, so the manager-workers component accepts exactly the inputs + the group manager accepts (e.g. the ``{{placeholder}}`` inputs of an ``Agent`` + group manager). Without this, the base default infers no inputs, so a + ``ManagerWorkers`` used as a flow ``AgentNode`` would expose no input ports and a + data-flow edge into it could not resolve. + """ + group_manager = getattr(self, "group_manager", None) + return list(getattr(group_manager, "inputs", None) or []) + + def _get_inferred_outputs(self) -> List[Property]: + """A ``ManagerWorkers`` exposes the outputs of its group manager. + + Symmetric with :meth:`_get_inferred_inputs`: the group manager produces the + component's result, so a ``ManagerWorkers`` used as a flow ``AgentNode`` can have + its output wired downstream (or surfaced as a leaf) just like an ``Agent`` step. + """ + group_manager = getattr(self, "group_manager", None) + return list(getattr(group_manager, "outputs", None) or []) + @model_validator_with_error_accumulation def _validate_one_or_more_workers(self) -> Self: if len(self.workers) == 0: diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py new file mode 100644 index 00000000..f6f99009 --- /dev/null +++ b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py @@ -0,0 +1,134 @@ +# Copyright © 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +"""A ManagerWorkers used as a flow step (AgentNode). + +Regression coverage for two coupled behaviours: + * ``ManagerWorkers._get_inferred_inputs`` exposes the group manager's inputs, so a + flow ``AgentNode`` wrapping a manager declares input ports and a ``DataFlowEdge`` + into it resolves at load (previously: "node does not have any input property..."). + * ``AgentNodeExecutor`` runs a ManagerWorkers node (previously: TypeError "can only + be used with AgentSpecAgent agents"), rendering the node inputs into the group + manager's prompt and returning its result. +""" + +from pyagentspec.agent import Agent +from pyagentspec.managerworkers import ManagerWorkers +from pyagentspec.property import StringProperty + + +def test_managerworkers_infers_inputs_from_group_manager_prompt() -> None: + """A ManagerWorkers exposes the group manager's prompt placeholders as inputs.""" + llm = {"name": "m", "model_id": "fake", "url": "null"} + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + cfg = OpenAiCompatibleConfig(**llm) + manager = Agent( + name="manager", + llm_config=cfg, + system_prompt="Translate the following to Arabic:\n\n{{joke}}\n\nMake {{count}} variants.", + ) + worker = Agent(name="worker", llm_config=cfg, system_prompt="You translate.") + mw = ManagerWorkers(name="mw", group_manager=manager, workers=[worker]) + + assert sorted(p.title for p in (mw.inputs or [])) == ["count", "joke"] + + +def test_managerworkers_runs_as_a_flow_step_with_data_edge_inputs() -> None: + """A ManagerWorkers flow step loads (data edge resolves) and executes offline. + + The model is stubbed (no real LLM, no delegation), so the manager produces a final + message and the manager graph routes straight to END. Asserts the flow both loads — + proving the manager node exposes the ``joke`` input the data edge targets — and runs, + surfacing the manager's answer as the node's single string output. + """ + from unittest.mock import patch + + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge + from pyagentspec.flows.flow import Flow + from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + # Final message has no tool_calls → the manager routes to END without delegating. + fake_llm = _FakeModel(responses=[AIMessage(content="لماذا...")]) + + cfg = OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null") + joke = StringProperty(title="joke") + translated = StringProperty(title="translated") + + manager = Agent( + name="manager", + llm_config=cfg, + system_prompt="Translate the following to Arabic:\n\n{{joke}}", + outputs=[translated], + ) + worker = Agent(name="worker", llm_config=cfg, system_prompt="You translate.") + mw = ManagerWorkers(name="translator", group_manager=manager, workers=[worker]) + # The manager node exposes the group manager's `joke` input, and the single + # `translated` output (inherited from the group manager) for the leaf edge. + assert [p.title for p in (mw.inputs or [])] == ["joke"] + + manager_node = AgentNode(name="manager_node", agent=mw) + start_node = StartNode(name="start", inputs=[joke]) + end_node = EndNode(name="end", outputs=[translated]) + flow = Flow( + name="flow", + start_node=start_node, + nodes=[start_node, manager_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=manager_node), + ControlFlowEdge(name="node_to_end", from_node=manager_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="joke_edge", + source_node=start_node, + source_output=joke.title, + destination_node=manager_node, + destination_input=joke.title, + ), + DataFlowEdge( + name="translated_edge", + source_node=manager_node, + source_output=translated.title, + destination_node=end_node, + destination_input=translated.title, + ), + ], + outputs=[translated], + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ): + compiled = loader.load_component(flow) + result = compiled.invoke( + {"inputs": {"joke": "Why did the car..."}, "messages": [{"role": "user", "content": ""}]}, + {"configurable": {"thread_id": "managerworkers-node"}}, + ) + + assert "outputs" in result + assert result["outputs"]["translated"] == "لماذا..." From 728e126c94f78c9385041900afab63447c445808 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Tue, 30 Jun 2026 11:39:06 +0400 Subject: [PATCH 17/34] feat(adapters/langgraph): support ManagerWorkers as a Swarm member The LangGraph adapter rejected any non-Agent Swarm member, so an Agent that carries sub-agents (serialized as a ManagerWorkers) could not take part in a Swarm: "Only Agents are supported as part of a Swarm ...". A Swarm handoff is a Command(goto=X, graph=Command.PARENT). Fired from inside a ManagerWorkers' manager it only reaches the ManagerWorkers graph (one level up) and is silently dropped, never reaching the Swarm. So a ManagerWorkers member needs the handoff re-emitted at its parent boundary. - _swarm_convert_to_langgraph: accept Agent and ManagerWorkers members (clear NotImplementedError for Flow / nested Swarm, which have no single LLM to attach handoff tools to). ManagerWorkers members are built with their swarm handoff destinations. - _manager_workers_convert_to_langgraph: new swarm_handoff_destinations param (default off -> unchanged behaviour for standalone / flow-step / nested ManagerWorkers). It synthesizes transfer_to_ placeholder tools on the manager (mirroring the delegate_to_ pattern), adds a __handoff__ parent node that re-emits Command(goto=sibling, graph=PARENT, active_agent=sibling), and the router now prefers handoff over delegation. - The __handoff__ node forwards the manager's transfer AIMessage ahead of a ToolMessage answering every open tool call, so the Swarm transcript stays a valid tool-call/result sequence (the ManagerWorkers' internal messages do not merge into the Swarm on a PARENT-jump exit; an orphan ToolMessage would 400 the next member's LLM). Tests: 5 new offline tests in test_managerworkers.py (helpers, routing precedence, the handoff node, a full E2E Swarm-with-ManagerWorkers handoff, and the unsupported-member rejection). Agent-only Swarms are unchanged. --- .../adapters/langgraph/_langgraphconverter.py | 357 +++++++++++++++--- .../adapters/langgraph/test_managerworkers.py | 209 +++++++++- 2 files changed, 505 insertions(+), 61 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index cc7f8c9f..8cadf1a0 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -1124,59 +1124,84 @@ def _swarm_convert_to_langgraph( raise ValueError( "Handoff mode NEVER is not supported for conversion in LangGraph adapter" ) - agents: dict[str, AgentSpecAgent] = { - # LangGraph distinguishes agents by name, so we use names here. - # We also assume to get only agents in relationships. - agent.name: cast(AgentSpecAgent, agent) - # Relationships are tuples of (from_agent, to_agent) - for agent in (e for r in agentspec_component.relationships for e in r) + members: Dict[str, AgentSpecComponent] = { + # LangGraph distinguishes members by name, so we key by name. + member.name: member + # Relationships are tuples of (from_member, to_member) + for member in (e for r in agentspec_component.relationships for e in r) } - for agent in agents.values(): - # Since handoff is performed with tools, we can only support agents in relationships for now - # Note that the fact that we called `cast` before does not change the actual type of the agent - if not isinstance(agent, AgentSpecAgent): - raise ValueError( - f"Only Agents are supported as part of a Swarm in the LangGraph adapter, received {type(agent)} instead." + for member in members.values(): + # Swarm handoff is driven by an LLM emitting a transfer tool-call, + # so a member must run a chat loop the handoff tools can attach to: + # a plain Agent, or a ManagerWorkers (whose group-manager Agent + # makes the decision). A Flow / nested Swarm has no single + # "decide-the-handoff" LLM, so it cannot participate yet. + if not isinstance(member, (AgentSpecAgent, AgentSpecManagerWorkers)): + raise NotImplementedError( + "Only Agent and ManagerWorkers members are supported as part " + f"of a Swarm in the LangGraph adapter, received " + f"{type(member).__name__} instead." ) - # We convert the agents event though we do not use them in langgraph, since we have to append - # the handoff tools, but at least this way the agents will be created and stored in the registry - # of converted components in case they are used in other places + # We convert each member even though we do not use this copy in the + # swarm (the handoff-enabled copy is built below), so it is created + # and stored in the converted-components registry in case it is + # referenced elsewhere in the spec. self.convert( - agent, + member, tool_registry=tool_registry, converted_components=converted_components, checkpointer=checkpointer, config=config, middleware=middleware, ) - handoffs: dict[str, list[str]] = {agent_name: [] for agent_name in agents} - for from_agent, to_agent in agentspec_component.relationships: - handoffs[from_agent.name].append(to_agent.name) - # We re-create the agents with the additional handoff tools - langgraph_agents: list[CompiledStateGraph[Any, Any, Any]] = [ - self._create_react_agent_with_given_info( - agent=agent, - name=agent.name, - system_prompt=agent.system_prompt, - llm_config=agent.llm_config, - tools=agent.tools, - toolboxes=agent.toolboxes, - inputs=agent.inputs or [], - outputs=agent.outputs or [], - tool_registry=tool_registry, - converted_components=converted_components, - checkpointer=checkpointer, - config=config, - middleware=middleware, - additional_langgraph_tools=[ - langgraph_swarm.create_handoff_tool(agent_name=to_agent_name) - for to_agent_name in handoffs.get(agent.name, []) - ], + handoffs: Dict[str, List[str]] = {name: [] for name in members} + for from_member, to_member in agentspec_component.relationships: + handoffs[from_member.name].append(to_member.name) + # We re-create each member equipped with the handoff tools that let it + # transfer the conversation to its related members. + langgraph_members: List[CompiledStateGraph[Any, Any, Any]] = [] + for member in members.values(): + destinations = handoffs.get(member.name, []) + if isinstance(member, AgentSpecManagerWorkers): + # A ManagerWorkers member gets its handoff tools wired onto its + # group-manager and re-emitted to the swarm — see + # `_manager_workers_convert_to_langgraph`. + langgraph_members.append( + self._manager_workers_convert_to_langgraph( + member, + tool_registry=tool_registry, + converted_components=converted_components, + checkpointer=checkpointer, + config=config, + middleware=middleware, + swarm_handoff_destinations=destinations, + ) + ) + continue + agent = cast(AgentSpecAgent, member) + langgraph_members.append( + self._create_react_agent_with_given_info( + agent=agent, + name=agent.name, + system_prompt=agent.system_prompt, + llm_config=agent.llm_config, + tools=agent.tools, + toolboxes=agent.toolboxes, + inputs=agent.inputs or [], + outputs=agent.outputs or [], + tool_registry=tool_registry, + converted_components=converted_components, + checkpointer=checkpointer, + config=config, + middleware=middleware, + additional_langgraph_tools=[ + langgraph_swarm.create_handoff_tool(agent_name=to_name) + for to_name in destinations + ], + ) ) - for agent in agents.values() - ] return langgraph_swarm.create_swarm( - agents=langgraph_agents, # type: ignore + agents=langgraph_members, # type: ignore default_active_agent=agentspec_component.first_agent.name, ).compile(name=agentspec_component.name, checkpointer=checkpointer) @@ -1188,6 +1213,7 @@ def _manager_workers_convert_to_langgraph( checkpointer: Optional[Checkpointer], config: RunnableConfig, middleware: List[Any], + swarm_handoff_destinations: Optional[List[str]] = None, ) -> CompiledStateGraph[Any, Any, Any]: """Compile a ``ManagerWorkers`` into a hierarchical LangGraph. @@ -1210,6 +1236,16 @@ def _manager_workers_convert_to_langgraph( delegation tool-call id. Recursive ``ManagerWorkers`` (workers that are themselves ``ManagerWorkers``) compose for free through ``self.convert(...)``. + + ``swarm_handoff_destinations`` is set only when this ManagerWorkers is + a member of a ``Swarm``: it lists the sibling member names this MW may + hand the conversation off to. Each becomes a synthetic + ``transfer_to_`` tool on the manager, plus a ``__handoff__`` + parent node that re-emits the handoff up to the Swarm (the manager's + own ``Command(graph=PARENT)`` would stop one level short, at this + ManagerWorkers graph). When ``None``/empty (the standalone, Flow-step, + or nested-worker case) no handoff machinery is added and behaviour is + unchanged. """ if not isinstance(mw.group_manager, AgentSpecAgent): # Pyagentspec allows any AgenticComponent as group_manager, @@ -1265,7 +1301,22 @@ def _manager_workers_convert_to_langgraph( for node_name in worker_node_names ] - # 4. Compile the manager as a react-agent with the delegation tools. + # 3b. When this ManagerWorkers is a Swarm member, synthesize one + # transfer_to_ tool per allowed handoff destination. Like + # the delegation tools these are placeholders: the manager's LLM + # emits the call, the parent graph intercepts it (routing edge + # below) and the `__handoff__` node re-emits the handoff to the + # Swarm. We map the (normalized) tool name back to the raw sibling + # name, which is the Swarm node name used as the handoff `goto`. + handoff_dest_by_tool_name: Dict[str, str] = {} + handoff_tools: List[Any] = [] + for dest in swarm_handoff_destinations or []: + handoff_tool = _make_swarm_handoff_tool(dest) + handoff_tools.append(handoff_tool) + handoff_dest_by_tool_name[handoff_tool.name] = dest + + # 4. Compile the manager as a react-agent with the delegation tools + # (and, for a Swarm member, the transfer tools). manager_graph = self._create_react_agent_with_given_info( name=manager_agent.name, system_prompt=rendered_prompt, @@ -1280,7 +1331,7 @@ def _manager_workers_convert_to_langgraph( checkpointer=checkpointer, config=config, middleware=middleware, - additional_langgraph_tools=delegation_tools, + additional_langgraph_tools=delegation_tools + handoff_tools, ) # 5. Compose the parent StateGraph. The manager and every worker @@ -1303,17 +1354,36 @@ def _manager_workers_convert_to_langgraph( _wrap_worker_for_subgraph(worker_graph, node_name), ) + # Path-map covers delegate-to-worker and the END branch so langgraph + # can statically validate the routing; the Swarm-handoff branch is + # added only when this MW is a swarm member with handoff destinations. + routing_path_map: Dict[str, str] = { + node_name: node_name for node_name in worker_node_names + } + routing_path_map[langgraph_graph.END] = langgraph_graph.END + if handoff_dest_by_tool_name: + if _HANDOFF_NODE_KEY in worker_graphs: + raise ValueError( + f"Worker name '{_HANDOFF_NODE_KEY}' is reserved for the " + f"Swarm-handoff node in ManagerWorkers; rename the worker." + ) + builder.add_node( + _HANDOFF_NODE_KEY, + _make_handoff_forward_node(handoff_dest_by_tool_name), + ) + routing_path_map[_HANDOFF_NODE_KEY] = _HANDOFF_NODE_KEY + builder.add_edge(langgraph_graph.START, manager_node_key) - # Path-map covers both delegate-to-worker and the END branch so - # langgraph can statically validate the routing. builder.add_conditional_edges( manager_node_key, - _route_manager_to_worker_or_end, - {node_name: node_name for node_name in worker_node_names} - | {langgraph_graph.END: langgraph_graph.END}, + _route_manager_to_worker_handoff_or_end, + routing_path_map, ) for node_name in worker_node_names: builder.add_edge(node_name, manager_node_key) + # The `__handoff__` node has no outgoing edge on purpose: it returns a + # Command(goto=, graph=PARENT) that exits this graph into the + # Swarm, so looping it back to the manager would be wrong. compiled_graph = builder.compile( checkpointer=checkpointer, name=mw.name @@ -2204,6 +2274,16 @@ def _ensure_checkpointer_and_valid_tool_config( # the normalized worker node name. _DELEGATE_TOOL_PREFIX = "delegate_to_" +# Prefix the manager's LLM uses to hand the conversation off to a sibling +# Swarm member (only present when this ManagerWorkers is a Swarm member). The +# suffix is the normalized sibling name; matches langgraph_swarm's convention. +_HANDOFF_TOOL_PREFIX = "transfer_to_" + +# Node key for the Swarm-handoff node in the ManagerWorkers parent StateGraph. +# Like ``_MANAGER_NODE_KEY`` it cannot collide with a normalized worker node +# name (which never contains leading/trailing underscores). +_HANDOFF_NODE_KEY = "__handoff__" + # Keys carried on the per-delegation ``Send`` payload from the manager's # routing edge to a worker node, so a worker run knows which task it was # given and which ``tool_call_id`` its reply ToolMessage must answer. This @@ -2273,7 +2353,7 @@ def _make_worker_delegation_tool(worker_node_name: str) -> Any: langchain ``ToolNode`` propagates up to the parent graph, breaking out of the manager's react-agent inner loop. It deliberately carries **no ``goto``**: routing is the parent graph's conditional edge's job - (:func:`_route_manager_to_worker_or_end`), which inspects the manager's + (:func:`_route_manager_to_worker_handoff_or_end`), which inspects the manager's AIMessage and fans out one ``Send`` per ``delegate_to_`` call. Routing via ``goto`` here would be wrong when the manager emits several delegations in a single turn — ``ToolNode`` collapses the multiple @@ -2333,10 +2413,175 @@ def _delegate( return _delegate -def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: - """Inspect the manager's last AIMessage and fan out one worker run per - ``delegate_to_`` tool call, via ``Send``; return ``END`` when - the manager delegated to no one. +def _handoff_tool_name(destination_name: str) -> str: + """``transfer_to_`` — the tool name the manager's LLM + emits to hand off to a Swarm sibling. Normalization mirrors + :func:`_safe_node_name` so the name is a clean tool identifier; the raw + destination (the Swarm node name used as the handoff ``goto``) is recovered + via the tool-name→destination map held by the handoff node.""" + normalized = re.sub(r"[^a-z0-9]+", "_", (destination_name or "").lower()).strip("_") + return f"{_HANDOFF_TOOL_PREFIX}{normalized or 'agent'}" + + +def _make_swarm_handoff_tool(destination_name: str) -> Any: + """Build the ``transfer_to_`` tool a Swarm-member ManagerWorkers' + manager LLM emits to hand the conversation off to a sibling member. + + Mirrors :func:`_make_worker_delegation_tool`: the body returns a + ``Command(graph=Command.PARENT)`` carrying **no** ``goto`` — it only breaks + the manager's react loop and surfaces the AIMessage on the parent graph. + The real handoff is performed by the parent's handoff node + (:func:`_make_handoff_forward_node`), which re-emits a + ``Command(goto=, graph=Command.PARENT)`` so the destination is + resolved against the *Swarm* graph. A plain + ``langgraph_swarm.create_handoff_tool`` cannot be used directly on the + manager: its single ``Command(graph=PARENT)`` would land on *this* + ManagerWorkers graph (one level short of the Swarm) and be silently + dropped, so the handoff would never happen. + """ + from typing import Annotated + + from langchain_core.tools import InjectedToolCallId, tool + from langgraph.prebuilt import InjectedState + from langgraph.types import Command + + tool_name = _handoff_tool_name(destination_name) + + @tool(tool_name) + def _handoff( + state: Annotated[Any, InjectedState], + tool_call_id: Annotated[str, InjectedToolCallId], + ) -> Command: + """Hand the whole conversation off to the named agent.""" + if isinstance(state, dict): + subgraph_messages = list(state.get("messages") or []) + else: + subgraph_messages = list(getattr(state, "messages", []) or []) + del tool_call_id # id is recovered from the AIMessage by the handoff node + return Command( + graph=Command.PARENT, + update={"messages": subgraph_messages}, + ) + + _handoff.description = ( + f"Transfer the full conversation to the '{destination_name}' agent so " + f"it takes over the dialogue with the user. Use this when " + f"'{destination_name}' is better suited to continue; you will not " + f"regain control afterwards." + ) + return _handoff + + +def _make_handoff_forward_node(handoff_dest_by_tool_name: Dict[str, str]) -> Any: + """Build the parent-graph node that performs a Swarm handoff. + + Reached (via the routing edge) when the manager's last AIMessage carries a + ``transfer_to_`` tool call. It returns a + ``Command(goto=, graph=Command.PARENT, update={..., active_agent})`` + that re-emits the handoff up to the Swarm graph (mirroring what + ``langgraph_swarm.create_handoff_tool`` does for a plain Agent member). + + Transcript validity: the ManagerWorkers graph exits via this PARENT + command rather than running to its own END, so its internal messages do + **not** merge into the shared Swarm conversation — only this command's + ``update`` does. We therefore forward the manager's transfer AIMessage + itself, followed by a ``ToolMessage`` answering every (still-unanswered) + tool call on it, so the Swarm sees a well-formed + ``AIMessage(tool_calls)`` → ``ToolMessage`` sequence — an orphan + ToolMessage would 400 the next member's LLM. Answering *every* call (not + just the transfer) keeps the sequence valid even when the manager emitted + delegations in the same turn; the manager's internal delegation mechanics + otherwise stay hidden inside the ManagerWorkers, as in a standalone run. + + Returns a ``RunnableLambda`` exposing both sync (``func``) and async + (``afunc``) entrypoints so LangGraph can call it on either path; the body + is pure so the async wrapper just delegates. + """ + from langchain_core.messages import ToolMessage + from langgraph.types import Command + + from pyagentspec.adapters.langgraph._types import RunnableLambda + + def _forward(state: Dict[str, Any]) -> Any: + messages = state.get("messages") or [] + last = messages[-1] if messages else None + tool_calls = getattr(last, "tool_calls", None) or [] + transfer_call = next( + ( + tc + for tc in tool_calls + if _tc_get(tc, "name") in handoff_dest_by_tool_name + ), + None, + ) + if transfer_call is None: + # Defensive: the routing edge only sends us here on a transfer call. + return {"messages": []} + destination = handoff_dest_by_tool_name[_tc_get(transfer_call, "name")] + transfer_id = _tc_get(transfer_call, "id") or "" + already_answered = { + getattr(m, "tool_call_id", None) + for m in messages + if getattr(m, "type", None) == "tool" + } + tool_messages: List[Any] = [] + for tc in tool_calls: + call_id = _tc_get(tc, "id") or "" + if call_id in already_answered: + continue + if call_id == transfer_id: + content = f"Successfully transferred to {destination}" + else: + content = ( + f"Not executed: the conversation was handed off to " + f"{destination}." + ) + tool_messages.append( + ToolMessage( + content=content, + name=_tc_get(tc, "name"), + tool_call_id=call_id, + ) + ) + return Command( + goto=destination, + graph=Command.PARENT, + # Forward the manager's transfer AIMessage ahead of the answering + # ToolMessages so the Swarm transcript is a valid tool-call/result + # sequence (the MW's internal messages don't otherwise merge on a + # PARENT-jump exit). add_messages dedupes by id, so re-sending an + # already-present message is a no-op. + update={"messages": [last, *tool_messages], "active_agent": destination}, + ) + + async def _forward_async(state: Dict[str, Any]) -> Any: + return _forward(state) + + return RunnableLambda( + func=_forward, + afunc=_forward_async, + name="swarm_handoff", + ) + + +def _is_handoff_name(name: Any) -> bool: + """True if ``name`` is one of the synthetic ``transfer_to_`` tool + names the manager emits to hand the conversation off to a Swarm sibling.""" + return isinstance(name, str) and name.startswith(_HANDOFF_TOOL_PREFIX) + + +def _route_manager_to_worker_handoff_or_end(state: Dict[str, Any]) -> Any: + """Inspect the manager's last AIMessage and route the parent graph. + + Routing precedence: + + * a ``transfer_to_`` tool call → the Swarm-handoff node + (:func:`_make_handoff_forward_node`), which re-emits the handoff up to + the Swarm. Handoff transfers the *whole* conversation, so it wins over + delegation and routes to a single destination (only present when this + ManagerWorkers is a Swarm member). + * one or more ``delegate_to_`` tool calls → one ``Send`` per + delegation; otherwise → ``END``. A single manager turn may delegate to several workers at once — the LLM emits multiple ``delegate_to_`` tool calls in one AIMessage @@ -2358,6 +2603,10 @@ def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: return langgraph_graph.END last = messages[-1] tool_calls = getattr(last, "tool_calls", None) or [] + # Swarm handoff wins: it hands the whole conversation to a sibling, so any + # delegations in the same turn are answered-and-dropped by the handoff node. + if any(_is_handoff_name(_tc_get(tc, "name")) for tc in tool_calls): + return _HANDOFF_NODE_KEY sends = [] for tc in tool_calls: name = _tc_get(tc, "name") diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index 4472706c..55b32438 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -92,7 +92,7 @@ def test_route_manager_to_worker_or_end_sends_to_pending_delegation() -> None: from pyagentspec.adapters.langgraph._langgraphconverter import ( _DELEGATE_CALL_ID_KEY, _DELEGATE_TASK_KEY, - _route_manager_to_worker_or_end, + _route_manager_to_worker_handoff_or_end, ) delegating = AIMessage( @@ -101,7 +101,7 @@ def test_route_manager_to_worker_or_end_sends_to_pending_delegation() -> None: {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "c1"} ], ) - sends = _route_manager_to_worker_or_end({"messages": [delegating]}) + sends = _route_manager_to_worker_handoff_or_end({"messages": [delegating]}) # One delegation → a single Send to the worker node carrying the task # and the tool_call_id its reply must answer. assert isinstance(sends, list) and len(sends) == 1 @@ -114,13 +114,13 @@ def test_route_manager_to_worker_or_end_returns_end_when_no_delegation() -> None from langchain_core.messages import AIMessage from pyagentspec.adapters.langgraph._langgraphconverter import ( - _route_manager_to_worker_or_end, + _route_manager_to_worker_handoff_or_end, ) from langgraph.graph import END not_delegating = AIMessage(content="Done.", tool_calls=[]) - assert _route_manager_to_worker_or_end({"messages": [not_delegating]}) == END - assert _route_manager_to_worker_or_end({"messages": []}) == END + assert _route_manager_to_worker_handoff_or_end({"messages": [not_delegating]}) == END + assert _route_manager_to_worker_handoff_or_end({"messages": []}) == END def test_route_manager_to_worker_or_end_fans_out_every_delegation() -> None: @@ -130,7 +130,7 @@ def test_route_manager_to_worker_or_end_fans_out_every_delegation() -> None: from pyagentspec.adapters.langgraph._langgraphconverter import ( _DELEGATE_CALL_ID_KEY, _DELEGATE_TASK_KEY, - _route_manager_to_worker_or_end, + _route_manager_to_worker_handoff_or_end, ) msg = AIMessage( @@ -141,7 +141,7 @@ def test_route_manager_to_worker_or_end_fans_out_every_delegation() -> None: {"name": "delegate_to_research_helper", "args": {"task": "y"}, "id": "c2"}, ], ) - sends = _route_manager_to_worker_or_end({"messages": [msg]}) + sends = _route_manager_to_worker_handoff_or_end({"messages": [msg]}) # Every delegation gets its own Send so each tool_call_id is answered. # The non-delegation tool call was already executed inside the manager's # react loop and is ignored by routing. @@ -1039,3 +1039,198 @@ def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: compiled = loader.load_component(mw) assert getattr(compiled.astream_events, "__name__", "") == "patched_astream_events" + + +# ─── ManagerWorkers as a Swarm member: handoff to a sibling ───────────────── + + +def test_handoff_tool_name_normalizes_like_node_names() -> None: + from pyagentspec.adapters.langgraph._langgraphconverter import _handoff_tool_name + + assert _handoff_tool_name("Specialist") == "transfer_to_specialist" + assert _handoff_tool_name("Math Helper v2") == "transfer_to_math_helper_v2" + # Empty / punctuation-only falls back to a stable identifier. + assert _handoff_tool_name("!!!") == "transfer_to_agent" + + +def test_route_manager_handoff_takes_precedence_over_delegation() -> None: + """A ``transfer_to_`` call routes to the handoff node, and wins + over any ``delegate_to_`` calls emitted in the same turn.""" + from langchain_core.messages import AIMessage + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _HANDOFF_NODE_KEY, + _route_manager_to_worker_handoff_or_end, + ) + + mixed = AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_research_helper", "args": {"task": "x"}, "id": "c1"}, + {"name": "transfer_to_specialist", "args": {}, "id": "c2"}, + ], + ) + assert _route_manager_to_worker_handoff_or_end({"messages": [mixed]}) == _HANDOFF_NODE_KEY + + +def test_make_handoff_forward_node_reemits_parent_command() -> None: + """The handoff node returns a ``Command(goto=, graph=PARENT)`` + that sets ``active_agent`` and forwards the transfer AIMessage ahead of a + ToolMessage answering *every* unanswered tool call on it.""" + from langchain_core.messages import AIMessage, ToolMessage + from langgraph.types import Command + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _make_handoff_forward_node, + ) + + node = _make_handoff_forward_node({"transfer_to_specialist": "Specialist"}) + transfer_ai = AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_helper", "args": {"task": "y"}, "id": "d1"}, + {"name": "transfer_to_specialist", "args": {}, "id": "h1"}, + ], + ) + out = node.invoke({"messages": [transfer_ai]}) + + assert isinstance(out, Command) + assert out.goto == "Specialist" + assert out.graph == Command.PARENT + assert out.update["active_agent"] == "Specialist" + + forwarded = out.update["messages"] + # Transfer AIMessage first, then a ToolMessage per unanswered call. + assert forwarded[0] is transfer_ai + tool_msgs = [m for m in forwarded if isinstance(m, ToolMessage)] + answered = {m.tool_call_id for m in tool_msgs} + assert answered == {"d1", "h1"} + transferred = next(m for m in tool_msgs if m.tool_call_id == "h1") + assert transferred.content == "Successfully transferred to Specialist" + + +def test_manager_workers_as_swarm_member_hands_off_to_sibling() -> None: + """End-to-end: a Swarm whose first member is a ManagerWorkers. The + manager's LLM emits ``transfer_to_``; the parent graph re-emits + the handoff to the Swarm, which routes to the sibling Agent and lets it + answer — proving a sub-agent-bearing agent can participate in a Swarm + (the case the LangGraph adapter used to reject).""" + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + from pyagentspec.swarm import Swarm + + manager_agent = Agent( + name="Coordinator", + description="Coordinates", + system_prompt="You coordinate.", + llm_config=_llm_cfg("manager_llm"), + ) + worker = Agent( + name="Research Helper", + description="Handles research", + system_prompt="You research.", + llm_config=_llm_cfg("worker_llm"), + ) + team = ManagerWorkers(name="Team", group_manager=manager_agent, workers=[worker]) + specialist = Agent( + name="Specialist", + description="Domain specialist", + system_prompt="You are the specialist.", + llm_config=_llm_cfg("specialist_llm"), + ) + swarm = Swarm( + name="Crew", + first_agent=team, + relationships=[(team, specialist), (specialist, team)], + ) + + # Manager turn 1: hand the conversation off to the Specialist sibling. + fake_manager = _fake_manager( + AIMessage( + content="", + tool_calls=[{"name": "transfer_to_specialist", "args": {}, "id": "call_h1"}], + ) + ) + fake_specialist = _fake_manager(AIMessage(content="Specialist handled it.")) + fake_worker = _fake_manager(AIMessage(content="(worker, unused)")) + + def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: + return { + "manager_llm": fake_manager, + "worker_llm": fake_worker, + "specialist_llm": fake_specialist, + }[llm_config.name] + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=_dispatch, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **kw: self_obj, + ): + compiled = loader.load_component(swarm) + + result = compiled.invoke( + {"messages": [HumanMessage(content="Please help.")]}, + {"configurable": {"thread_id": "crew-1"}}, + ) + messages = result["messages"] + + # The Specialist produced the final answer → the handoff actually routed. + assert isinstance(messages[-1], AIMessage) + assert "Specialist handled it." in messages[-1].content + + # The transfer is a well-formed AIMessage(tool_call) → ToolMessage pair. + transferred = [ + m + for m in messages + if isinstance(m, ToolMessage) + and m.content == "Successfully transferred to Specialist" + ] + assert transferred and transferred[0].tool_call_id == "call_h1" + + # Transcript validity: every ToolMessage answers a preceding AIMessage + # tool_call with a matching id (an orphan ToolMessage would 400 a real LLM). + open_call_ids: set = set() + for m in messages: + for tc in getattr(m, "tool_calls", None) or []: + open_call_ids.add(tc["id"]) + if isinstance(m, ToolMessage): + assert m.tool_call_id in open_call_ids, ( + f"orphan ToolMessage {m.tool_call_id} with no preceding tool_call" + ) + + +def test_swarm_rejects_unsupported_member_type() -> None: + """A Swarm member that is neither an Agent nor a ManagerWorkers (here a + nested Swarm) raises a clear NotImplementedError — there is no single + LLM to attach the handoff tools to.""" + import pytest + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.swarm import Swarm + + a1 = Agent(name="A1", description="a1", system_prompt="x", llm_config=_llm_cfg("l1")) + a2 = Agent(name="A2", description="a2", system_prompt="y", llm_config=_llm_cfg("l2")) + nested = Swarm(name="Nested", first_agent=a1, relationships=[(a1, a2)]) + outer = Swarm(name="Outer", first_agent=nested, relationships=[(nested, a1)]) + + with pytest.raises(NotImplementedError, match="Swarm"): + AgentSpecToLangGraphConverter().convert(outer, tool_registry={}) From aea75d584d8b082f44399fa4078f9ec9015d412b Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Tue, 30 Jun 2026 16:31:25 +0400 Subject: [PATCH 18/34] feat(adapters/langgraph): run a Swarm as a flow step Symmetric with the ManagerWorkers flow-step support. A Swarm is an AgenticComponent, not an Agent, so an AgentNode wrapping one previously raised TypeError in AgentNodeExecutor and exposed no input ports (a DataFlowEdge into the node could not resolve at load). - Swarm._get_inferred_inputs/_get_inferred_outputs expose the entry agent's (first_agent) ports, mirroring ManagerWorkers' group manager, so an AgentNode wrapping a Swarm declares inputs and data edges into it resolve at load. - AgentNodeExecutor now dispatches a Swarm flow step: it renders the node inputs into the entry agent's prompt (swapping the rendered agent into both first_agent and the relationship tuples), drops the satisfied input ports, compiles via _swarm_convert_to_langgraph and drives the graph over messages. Adds tests/adapters/langgraph/flows/test_swarm_node.py. --- .../adapters/langgraph/_node_execution.py | 66 +++++++++ pyagentspec/src/pyagentspec/swarm.py | 23 +++ .../langgraph/flows/test_swarm_node.py | 137 ++++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 pyagentspec/tests/adapters/langgraph/flows/test_swarm_node.py diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index 3842fde1..98914c51 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -52,6 +52,7 @@ from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers from pyagentspec.property import Property as AgentSpecProperty from pyagentspec.property import _empty_default as pyagentspec_empty_default +from pyagentspec.swarm import Swarm as AgentSpecSwarm from pyagentspec.tracing.events import NodeExecutionEnd as AgentSpecNodeExecutionEnd from pyagentspec.tracing.events import NodeExecutionStart as AgentSpecNodeExecutionStart from pyagentspec.tracing.events.exception import ExceptionRaised @@ -581,6 +582,65 @@ def _create_manager_graph_with_given_input_values( ) return self._agents_cache[cache_key] + def _create_swarm_graph_with_given_input_values( + self, inputs: Dict[str, Any] + ) -> CompiledStateGraph[Any, Any]: + """Compile the node's ``Swarm`` into a runnable graph for these inputs. + + Mirrors :meth:`_create_manager_graph_with_given_input_values`: a Swarm flow step + runs as a multi-agent graph over ``MessagesState`` and can't carry structured + inputs to its inner agents, so the node inputs are rendered into the entry + (``first_agent``) ``system_prompt`` (so flow ``{{placeholder}}`` inputs substitute) + and the compiled swarm graph is cached by the rendered prompt. The entry agent + appears both as ``first_agent`` and inside the relationship tuples the converter + builds the swarm from, so it is swapped in both (matched by id) — the converted + swarm then uses the rendered prompt and ``default_active_agent`` still resolves by + the unchanged name. The now-satisfied input ports are dropped so declared == + inferred and the downstream span re-validation passes. A non-Agent entry agent is + left untouched so the converter raises its own clear error. + """ + from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter + + swarm = self.node.agent + if not isinstance(swarm, AgentSpecSwarm): + raise TypeError("_create_swarm_graph_with_given_input_values requires a Swarm") + entry_agent = swarm.first_agent + if isinstance(entry_agent, AgentSpecAgent): + cache_key = render_template(entry_agent.system_prompt, inputs) + rendered_entry = entry_agent.model_copy( + update={"system_prompt": cache_key, "inputs": []} + ) + + def _swap(agent: Any) -> Any: + return rendered_entry if agent.id == entry_agent.id else agent + + rendered_swarm = swarm.model_copy( + update={ + "first_agent": rendered_entry, + "relationships": [ + (_swap(caller), _swap(recipient)) + for caller, recipient in swarm.relationships + ], + "inputs": [], + } + ) + else: + # The converter rejects a non-Agent swarm member; pass through unchanged. + cache_key = swarm.id + rendered_swarm = swarm + if cache_key not in self._agents_cache: + self._agents_cache[ + cache_key + ] = AgentSpecToLangGraphConverter()._swarm_convert_to_langgraph( + rendered_swarm, + tool_registry=self.tool_registry, + converted_components=self.converted_components, + checkpointer=self.checkpointer, + config=self.config, + middleware=self._middleware, + ) + return self._agents_cache[cache_key] + def _prepare_agent_and_inputs( self, inputs: Dict[str, Any], messages: Messages ) -> Tuple[CompiledStateGraph[Any, Any], Dict[str, Any]]: @@ -596,6 +656,12 @@ def _prepare_agent_and_inputs( # graph is driven by messages alone (not the agent's remaining_steps state). graph = self._create_manager_graph_with_given_input_values(inputs) return graph, {"messages": messages} + if isinstance(self.node.agent, AgentSpecSwarm): + # A Swarm flow step runs as a multi-agent graph over MessagesState, same as a + # ManagerWorkers: the node inputs were rendered into the entry agent's prompt, + # so the graph is driven by messages alone. + graph = self._create_swarm_graph_with_given_input_values(inputs) + return graph, {"messages": messages} agent = self._create_react_agent_with_given_input_values(inputs) inputs |= { "remaining_steps": 20, # Get the right number of steps left diff --git a/pyagentspec/src/pyagentspec/swarm.py b/pyagentspec/src/pyagentspec/swarm.py index 3b9c7f72..50ef9bb4 100644 --- a/pyagentspec/src/pyagentspec/swarm.py +++ b/pyagentspec/src/pyagentspec/swarm.py @@ -15,6 +15,7 @@ from pyagentspec.agenticcomponent import AgenticComponent from pyagentspec.component import SerializeAsEnum +from pyagentspec.property import Property from pyagentspec.validation_helpers import model_validator_with_error_accumulation from pyagentspec.versioning import AgentSpecVersionEnum @@ -127,6 +128,28 @@ class Swarm(AgenticComponent): default=AgentSpecVersionEnum.v25_4_2, init=False, exclude=True ) + def _get_inferred_inputs(self) -> List[Property]: + """A ``Swarm`` exposes the inputs of its entry agent (``first_agent``). + + Symmetric with :meth:`ManagerWorkers._get_inferred_inputs`. The ``first_agent`` + is the swarm's entry point — it interacts with the user before any handoff — so + the swarm component accepts exactly the inputs that agent accepts (e.g. the + ``{{placeholder}}`` inputs of an ``Agent`` entry's prompt). Without this, the base + default infers no inputs, so a flow ``AgentNode`` wrapping a swarm declares no + input ports and a ``DataFlowEdge`` into it fails to resolve at load. + """ + first_agent = getattr(self, "first_agent", None) + return list(getattr(first_agent, "inputs", None) or []) + + def _get_inferred_outputs(self) -> List[Property]: + """A ``Swarm`` exposes the outputs of its entry agent (``first_agent``). + + Symmetric with :meth:`_get_inferred_inputs`: the ``first_agent`` produces the + swarm's surfaced result, so the swarm exposes its outputs. + """ + first_agent = getattr(self, "first_agent", None) + return list(getattr(first_agent, "outputs", None) or []) + @model_validator(mode="before") def _raise_warning_if_handoff_is_bool(cls: Self, values: Any) -> Any: import warnings diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_swarm_node.py b/pyagentspec/tests/adapters/langgraph/flows/test_swarm_node.py new file mode 100644 index 00000000..992c9200 --- /dev/null +++ b/pyagentspec/tests/adapters/langgraph/flows/test_swarm_node.py @@ -0,0 +1,137 @@ +# Copyright © 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +"""A Swarm used as a flow step (AgentNode). + +Regression coverage for two coupled behaviours, symmetric with the ManagerWorkers +flow-step coverage in ``test_managerworkers_node.py``: + * ``Swarm._get_inferred_inputs`` exposes the entry agent's (``first_agent``) inputs, so a + flow ``AgentNode`` wrapping a swarm declares input ports and a ``DataFlowEdge`` into it + resolves at load (previously: "node does not have any input property..."). + * ``AgentNodeExecutor`` runs a Swarm node (previously: TypeError "can only be used with + AgentSpecAgent agents"), rendering the node inputs into the entry agent's prompt and + returning its result. +""" + +from pyagentspec.agent import Agent +from pyagentspec.property import StringProperty +from pyagentspec.swarm import Swarm + + +def test_swarm_infers_inputs_from_first_agent_prompt() -> None: + """A Swarm exposes the entry agent's prompt placeholders as inputs.""" + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + cfg = OpenAiCompatibleConfig(name="m", model_id="fake", url="null") + first = Agent( + name="first", + llm_config=cfg, + system_prompt="Translate the following to Arabic:\n\n{{joke}}\n\nMake {{count}} variants.", + ) + second = Agent(name="second", llm_config=cfg, system_prompt="You translate.") + swarm = Swarm(name="swarm", first_agent=first, relationships=[(first, second)]) + + assert sorted(p.title for p in (swarm.inputs or [])) == ["count", "joke"] + + +def test_swarm_runs_as_a_flow_step_with_data_edge_inputs() -> None: + """A Swarm flow step loads (data edge resolves) and executes offline. + + The model is stubbed (no real LLM, no handoff), so the entry agent produces a final + message and the swarm routes straight to END. Asserts the flow both loads — proving the + swarm node exposes the ``joke`` input the data edge targets — and runs, surfacing the + entry agent's answer as the node's single string output. + """ + from unittest.mock import patch + + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge + from pyagentspec.flows.flow import Flow + from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + # Final message has no tool_calls → the entry agent answers without handing off. + fake_llm = _FakeModel(responses=[AIMessage(content="لماذا...")]) + + cfg = OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null") + joke = StringProperty(title="joke") + translated = StringProperty(title="translated") + + first = Agent( + name="first", + llm_config=cfg, + system_prompt="Translate the following to Arabic:\n\n{{joke}}", + outputs=[translated], + ) + second = Agent(name="second", llm_config=cfg, system_prompt="You translate.") + swarm = Swarm(name="translator", first_agent=first, relationships=[(first, second)]) + # The swarm node exposes the entry agent's `joke` input, and its single `translated` + # output (inherited from the entry agent) for the leaf edge. + assert [p.title for p in (swarm.inputs or [])] == ["joke"] + + swarm_node = AgentNode(name="swarm_node", agent=swarm) + start_node = StartNode(name="start", inputs=[joke]) + end_node = EndNode(name="end", outputs=[translated]) + flow = Flow( + name="flow", + start_node=start_node, + nodes=[start_node, swarm_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=swarm_node), + ControlFlowEdge(name="node_to_end", from_node=swarm_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="joke_edge", + source_node=start_node, + source_output=joke.title, + destination_node=swarm_node, + destination_input=joke.title, + ), + DataFlowEdge( + name="translated_edge", + source_node=swarm_node, + source_output=translated.title, + destination_node=end_node, + destination_input=translated.title, + ), + ], + outputs=[translated], + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ): + compiled = loader.load_component(flow) + result = compiled.invoke( + { + "inputs": {"joke": "Why did the car..."}, + "messages": [{"role": "user", "content": ""}], + }, + {"configurable": {"thread_id": "swarm-node"}}, + ) + + assert "outputs" in result + assert result["outputs"]["translated"] == "لماذا..." From 2199de692cb240cad8bfad68fa87dfb9360549a4 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Fri, 3 Jul 2026 17:05:41 +0400 Subject: [PATCH 19/34] fix(adapters/langgraph): tolerate nested schema titles in MCP tool schemas MCP servers commonly derive tool schemas from OpenAPI documents whose nested schemas carry human-readable titles (e.g. Notion's "Rich Text"). The on-the-fly MCPTool built for the tracing callback fed those raw schemas into Property, whose title validation rejects any title with spaces or special characters, failing the whole agent run before the model was ever invoked. Strip title annotations from the arg schema before building the tracing Property, visiting exactly the positions the validator traverses (items, anyOf, additionalProperties, properties values) so non-schema payloads like default values are left intact. The LLM-facing args_schema is untouched. --- .../adapters/langgraph/_langgraphconverter.py | 38 +++++- .../tests/adapters/langgraph/mcp/test_mcp.py | 113 ++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 8cadf1a0..b338eec5 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -1830,7 +1830,9 @@ async def load_all_mcp_tools() -> List[BaseTool]: description=tool.description, client_transport=client_transport, inputs=[ - AgentSpecProperty(title=arg_name, json_schema=arg_json_schema) + AgentSpecProperty( + title=arg_name, json_schema=_strip_schema_titles(arg_json_schema) + ) for arg_name, arg_json_schema in tool.args.items() ], outputs=[AgentSpecStringProperty(title="tool_output")], @@ -2062,6 +2064,40 @@ def _normalize_title(d: Dict[str, Any]) -> Dict[str, Any]: return out +def _strip_schema_titles(json_schema: Dict[str, Any]) -> Dict[str, Any]: + """Return a copy of ``json_schema`` with ``title`` annotations removed. + + MCP servers commonly derive tool schemas from OpenAPI documents whose + nested schemas carry human-readable titles (e.g. ``"Rich Text"``). + ``Property`` validates every title it can reach as an identifier and + rejects the whole schema, killing the run. The on-the-fly ``MCPTool`` + built from these schemas only backs the tracing callback (the LLM-facing + ``args_schema`` is untouched), and its port name is passed explicitly as + ``title=``, so the schema's own titles are safe to drop. + + Only the positions ``Property``'s validator traverses are visited — + ``items``, ``anyOf``, ``additionalProperties`` and ``properties`` values. + A generic recursive strip would corrupt non-schema payloads such as + ``default``/``examples`` values containing a ``title`` key. + """ + out = {key: value for key, value in json_schema.items() if key != "title"} + if isinstance(out.get("items"), dict): + out["items"] = _strip_schema_titles(out["items"]) + if isinstance(out.get("anyOf"), list): + out["anyOf"] = [ + _strip_schema_titles(inner) if isinstance(inner, dict) else inner + for inner in out["anyOf"] + ] + if isinstance(out.get("additionalProperties"), dict): + out["additionalProperties"] = _strip_schema_titles(out["additionalProperties"]) + if isinstance(out.get("properties"), dict): + out["properties"] = { + name: _strip_schema_titles(inner) if isinstance(inner, dict) else inner + for name, inner in out["properties"].items() + } + return out + + def _confirm_tool_use(tool_name: str, **tool_arguments: Any) -> Tuple[bool, str]: # aligned with https://docs.langchain.com/oss/python/langchain/human-in-the-loop#responding-to-interrupts ALLOWED_DECISIONS = ["approve", "reject"] diff --git a/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py b/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py index 2abf1a0c..65ee12b7 100644 --- a/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py +++ b/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py @@ -423,3 +423,116 @@ async def test_flow_with_mcp_tool_with_interrupt(sse_client_transport): # Server fooza: a*2 + b*3 - 1 => 2*2 + 5*3 - 1 = 18 assert result["outputs"]["my_result"] == 18 + + +def test_strip_schema_titles_removes_only_schema_title_annotations(): + from pyagentspec.adapters.langgraph._langgraphconverter import _strip_schema_titles + + schema = { + "type": "array", + "title": "Children", + "default": [{"title": "kept payload"}], + "items": { + "anyOf": [ + { + "type": "object", + "title": "Paragraph Block", + "properties": { + "rich_text": { + "type": "array", + "title": "Rich Text", + "items": {"type": "object", "title": "Rich Text Item"}, + }, + "title": {"type": "string", "title": "Page Title"}, + }, + "additionalProperties": {"type": "string", "title": "Extra Value"}, + } + ] + }, + } + + stripped = _strip_schema_titles(schema) + + block = stripped["items"]["anyOf"][0] + assert "title" not in stripped + assert "title" not in block + assert "title" not in block["properties"]["rich_text"] + assert "title" not in block["properties"]["rich_text"]["items"] + assert "title" not in block["additionalProperties"] + # a *property named* "title" is data, not an annotation - only its schema is sanitized + assert block["properties"]["title"] == {"type": "string"} + # non-schema payloads such as default values are untouched + assert stripped["default"] == [{"title": "kept payload"}] + # the input schema is not mutated + assert schema["items"]["anyOf"][0]["title"] == "Paragraph Block" + + +def test_mcp_toolbox_tolerates_openapi_style_nested_schema_titles(monkeypatch): + """Nested titles with spaces (e.g. Notion's "Rich Text") must not kill the run. + + MCP servers commonly derive tool schemas from OpenAPI documents whose nested + schemas carry human-readable titles. The on-the-fly MCPTool built for the + tracing callback used to feed those raw schemas into Property, whose title + validation rejected them and failed the whole agent run. + """ + import langchain_mcp_adapters.tools as mcp_adapter_tools + from langchain_core.tools import StructuredTool + + from pyagentspec.adapters.langgraph.tracing import AgentSpecToolCallbackHandler + + args_schema = { + "type": "object", + "properties": { + "block_id": {"type": "string", "description": "Parent block id."}, + "children": { + "type": "array", + "description": "Array of block objects to append.", + "items": { + "anyOf": [ + { + "type": "object", + "title": "Paragraph Block", + "properties": { + "rich_text": {"type": "array", "title": "Rich Text"}, + }, + } + ] + }, + }, + }, + "required": ["block_id", "children"], + } + + async def run_tool(**kwargs: object) -> str: # pragma: no cover - never invoked + return "ok" + + notion_tool = StructuredTool( + name="API-patch-block-children", + description="Append new children blocks to a block.", + args_schema=args_schema, + coroutine=run_tool, + ) + + async def fake_load_mcp_tools(session, connection): + return [notion_tool] + + monkeypatch.setattr(mcp_adapter_tools, "load_mcp_tools", fake_load_mcp_tools) + + toolbox = MCPToolBox( + name="notion", + client_transport=SSETransport(name="notion server", url="https://example.com/sse"), + ) + + tools = AgentSpecToLangGraphConverter().convert(toolbox, tool_registry={}) + + assert tools == [notion_tool] + # The LLM-facing schema keeps the server's titles untouched... + assert notion_tool.args["children"]["items"]["anyOf"][0]["title"] == "Paragraph Block" + # ...while the tracing MCPTool is built from sanitized schemas + handler = next( + callback + for callback in notion_tool.callbacks + if isinstance(callback, AgentSpecToolCallbackHandler) + ) + children_input = next(inp for inp in handler.tool.inputs if inp.title == "children") + assert "Rich Text" not in str(children_input.json_schema) From 5bfe0af0903ce264eefd8b5c17a2be07e5aab0ee Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Sat, 4 Jul 2026 11:16:00 +0400 Subject: [PATCH 20/34] Revert "fix(adapters/langgraph): strip delegate tool calls from ManagerWorkers state snapshots" This reverts commit fd1eede6b0a8623e334acfd0de895050dc707be1. --- .../adapters/langgraph/_langgraphconverter.py | 46 ++--------- .../adapters/langgraph/test_managerworkers.py | 81 ------------------- 2 files changed, 8 insertions(+), 119 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index b338eec5..957b9237 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -2896,25 +2896,12 @@ def _scrub_payload_messages( payload: Any, delegate_call_ids: "set", ) -> Tuple[Any, bool]: - """For a node / state payload shaped ``{"messages": [...]}``, remove the - whole delegation protocol so it never surfaces in a consumer-facing - message snapshot: drop the worker's synthetic reply ToolMessage(s) AND - strip the synthetic ``delegate_to_`` tool calls off the manager's - AIMessage(s), dropping an AIMessage that is left empty (a pure delegation - turn). - - Stripping the tool calls — not just the ToolMessages — is what keeps a - downstream message snapshot consistent. A consumer that builds its - message history from an ``on_chain_end`` state payload (e.g. the AG-UI - MESSAGES_SNAPSHOT) reads ``tool_calls`` straight off the AIMessage; if we - dropped only the reply ToolMessages, the snapshot would carry delegate - tool calls whose results are gone, which renders as a "tool call with no - result". Messages are walked in order, so a delegation AIMessage records - its call ids before its reply ToolMessages are tested for removal. - - Returns ``(payload, drop_event)``: ``payload`` is a new dict when - anything changed (the original is never mutated), otherwise the object - passed in. ``drop_event`` is ``True`` when scrubbing empties the + """For a node payload shaped ``{"messages": [...]}``, drop the worker's + synthetic reply ToolMessage (matched to a now-hidden delegate call id). + + Returns ``(payload, drop_event)``: ``payload`` is a new dict when a + ToolMessage was removed (the original is never mutated), otherwise the + object passed in. ``drop_event`` is ``True`` when the removal empties the ``messages`` list, so the caller drops the whole event. """ if not isinstance(payload, dict): @@ -2922,25 +2909,8 @@ def _scrub_payload_messages( messages = payload.get("messages") if not isinstance(messages, list) or not messages: return payload, False - kept: List[Any] = [] - changed = False - for m in messages: - # The worker's reply ToolMessage — pure delegation plumbing. - if _is_delegate_tool_message(m, delegate_call_ids): - changed = True - continue - # An AIMessage may carry delegate tool calls; strip them and drop the - # message if nothing renderable remains. Non-delegation messages - # (real tool calls/results, plain content) are left untouched. - if hasattr(m, "tool_calls"): - scrubbed, is_empty = _scrubbed_ai_message(m, set(), delegate_call_ids) - if scrubbed is not None: - changed = True - if not is_empty: - kept.append(scrubbed) - continue - kept.append(m) - if not changed: + kept = [m for m in messages if not _is_delegate_tool_message(m, delegate_call_ids)] + if len(kept) == len(messages): return payload, False new_payload = dict(payload) new_payload["messages"] = kept diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index 55b32438..9e273869 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -790,87 +790,6 @@ def test_delegation_filter_drops_worker_synthetic_tool_message() -> None: assert kept["data"]["output"]["messages"] == [other] -def test_delegation_filter_strips_delegate_calls_from_state_snapshot() -> None: - """Regression: a node/state payload (``on_chain_end``) carrying the full - ``messages`` list must surface NEITHER the delegate tool calls NOR their - reply ToolMessages. - - A consumer builds its message snapshot from this payload and reads - ``tool_calls`` straight off the AIMessage. If the filter dropped only the - reply ToolMessages but left the delegate tool calls on the AIMessage, the - snapshot would show delegate tool calls with no results — rendered as a - "tool call with no result". Real (non-delegation) tool calls and their - results must be preserved. - """ - from langchain_core.messages import AIMessage, HumanMessage, ToolMessage - - from pyagentspec.adapters.langgraph._langgraphconverter import ( - _DelegationEventFilter, - ) - - f = _DelegationEventFilter() - # Manager's delegation turn streams first so the filter learns the ids. - delegate_ai = AIMessage( - content="", - tool_calls=[ - {"name": "delegate_to_sub_agent", "args": {"task": "ES"}, "id": "call_1"}, - {"name": "delegate_to_sub_agent", "args": {"task": "FR"}, "id": "call_2"}, - ], - ) - f.scrub( - {"event": "on_chat_model_end", "name": "x", "run_id": "r", - "data": {"output": delegate_ai}} - ) - - # The final-state snapshot carries the whole conversation, including a - # real tool call ("search") + its result that must survive. - snapshot = { - "messages": [ - HumanMessage(content="2 poems via sub-agents"), - delegate_ai, - ToolMessage(content="poem ES", tool_call_id="call_1"), - ToolMessage(content="poem FR", tool_call_id="call_2"), - AIMessage( - content="", - tool_calls=[{"name": "search", "args": {"q": "x"}, "id": "call_real"}], - ), - ToolMessage(content="search result", tool_call_id="call_real"), - AIMessage(content="Here are your poems."), - ] - } - out = f.scrub( - {"event": "on_chain_end", "name": "__manager__", "run_id": "r2", - "data": {"output": snapshot}} - ) - assert out is not None - msgs = out["data"]["output"]["messages"] - - # No delegate tool calls and no delegate ToolMessages remain. - delegate_calls = [ - tc for m in msgs if isinstance(m, AIMessage) - for tc in (m.tool_calls or []) if tc["name"].startswith("delegate_to_") - ] - assert delegate_calls == [] - tool_ids = [m.tool_call_id for m in msgs if type(m).__name__ == "ToolMessage"] - assert "call_1" not in tool_ids and "call_2" not in tool_ids - # The empty delegation AIMessage is dropped entirely. - assert delegate_ai not in msgs - - # The REAL tool call + its result are preserved and still paired. - real_calls = [ - tc["id"] for m in msgs if isinstance(m, AIMessage) - for tc in (m.tool_calls or []) - ] - assert real_calls == ["call_real"] - assert "call_real" in tool_ids - # The human turn and the manager's final answer survive. - assert any(type(m).__name__ == "HumanMessage" for m in msgs) - assert msgs[-1].content == "Here are your poems." - - # The original state objects are never mutated (graph state stays intact). - assert delegate_ai.tool_calls and len(delegate_ai.tool_calls) == 2 - - def test_delegation_filter_passes_through_real_content() -> None: from langchain_core.messages import AIMessageChunk From 899fb5a1ff33f3c2d77f11c9695b4163f49db1dc Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Sat, 4 Jul 2026 11:19:35 +0400 Subject: [PATCH 21/34] Revert "feat(adapters/langgraph): hide ManagerWorkers delegation protocol from astream_events" This reverts commit 38b4f07faca880486b664d05770723e4b35686e4. --- .../adapters/langgraph/_langgraphconverter.py | 286 ------------------ .../adapters/langgraph/test_managerworkers.py | 266 ---------------- 2 files changed, 552 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 957b9237..98eeef05 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -1393,10 +1393,6 @@ def _manager_workers_convert_to_langgraph( # surrounds each run. Mirrors the patches applied to Agent and # Flow graphs above. _patch_with_manager_workers_execution_span(compiled_graph, mw) - # Hide the delegate_to_ routing protocol from the - # astream_events view (tool calls, their tool lifecycle events, and - # the worker's synthetic reply ToolMessage) without touching state. - _patch_hide_delegation_in_astream_events(compiled_graph) return compiled_graph def _create_react_agent_with_given_info( @@ -2761,288 +2757,6 @@ async def _run_async(state: Dict[str, Any]) -> Dict[str, Any]: ) -# ─── ManagerWorkers: hide the delegation protocol from astream_events ───────── - - -def _is_delegate_name(name: Any) -> bool: - """True if ``name`` is one of the synthetic ``delegate_to_`` - tool names the manager emits to route to a worker.""" - return isinstance(name, str) and name.startswith(_DELEGATE_TOOL_PREFIX) - - -def _is_delegate_tool_message(msg: Any, delegate_call_ids: "set") -> bool: - """True if ``msg`` is the worker's synthetic reply ToolMessage — i.e. a - ToolMessage answering a (now-hidden) delegation tool-call id.""" - return ( - getattr(msg, "type", None) == "tool" - and getattr(msg, "tool_call_id", None) in delegate_call_ids - ) - - -def _scrubbed_ai_message( - msg: Any, - delegate_indices: "set", - delegate_call_ids: "set", -) -> Tuple[Optional[Any], bool]: - """Return ``(scrubbed_copy_or_None, is_empty)`` for an AIMessage(Chunk), - removing every ``delegate_to_`` tool call. - - ``scrubbed_copy_or_None`` is ``None`` when the message carried no - delegation artifact (the caller emits it unchanged). ``is_empty`` is - ``True`` when, after removal, nothing renderable remains (no content and - no other tool calls) — the caller drops the event. - - Never mutates ``msg``: the same object lives in the graph's message - state, where the manager react loop relies on the delegation - tool-call / tool-result pair staying intact. ``delegate_indices`` tracks - streamed tool-call positions so argument-continuation chunks (which - carry no ``name``) are stripped too; ``delegate_call_ids`` collects the - call ids so the worker's matching ToolMessage can be dropped later. - """ - changed = False - - # Provider-native streamed tool calls (e.g. OpenAI) ride along in - # ``additional_kwargs['tool_calls']`` and stream by index with the name - # only on the opening delta — match by name or by a known delegate index. - additional = getattr(msg, "additional_kwargs", None) or {} - new_additional = additional - raw_calls = additional.get("tool_calls") - if raw_calls: - kept_raw = [] - for tc in raw_calls: - index = tc.get("index") if isinstance(tc, dict) else None - function = (tc.get("function") or {}) if isinstance(tc, dict) else {} - fname = function.get("name") - if _is_delegate_name(fname) or (not fname and index in delegate_indices): - if index is not None: - delegate_indices.add(index) - if isinstance(tc, dict) and tc.get("id"): - delegate_call_ids.add(tc["id"]) - changed = True - else: - kept_raw.append(tc) - if len(kept_raw) != len(raw_calls): - new_additional = dict(additional) - if kept_raw: - new_additional["tool_calls"] = kept_raw - else: - new_additional.pop("tool_calls", None) - - # AIMessageChunk: ``tool_call_chunks`` is the source of truth and - # ``tool_calls`` / ``invalid_tool_calls`` are *derived* from it, so we - # rebuild the chunk (which re-runs that derivation) rather than copying — - # otherwise a stale derived ``tool_calls`` entry survives the strip. - if hasattr(msg, "tool_call_chunks"): - kept_chunks = [] - for chunk in getattr(msg, "tool_call_chunks", None) or []: - cname, cindex = chunk.get("name"), chunk.get("index") - if _is_delegate_name(cname) or (cname is None and cindex in delegate_indices): - if cindex is not None: - delegate_indices.add(cindex) - if chunk.get("id"): - delegate_call_ids.add(chunk["id"]) - changed = True - else: - kept_chunks.append(chunk) - if not changed: - return None, False - scrubbed = type(msg)( - content=msg.content, - additional_kwargs=new_additional, - response_metadata=getattr(msg, "response_metadata", None) or {}, - tool_call_chunks=kept_chunks, - id=getattr(msg, "id", None), - name=getattr(msg, "name", None), - usage_metadata=getattr(msg, "usage_metadata", None), - ) - has_remaining = ( - bool(scrubbed.content) - or bool(scrubbed.tool_call_chunks) - or bool((scrubbed.additional_kwargs or {}).get("tool_calls")) - ) - return scrubbed, not has_remaining - - # Full AIMessage: ``tool_calls`` is the source of truth. - update: Dict[str, Any] = {} - for attr in ("tool_calls", "invalid_tool_calls"): - items = getattr(msg, attr, None) - if items: - kept = [] - for tc in items: - if _is_delegate_name(_tc_get(tc, "name")): - cid = _tc_get(tc, "id") - if cid: - delegate_call_ids.add(cid) - changed = True - else: - kept.append(tc) - if len(kept) != len(items): - update[attr] = kept - if new_additional is not additional: - update["additional_kwargs"] = new_additional - if not changed: - return None, False - - scrubbed = msg.model_copy(update=update) - has_remaining = ( - bool(getattr(scrubbed, "content", None)) - or bool(getattr(scrubbed, "tool_calls", None)) - or bool((getattr(scrubbed, "additional_kwargs", None) or {}).get("tool_calls")) - ) - return scrubbed, not has_remaining - - -def _scrub_payload_messages( - payload: Any, - delegate_call_ids: "set", -) -> Tuple[Any, bool]: - """For a node payload shaped ``{"messages": [...]}``, drop the worker's - synthetic reply ToolMessage (matched to a now-hidden delegate call id). - - Returns ``(payload, drop_event)``: ``payload`` is a new dict when a - ToolMessage was removed (the original is never mutated), otherwise the - object passed in. ``drop_event`` is ``True`` when the removal empties the - ``messages`` list, so the caller drops the whole event. - """ - if not isinstance(payload, dict): - return payload, False - messages = payload.get("messages") - if not isinstance(messages, list) or not messages: - return payload, False - kept = [m for m in messages if not _is_delegate_tool_message(m, delegate_call_ids)] - if len(kept) == len(messages): - return payload, False - new_payload = dict(payload) - new_payload["messages"] = kept - return new_payload, len(kept) == 0 - - -class _DelegationEventFilter: - """Stateful scrubber for a single ``astream_events`` stream. - - Removes the synthetic ``delegate_to_`` routing protocol — the - delegation tool calls, their ``on_tool_*`` lifecycle events, and the - worker's matching reply ToolMessage — from the consumer-facing event - view. The graph's message state is never touched, so the manager react - loop still sees its well-formed tool-call / tool-result exchange. - """ - - def __init__(self) -> None: - # Streamed tool-call positions per chat-model run that belong to a - # delegation call, so argument-continuation chunks (name=None) are - # stripped along with the opening chunk. - self._delegate_indices_by_run: Dict[str, "set"] = {} - # Delegate tool-call ids seen so far, so the worker's reply - # ToolMessage can be dropped when it surfaces downstream. - self._delegate_call_ids: "set" = set() - - def scrub(self, event: Dict[str, Any]) -> Optional[Dict[str, Any]]: - etype = event.get("event") - name = event.get("name", "") - - # 1. Drop the tool lifecycle events for the delegation tools. - if ( - etype in ("on_tool_start", "on_tool_end", "on_tool_error") - and _is_delegate_name(name) - ): - return None - - data = event.get("data") or {} - - # 2. Strip delegate tool calls from streamed / final manager AIMessages. - if etype in ("on_chat_model_stream", "on_chat_model_end"): - key = "chunk" if etype == "on_chat_model_stream" else "output" - msg = data.get(key) - if msg is not None and hasattr(msg, "tool_calls"): - run_id = event.get("run_id", "") - indices = self._delegate_indices_by_run.setdefault(run_id, set()) - scrubbed, is_empty = _scrubbed_ai_message( - msg, indices, self._delegate_call_ids - ) - if scrubbed is not None: - # A streamed chunk that became empty is pure delegation - # plumbing — drop it. A final ``on_chat_model_end`` is kept - # (scrubbed) so consumers still get a turn-end marker. - if is_empty and etype == "on_chat_model_stream": - return None - new_data = dict(data) - new_data[key] = scrubbed - new_event = dict(event) - new_event["data"] = new_data - return new_event - return event - - # 3. Drop the worker's synthetic reply ToolMessage wherever it - # surfaces in a node payload. - new_data: Optional[Dict[str, Any]] = None - should_drop = False - for key in ("chunk", "output", "input"): - if key in data: - scrubbed_payload, drop_event = _scrub_payload_messages( - data[key], self._delegate_call_ids - ) - if scrubbed_payload is not data[key]: - if new_data is None: - new_data = dict(data) - new_data[key] = scrubbed_payload - if drop_event: - should_drop = True - if should_drop: - return None - if new_data is not None: - new_event = dict(event) - new_event["data"] = new_data - return new_event - return event - - -def _patch_hide_delegation_in_astream_events( - compiled_graph: CompiledStateGraph[Any, Any, Any], -) -> None: - """Wrap ``astream_events`` so the synthetic ``delegate_to_`` - routing protocol never reaches the consumer. - - ManagerWorkers routes by having the manager react-agent emit a - ``delegate_to_`` tool call, which the worker answers with a - ToolMessage matched to that call id. That pair is load-bearing for the - manager's react loop (it must observe a well-formed tool-call / - tool-result exchange) but it is internal plumbing the consumer should - never see as phantom tool calls. We filter only the emitted events; the - graph's message state is untouched, so the loop is unaffected. The - workers' real LLM/token events still propagate (they reach the consumer - via callback propagation through the isolated worker run), so this - strips the routing noise without hiding the workers' actual output. - """ - original_astream_events = compiled_graph.astream_events - - async def patched_astream_events( - *args: Any, **kwargs: Any - ) -> AsyncGenerator[Any, None]: - event_filter = _DelegationEventFilter() - async for event in original_astream_events(*args, **kwargs): - if not isinstance(event, dict): - yield event - continue - # Fail open: a scrubbing bug must never tear down the stream - # (which would swallow every later event — notably the worker - # events that follow the manager's delegation turn). On error we - # emit the event unfiltered rather than dropping the rest. - try: - kept = event_filter.scrub(event) - except Exception: # noqa: BLE001 — defensive, see above - logging.getLogger("pyagentspec.adapters.langgraph").warning( - "ManagerWorkers astream_events delegation filter raised; " - "passing the event through unfiltered.", - exc_info=True, - ) - yield event - continue - if kept is not None: - yield kept - - compiled_graph.astream_events = patched_astream_events # type: ignore[assignment] - - def _patch_with_manager_workers_execution_span( compiled_graph: CompiledStateGraph[Any, Any, Any], mw: AgentSpecManagerWorkers, diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index 9e273869..03361c49 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -622,190 +622,6 @@ def test_workers_with_name_slug_collision_are_rejected() -> None: loader.load_component(mw) -# ─── astream_events delegation scrubbing ───────────────────────────────────── -# -# The manager routes by emitting a ``delegate_to_`` tool call which -# the worker answers with a ToolMessage. That pair is internal plumbing; the -# consumer-facing ``astream_events`` view must not surface it as phantom tool -# calls. ``_DelegationEventFilter`` scrubs the event stream while leaving the -# graph's message state intact. - - -def test_delegation_filter_drops_delegate_tool_lifecycle_events() -> None: - from pyagentspec.adapters.langgraph._langgraphconverter import ( - _DelegationEventFilter, - ) - - f = _DelegationEventFilter() - for etype in ("on_tool_start", "on_tool_end", "on_tool_error"): - ev = { - "event": etype, - "name": "delegate_to_research_helper", - "run_id": "r", - "data": {}, - } - assert f.scrub(ev) is None - - # A real tool's lifecycle events pass through untouched. - real = {"event": "on_tool_start", "name": "search", "run_id": "r", "data": {}} - assert f.scrub(real) is real - - -def test_delegation_filter_strips_delegate_call_from_chat_model_end() -> None: - from langchain_core.messages import AIMessage - - from pyagentspec.adapters.langgraph._langgraphconverter import ( - _DelegationEventFilter, - ) - - f = _DelegationEventFilter() - msg = AIMessage( - content="", - tool_calls=[ - {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "call_1"}, - ], - ) - out = f.scrub( - {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": msg}} - ) - assert out is not None - assert out["data"]["output"].tool_calls == [] - # The original message object (which lives in graph state) is untouched. - assert msg.tool_calls and msg.tool_calls[0]["name"] == "delegate_to_research_helper" - - # A turn that mixes a delegation call with a real tool call keeps the real one. - mixed = AIMessage( - content="ok", - tool_calls=[ - {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "call_2"}, - {"name": "search", "args": {"q": "x"}, "id": "call_3"}, - ], - ) - out2 = f.scrub( - {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": mixed}} - ) - assert [tc["name"] for tc in out2["data"]["output"].tool_calls] == ["search"] - - -def test_delegation_filter_strips_streamed_delegate_tool_call_chunks() -> None: - from langchain_core.messages import AIMessageChunk - - from pyagentspec.adapters.langgraph._langgraphconverter import ( - _DelegationEventFilter, - ) - - f = _DelegationEventFilter() - # Opening chunk: names the delegation tool at index 0 → pure plumbing, dropped. - opening = AIMessageChunk( - content="", - tool_call_chunks=[ - { - "name": "delegate_to_research_helper", - "args": "", - "id": "call_1", - "index": 0, - "type": "tool_call_chunk", - } - ], - ) - assert ( - f.scrub( - {"event": "on_chat_model_stream", "name": "x", "run_id": "r", "data": {"chunk": opening}} - ) - is None - ) - - # Argument-continuation chunk: no name, same index 0 → also dropped. - cont = AIMessageChunk( - content="", - tool_call_chunks=[ - {"name": None, "args": '{"task":"hi"}', "id": None, "index": 0, "type": "tool_call_chunk"}, - ], - ) - assert ( - f.scrub( - {"event": "on_chat_model_stream", "name": "x", "run_id": "r", "data": {"chunk": cont}} - ) - is None - ) - - # A chunk mixing a delegation call with a real tool call keeps the real one. - mixed = AIMessageChunk( - content="", - tool_call_chunks=[ - {"name": "delegate_to_research_helper", "args": "", "id": "c4", "index": 0, "type": "tool_call_chunk"}, - {"name": "search", "args": "", "id": "c5", "index": 1, "type": "tool_call_chunk"}, - ], - ) - out = f.scrub( - {"event": "on_chat_model_stream", "name": "x", "run_id": "r2", "data": {"chunk": mixed}} - ) - assert out is not None - kept = out["data"]["chunk"].tool_call_chunks - assert [c["name"] for c in kept] == ["search"] - - -def test_delegation_filter_drops_worker_synthetic_tool_message() -> None: - from langchain_core.messages import AIMessage, ToolMessage - - from pyagentspec.adapters.langgraph._langgraphconverter import ( - _DelegationEventFilter, - ) - - f = _DelegationEventFilter() - # The manager's delegate turn first records the delegation call id. - delegate = AIMessage( - content="", - tool_calls=[ - {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "call_1"}, - ], - ) - f.scrub( - {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": delegate}} - ) - - # The worker node then emits its reply as a ToolMessage matched to call_1. - reply = ToolMessage(content="Saturn has rings.", tool_call_id="call_1") - out = f.scrub( - { - "event": "on_chain_end", - "name": "worker:research_helper", - "run_id": "w", - "data": {"output": {"messages": [reply]}}, - } - ) - assert out is None - - # A ToolMessage answering an unknown (real) tool call is preserved. - other = ToolMessage(content="x", tool_call_id="call_other") - kept = f.scrub( - { - "event": "on_chain_end", - "name": "node", - "run_id": "w2", - "data": {"output": {"messages": [other]}}, - } - ) - assert kept is not None - assert kept["data"]["output"]["messages"] == [other] - - -def test_delegation_filter_passes_through_real_content() -> None: - from langchain_core.messages import AIMessageChunk - - from pyagentspec.adapters.langgraph._langgraphconverter import ( - _DelegationEventFilter, - ) - - f = _DelegationEventFilter() - chunk = AIMessageChunk(content="Hello") - ev = {"event": "on_chat_model_stream", "name": "x", "run_id": "r", "data": {"chunk": chunk}} - out = f.scrub(ev) - # No delegation artifact → event passes through as the same object. - assert out is ev - assert out["data"]["chunk"].content == "Hello" - - def test_worker_events_stream_natively_namespaced_under_worker_node() -> None: """Regression: a worker's token events must stream under the worker node's checkpoint namespace so a consumer can attribute them to the @@ -878,88 +694,6 @@ async def _collect() -> Any: assert all(ns.startswith("research_helper:") for ns in namespaces), namespaces -def test_patched_astream_events_fails_open_on_filter_error() -> None: - """A bug in the delegation filter must never tear down the stream and - swallow later events (e.g. the worker events that follow the manager's - delegation turn). On a scrub error the event is passed through.""" - import asyncio - - from pyagentspec.adapters.langgraph._langgraphconverter import ( - _DelegationEventFilter, - _patch_hide_delegation_in_astream_events, - ) - - class _FakeGraph: - async def astream_events(self, *a: Any, **k: Any) -> Any: - yield {"event": "on_chat_model_stream", "run_id": "boom", "name": "x", "data": {}} - yield {"event": "on_chat_model_stream", "run_id": "ok", "name": "x", - "data": {"chunk": "worker-token"}} - - def _explode(self: Any, event: Any) -> Any: - if event.get("run_id") == "boom": - raise RuntimeError("kaboom") - return event - - graph = _FakeGraph() - _patch_hide_delegation_in_astream_events(graph) - - async def _collect() -> Any: - out = [] - with patch.object(_DelegationEventFilter, "scrub", new=_explode): - async for ev in graph.astream_events(): - out.append(ev) - return out - - events = asyncio.run(_collect()) - # Both events survive: the one that raised is passed through unfiltered, - # and the later (worker) event is still delivered. - assert [e["run_id"] for e in events] == ["boom", "ok"] - - -def test_manager_workers_patches_astream_events() -> None: - """The compiled ManagerWorkers graph has its ``astream_events`` wrapped - with the delegation scrubber.""" - from langchain_core.language_models.fake_chat_models import ( - FakeMessagesListChatModel, - ) - from langgraph.checkpoint.memory import MemorySaver - - from pyagentspec.adapters.langgraph import AgentSpecLoader - from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, - ) - from pyagentspec.agent import Agent - from pyagentspec.managerworkers import ManagerWorkers - - mw = ManagerWorkers( - name="Team", - group_manager=Agent( - name="Coordinator", description="c", system_prompt=".", llm_config=_llm_cfg("manager_llm"), - ), - workers=[ - Agent(name="Research Helper", description="r", system_prompt=".", llm_config=_llm_cfg("worker_llm")), - ], - ) - - def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: - return _fake_manager(*[]) - - loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) - with patch.object( - AgentSpecToLangGraphConverter, - "_llm_convert_to_langgraph", - autospec=True, - side_effect=_dispatch, - ), patch.object( - FakeMessagesListChatModel, - "bind_tools", - new=lambda self_obj, *a, **kw: self_obj, - ): - compiled = loader.load_component(mw) - - assert getattr(compiled.astream_events, "__name__", "") == "patched_astream_events" - - # ─── ManagerWorkers as a Swarm member: handoff to a sibling ───────────────── From 4010c413c41935f78e3f3afcf4839830da80f777 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Sat, 4 Jul 2026 17:50:21 +0400 Subject: [PATCH 22/34] fix(adapters/langgraph): answer delegation with error ToolMessage on worker failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ManagerWorkers worker runs as a subgraph node wrapped by _wrap_worker_for_subgraph, whose func/afunc built the answering ToolMessage only after worker_graph.(a)invoke returned. There was no try/except and no retry policy on the node, so a worker that raised propagated out of the whole parent run and left the manager's delegate_to_ tool-call unanswered. An orphan tool-call breaks the OpenAI/Anthropic contract and 400s the manager's next turn — notably on a checkpoint resume. Wrap the worker invocation so a failure is turned into an error ToolMessage matched to the pending delegation (Send fan-out payload or the manager's last AIMessage), letting the manager see a well-formed 'worker failed' result and react — mirroring how a tool raising inside the react-agent ToolNode surfaces as an error ToolMessage rather than crashing the graph. _extract_pending stays outside the try: with no delegation to answer, the original error still surfaces. --- .../adapters/langgraph/_langgraphconverter.py | 36 +++- .../adapters/langgraph/test_managerworkers.py | 156 ++++++++++++++++++ 2 files changed, 190 insertions(+), 2 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 98eeef05..155e947e 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -2740,14 +2740,46 @@ def _last_message_content(result: Any) -> str: return "" return getattr(messages[-1], "content", "") or "" + def _error_reply(call_id: str, exc: Exception) -> Dict[str, Any]: + # A worker crash must not abort the whole parent run and leave the + # manager's ``delegate_to_`` tool-call unanswered: an orphan + # tool-call breaks the OpenAI/Anthropic contract and 400s the manager's + # next turn (in particular on a checkpoint resume). Answer the pending + # delegation with an error ToolMessage instead, so the manager sees a + # well-formed "worker failed" tool response and can decide how to react + # — mirroring how a tool raising inside the react-agent ToolNode is + # surfaced as an error ToolMessage rather than crashing the graph. + logging.getLogger("pyagentspec.adapters.langgraph").exception( + "Worker '%s' failed; answering delegation %s with an error ToolMessage", + worker_node_name, + call_id, + ) + return { + "messages": [ + ToolMessage( + content=f"Worker '{worker_node_name}' failed: {exc}", + tool_call_id=call_id, + status="error", + ) + ] + } + def _run_sync(state: Dict[str, Any]) -> Dict[str, Any]: + # ``_extract_pending`` runs outside the try: if it raises there is no + # pending delegation to answer, so the error must surface unchanged. task, call_id = _extract_pending(state) - result = worker_graph.invoke(_worker_input(task)) + try: + result = worker_graph.invoke(_worker_input(task)) + except Exception as exc: # noqa: BLE001 — degrade any worker failure + return _error_reply(call_id, exc) return _tool_message_from(_last_message_content(result), call_id) async def _run_async(state: Dict[str, Any]) -> Dict[str, Any]: task, call_id = _extract_pending(state) - result = await worker_graph.ainvoke(_worker_input(task)) + try: + result = await worker_graph.ainvoke(_worker_input(task)) + except Exception as exc: # noqa: BLE001 — degrade any worker failure + return _error_reply(call_id, exc) return _tool_message_from(_last_message_content(result), call_id) return RunnableLambda( diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index 03361c49..86358f97 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -694,6 +694,162 @@ async def _collect() -> Any: assert all(ns.startswith("research_helper:") for ns in namespaces), namespaces +# ─── ManagerWorkers: a worker (subgraph) error must not orphan the delegation ─ + + +def _raising_worker_graph() -> Any: + """A compiled-graph stand-in whose invoke/ainvoke always raise, standing + in for a worker subgraph that crashes mid-run.""" + + class _Graph: + def invoke(self, _input: Any) -> Any: + raise RuntimeError("worker boom") + + async def ainvoke(self, _input: Any) -> Any: + raise RuntimeError("worker boom") + + return _Graph() + + +def test_worker_error_answers_pending_delegation_with_error_tool_message() -> None: + """A worker that raises answers the manager's pending + ``delegate_to_`` tool-call with an error ToolMessage (matched to + the tool_call_id carried on the Send fan-out payload) rather than letting + the exception propagate. Covers both the sync and async node entrypoints.""" + import asyncio + + from langchain_core.messages import ToolMessage + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _DELEGATE_CALL_ID_KEY, + _DELEGATE_TASK_KEY, + _wrap_worker_for_subgraph, + ) + + node = _wrap_worker_for_subgraph(_raising_worker_graph(), "researcher") + state = {_DELEGATE_TASK_KEY: "find X", _DELEGATE_CALL_ID_KEY: "c1"} + + result = asyncio.run(node.ainvoke(state)) + [msg] = result["messages"] + assert isinstance(msg, ToolMessage) + assert msg.tool_call_id == "c1" + assert msg.status == "error" + assert "researcher" in msg.content + assert "worker boom" in msg.content + + result_sync = node.invoke(state) + [msg_sync] = result_sync["messages"] + assert isinstance(msg_sync, ToolMessage) + assert msg_sync.tool_call_id == "c1" + assert msg_sync.status == "error" + + +def test_worker_error_recovers_call_id_from_manager_ai_message() -> None: + """Direct-edge path (no Send payload): the failing worker recovers the + pending tool_call_id from the manager's last AIMessage.""" + import asyncio + + from langchain_core.messages import AIMessage, ToolMessage + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _wrap_worker_for_subgraph, + ) + + ai = AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_researcher", "args": {"task": "find X"}, "id": "c9"} + ], + ) + node = _wrap_worker_for_subgraph(_raising_worker_graph(), "researcher") + + result = asyncio.run(node.ainvoke({"messages": [ai]})) + [msg] = result["messages"] + assert isinstance(msg, ToolMessage) + assert msg.tool_call_id == "c9" + assert msg.status == "error" + + +def test_worker_error_reraises_when_no_pending_delegation() -> None: + """With no delegation to answer (empty manager state), the original error + must surface rather than being silently swallowed — there is no tool-call + to keep well-formed.""" + import asyncio + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _wrap_worker_for_subgraph, + ) + + node = _wrap_worker_for_subgraph(_raising_worker_graph(), "researcher") + + with pytest.raises(RuntimeError, match="empty manager state"): + asyncio.run(node.ainvoke({"messages": []})) + + +def test_worker_error_lets_parent_run_complete_with_matched_tool_message() -> None: + """End-to-end: a worker subgraph that raises must NOT abort the parent + run. The worker node answers the manager's delegation with an error + ToolMessage, keeping the transcript well-formed (every tool_call answered), + so the manager can react instead of the exception killing the run.""" + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + from langgraph.graph import END, START, MessagesState, StateGraph + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _wrap_worker_for_subgraph, + ) + + wb = StateGraph(MessagesState) + + def _boom(state: Any) -> Any: + raise RuntimeError("worker boom") + + wb.add_node("agent", _boom) + wb.add_edge(START, "agent") + wb.add_edge("agent", END) + worker_graph = wb.compile() + + pb = StateGraph(MessagesState) + + def _manager(state: Any) -> Any: + return { + "messages": [ + AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_research_helper", "args": {"task": "Saturn"}, "id": "c1"} + ], + ) + ] + } + + pb.add_node("__manager__", _manager) + pb.add_node("research_helper", _wrap_worker_for_subgraph(worker_graph, "research_helper")) + pb.add_edge(START, "__manager__") + pb.add_edge("__manager__", "research_helper") + pb.add_edge("research_helper", END) + parent = pb.compile() + + # The run completes without raising ... + result = parent.invoke( + {"messages": [HumanMessage(content="hi")]}, + {"configurable": {"thread_id": "t"}}, + ) + messages = result["messages"] + + # ... the delegation is answered by exactly one error ToolMessage ... + tool_msgs = [m for m in messages if isinstance(m, ToolMessage)] + assert len(tool_msgs) == 1 + assert tool_msgs[0].tool_call_id == "c1" + assert tool_msgs[0].status == "error" + assert "worker boom" in tool_msgs[0].content + + # ... and no tool_call is left orphaned (an orphan would 400 a real LLM). + answered = {m.tool_call_id for m in tool_msgs} + for m in messages: + for tc in getattr(m, "tool_calls", None) or []: + assert tc["id"] in answered, f"orphan tool_call {tc['id']}" + + # ─── ManagerWorkers as a Swarm member: handoff to a sibling ───────────────── From c6477b1594f480d5693cbd8c839008802eafb5b3 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Sun, 5 Jul 2026 10:51:58 +0400 Subject: [PATCH 23/34] fix(adapters/langgraph): forward ManagerWorkers delegation task as SystemMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manager's delegated task was forwarded to each worker subgraph as a HumanMessage. Because the worker inherits the parent node's astream_events callbacks, that input message streams out to consumers and renders in the chat UI as a spurious end-user turn — the sub-agent's prompt shown as if the user typed it. Forward the task as a SystemMessage instead: it is the manager's internal instruction to the worker, not an end-user message. The worker is still driven by the task, worker state stays isolated (the top-level snapshot is unchanged), and the OpenAI-compatible models the runtime uses accept a system-only message list. Add a regression test asserting the worker receives its task as a SystemMessage with no HumanMessage in its input. --- .../adapters/langgraph/_langgraphconverter.py | 12 ++- .../adapters/langgraph/test_managerworkers.py | 98 +++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 155e947e..0c6b8ac5 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -2676,7 +2676,7 @@ def _wrap_worker_for_subgraph( (``afunc``) entrypoints — LangGraph picks the right one based on whether the parent graph is invoked via ``invoke`` or ``ainvoke``. """ - from langchain_core.messages import HumanMessage, ToolMessage + from langchain_core.messages import SystemMessage, ToolMessage from pyagentspec.adapters.langgraph._types import RunnableLambda @@ -2732,7 +2732,15 @@ def _worker_input(task: str) -> Dict[str, Any]: # LangGraph gives each ```` invocation a distinct # per-superstep ``checkpoint_ns``, so a worker called twice in a row # starts each run fresh rather than replaying its previous answer. - return {"messages": [HumanMessage(content=task)]} + # + # The task is forwarded as a SystemMessage, not a HumanMessage: it is + # the manager's internal instruction to the worker, not something the + # end user typed. Because the worker inherits this node's + # astream_events callbacks (above), its input message streams out to + # consumers — and a HumanMessage there surfaces in the chat UI as a + # spurious end-user turn. A SystemMessage still drives the worker while + # being rendered/attributed as an instruction rather than a user turn. + return {"messages": [SystemMessage(content=task)]} def _last_message_content(result: Any) -> str: messages = result.get("messages") if isinstance(result, dict) else None diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index 86358f97..f19379d6 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -397,6 +397,104 @@ def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: assert "Saturn has rings" in tool_msgs[0].content +def test_worker_receives_its_task_as_a_system_message() -> None: + """The manager's delegated task reaches the worker as a SystemMessage, + never a HumanMessage. + + The task is the manager's internal instruction to the worker, not an + end-user turn. Because the worker inherits this node's astream_events + callbacks, its input message streams out to consumers — a HumanMessage + there renders in the chat UI as a spurious user turn. This guards the + role choice in ``_wrap_worker_for_subgraph._worker_input``. + """ + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import AIMessage, HumanMessage, SystemMessage + from langchain_openai import ChatOpenAI + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + worker_inputs: list = [] + + class _CapturingModel(FakeMessagesListChatModel, ChatOpenAI): + """Records the messages passed to each worker model call.""" + + def _generate(self, messages: Any, *args: Any, **kwargs: Any) -> Any: + worker_inputs.append(list(messages)) + return super()._generate(messages, *args, **kwargs) + + manager_agent = Agent( + name="Coordinator", + description="Coordinates", + system_prompt="You coordinate.", + llm_config=_llm_cfg("manager_llm"), + ) + worker = Agent( + name="Research Helper", + description="Handles research", + system_prompt="You research.", + llm_config=_llm_cfg("worker_llm"), + ) + mw = ManagerWorkers(name="Team", group_manager=manager_agent, workers=[worker]) + + fake_manager = _fake_manager( + AIMessage( + content="", + tool_calls=[ + { + "name": "delegate_to_research_helper", + "args": {"task": "Look up Saturn"}, + "id": "call_1", + } + ], + ), + AIMessage(content="Done."), + ) + fake_worker = _CapturingModel(responses=[AIMessage(content="Saturn has rings.")]) + + def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: + if llm_config.name == "manager_llm": + return fake_manager + if llm_config.name == "worker_llm": + return fake_worker + raise AssertionError(f"unexpected llm_config: {llm_config.name}") + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=_dispatch, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **kw: self_obj, + ): + compiled = loader.load_component(mw) + + compiled.invoke( + {"messages": [HumanMessage(content="Tell me about Saturn.")]}, + {"configurable": {"thread_id": "mw-sysmsg-1"}}, + ) + + assert worker_inputs, "worker model was never invoked" + first_call = worker_inputs[0] + # The delegated task reached the worker as a SystemMessage... + assert any( + isinstance(m, SystemMessage) and "Look up Saturn" in (m.content or "") + for m in first_call + ), [type(m).__name__ for m in first_call] + # ...and no HumanMessage leaked into the worker's input. + assert not any(isinstance(m, HumanMessage) for m in first_call) + + def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: """Regression: when the manager emits SEVERAL ``delegate_to_`` tool calls in one turn (e.g. "spin up 5 sub-agents"), every delegation From 8db8e6308b23eddb0e9b3caee73c607e4242812c Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Mon, 6 Jul 2026 01:56:06 +0400 Subject: [PATCH 24/34] fix(adapters/langgraph): mark ManagerWorkers delegation task instead of sending it as a SystemMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts forwarding the delegated task as a SystemMessage. A system-only worker conversation gives the model no user turn to answer; strict OpenAI-compatible providers return an empty completion and langchain-core then raises "No generations found in stream", failing every delegation. The task is a HumanMessage again (a user turn the model can answer). To stop it rendering as a spurious end-user turn where the worker's inherited astream_events callbacks surface it, stamp additional_kwargs pyagentspec_kind=delegation_task so consumers can recognise it as internal delegation plumbing and drop/relabel it — non-destructively, without stripping anything from the stream (which breaks tool-call/result pairing and attribution, as the earlier reverts showed). --- .../adapters/langgraph/_langgraphconverter.py | 50 +++++++++++++---- .../adapters/langgraph/test_managerworkers.py | 54 ++++++++++++++----- 2 files changed, 81 insertions(+), 23 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 0c6b8ac5..7e9896e4 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -2324,6 +2324,20 @@ def _ensure_checkpointer_and_valid_tool_config( _DELEGATE_TASK_KEY = "__delegate_task__" _DELEGATE_CALL_ID_KEY = "__delegate_tool_call_id__" +# The manager's delegated task is forwarded to the worker as a HumanMessage so +# the model has a user turn to answer (a system-only turn yields an empty +# completion — see ``_worker_input``). Because the worker inherits the node's +# astream_events callbacks, that HumanMessage streams out to consumers, where it +# would otherwise render as a spurious end-user turn. We stamp this marker in the +# message's ``additional_kwargs`` so consumers can identify it as internal +# delegation plumbing (not an end-user turn) and drop/relabel it — without +# stripping anything from the stream (which breaks tool-call/result pairing and +# attribution). ``additional_kwargs`` is metadata: it survives serialization +# into the streamed ``on_chain_end`` message state and is not sent to the model +# provider for user-role messages. +_DELEGATION_TASK_MARKER_KEY = "pyagentspec_kind" +_DELEGATION_TASK_MARKER_VALUE = "delegation_task" + # Collapses any run of whitespace to a single space so multi-line worker # descriptions stay on one roster line. _WHITESPACE_RE = re.compile(r"\s+") @@ -2676,7 +2690,7 @@ def _wrap_worker_for_subgraph( (``afunc``) entrypoints — LangGraph picks the right one based on whether the parent graph is invoked via ``invoke`` or ``ainvoke``. """ - from langchain_core.messages import SystemMessage, ToolMessage + from langchain_core.messages import HumanMessage, ToolMessage from pyagentspec.adapters.langgraph._types import RunnableLambda @@ -2733,14 +2747,32 @@ def _worker_input(task: str) -> Dict[str, Any]: # per-superstep ``checkpoint_ns``, so a worker called twice in a row # starts each run fresh rather than replaying its previous answer. # - # The task is forwarded as a SystemMessage, not a HumanMessage: it is - # the manager's internal instruction to the worker, not something the - # end user typed. Because the worker inherits this node's - # astream_events callbacks (above), its input message streams out to - # consumers — and a HumanMessage there surfaces in the chat UI as a - # spurious end-user turn. A SystemMessage still drives the worker while - # being rendered/attributed as an instruction rather than a user turn. - return {"messages": [SystemMessage(content=task)]} + # The task is forwarded as a HumanMessage: a chat model generates the + # next assistant turn in response to a user (or tool) turn, so the + # worker needs a user-role message to answer. A SystemMessage-only + # conversation gives the model nothing to respond to — strict + # OpenAI-compatible providers return an empty completion and + # langchain-core then raises "No generations found in stream", failing + # the whole delegation. + # + # This message *does* stream out to consumers (the worker inherits this + # node's astream_events callbacks — see above) and would otherwise + # surface in the chat UI as a spurious end-user turn. Rather than starve + # the model of the user turn it needs, we stamp a marker in + # ``additional_kwargs`` so the consumer can recognise it as internal + # delegation plumbing and drop/relabel it. The marker is non-destructive + # (nothing is stripped from the stream) and metadata-only (not sent to + # the provider for user-role messages). + return { + "messages": [ + HumanMessage( + content=task, + additional_kwargs={ + _DELEGATION_TASK_MARKER_KEY: _DELEGATION_TASK_MARKER_VALUE + }, + ) + ] + } def _last_message_content(result: Any) -> str: messages = result.get("messages") if isinstance(result, dict) else None diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index f19379d6..c2c8e2ad 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -397,15 +397,21 @@ def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: assert "Saturn has rings" in tool_msgs[0].content -def test_worker_receives_its_task_as_a_system_message() -> None: - """The manager's delegated task reaches the worker as a SystemMessage, - never a HumanMessage. - - The task is the manager's internal instruction to the worker, not an - end-user turn. Because the worker inherits this node's astream_events - callbacks, its input message streams out to consumers — a HumanMessage - there renders in the chat UI as a spurious user turn. This guards the +def test_worker_receives_its_task_as_a_human_message() -> None: + """The manager's delegated task reaches the worker as a HumanMessage. + + A chat model generates the next assistant turn in response to a user (or + tool) turn, so the worker needs a user-role message to answer. Delivering + the task as a SystemMessage leaves the worker with a system-only + conversation and nothing to respond to — strict OpenAI-compatible + providers return an empty completion and langchain-core then raises + "No generations found in stream", failing the delegation. This guards the role choice in ``_wrap_worker_for_subgraph._worker_input``. + + (The worker's input message streaming out and rendering as a spurious + end-user turn is a consumer-side rendering concern, handled downstream by + dropping user-role messages from non-root subgraph namespaces — not by + starving the model of the user turn it needs.) """ from langchain_core.language_models.fake_chat_models import ( FakeMessagesListChatModel, @@ -416,6 +422,8 @@ def test_worker_receives_its_task_as_a_system_message() -> None: from pyagentspec.adapters.langgraph import AgentSpecLoader from pyagentspec.adapters.langgraph._langgraphconverter import ( + _DELEGATION_TASK_MARKER_KEY, + _DELEGATION_TASK_MARKER_VALUE, AgentSpecToLangGraphConverter, ) from pyagentspec.agent import Agent @@ -481,18 +489,36 @@ def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: compiled.invoke( {"messages": [HumanMessage(content="Tell me about Saturn.")]}, - {"configurable": {"thread_id": "mw-sysmsg-1"}}, + {"configurable": {"thread_id": "mw-humanmsg-1"}}, ) assert worker_inputs, "worker model was never invoked" first_call = worker_inputs[0] - # The delegated task reached the worker as a SystemMessage... - assert any( + # The delegated task reached the worker as a HumanMessage (a user turn the + # model can answer)... + task_msg = next( + ( + m + for m in first_call + if isinstance(m, HumanMessage) and "Look up Saturn" in (m.content or "") + ), + None, + ) + assert task_msg is not None, [type(m).__name__ for m in first_call] + # ...carrying the delegation marker so consumers can tell it apart from a + # real end-user turn (and drop/relabel it) instead of rendering it as one. + assert ( + task_msg.additional_kwargs.get(_DELEGATION_TASK_MARKER_KEY) + == _DELEGATION_TASK_MARKER_VALUE + ) + # ...and the task was NOT smuggled in as a SystemMessage (which would leave + # the model with no user turn to respond to). The worker's own system + # prompt is still a SystemMessage, so assert on the task content, not the + # mere presence of a SystemMessage. + assert not any( isinstance(m, SystemMessage) and "Look up Saturn" in (m.content or "") for m in first_call - ), [type(m).__name__ for m in first_call] - # ...and no HumanMessage leaked into the worker's input. - assert not any(isinstance(m, HumanMessage) for m in first_call) + ) def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: From a5c335838955de45e670be877c3cc273a9eb16fe Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Wed, 15 Jul 2026 16:01:52 +0400 Subject: [PATCH 25/34] fix(langgraph): propagate contextvars into run_async_in_sync worker thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When called from a running event loop (AsyncContext.ASYNC), run_async_in_sync runs the coroutine on a fresh worker thread with its own event loop. A new thread starts with an EMPTY contextvars context, so request-scoped state the caller set — tenant/user identity, the OTEL trace context — was silently lost inside async_function. In particular, an MCP client loaded synchronously from an async request handler read empty ContextVars and dropped the per-request headers derived from them (e.g. the tenant header the downstream Connectors proxy authenticates with), producing a 401. Copy the caller's context and run the worker-thread body inside it via ctx.run(...), so contextvars propagate across the thread boundary. Add a regression test that fails without the copy (empty value) and passes with it. --- .../adapters/langgraph/mcp_utils.py | 12 +++- .../langgraph/test_run_async_in_sync.py | 59 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 pyagentspec/tests/adapters/langgraph/test_run_async_in_sync.py diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py b/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py index 9457e3df..6f911bea 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py @@ -4,6 +4,7 @@ # (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. +import contextvars import ssl import warnings from concurrent.futures import ThreadPoolExecutor @@ -163,10 +164,19 @@ def run_async_in_sync( # workaround: anyio does not have any API run asynchronous code in a # synchronous method that was not started with anyio.to_thread # instead, we spawn a thread to execute it in a completely new event loop + # + # A fresh thread starts with an EMPTY contextvars context, so any + # request-scoped state the caller set — tenant/user identity, the + # OTEL trace context — would be lost inside async_function. Notably an + # MCP client loaded here would then read empty ContextVars and drop + # the per-request headers derived from them. Copy the caller's context + # and run the thread body inside it so that state propagates. + ctx = contextvars.copy_context() + def thread_target() -> T: return anyio.run(async_function, *args) - future = ThreadPoolExecutor(max_workers=1).submit(thread_target) + future = ThreadPoolExecutor(max_workers=1).submit(ctx.run, thread_target) return future.result() case unsupported_context: raise NotImplementedError(f"Unsupported async context: {unsupported_context}") diff --git a/pyagentspec/tests/adapters/langgraph/test_run_async_in_sync.py b/pyagentspec/tests/adapters/langgraph/test_run_async_in_sync.py new file mode 100644 index 00000000..4d9a975a --- /dev/null +++ b/pyagentspec/tests/adapters/langgraph/test_run_async_in_sync.py @@ -0,0 +1,59 @@ +# Copyright © 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +"""``run_async_in_sync`` must carry the caller's contextvars into the worker +thread it spawns for the async→sync-from-async case. + +When ``run_async_in_sync`` is called from a running event loop +(``AsyncContext.ASYNC``) it runs the coroutine on a brand-new worker thread with +its own event loop. A fresh thread starts with an EMPTY contextvars context, so +without copying the caller's context, request-scoped state the coroutine reads — +tenant/user identity, the OTEL trace context — is silently lost. This is exactly +what dropped the per-request headers of an MCP client loaded synchronously from +an async request handler. +""" + +import contextvars + +import pytest + +from pyagentspec.adapters.langgraph.mcp_utils import ( + AsyncContext, + get_execution_context, + run_async_in_sync, +) + +_probe: contextvars.ContextVar[str] = contextvars.ContextVar("probe", default="") + + +@pytest.mark.anyio +async def test_run_async_in_sync_propagates_contextvars_across_worker_thread() -> None: + # Being inside the event loop, this is the ASYNC case — the one that spawns + # the worker thread; the propagation gap only exists there. + assert get_execution_context() is AsyncContext.ASYNC + + async def read_probe() -> str: + return _probe.get() + + token = _probe.set("azaaza") + try: + assert run_async_in_sync(read_probe) == "azaaza" + finally: + _probe.reset(token) + + +def test_run_async_in_sync_runs_in_plain_sync_context() -> None: + # Sanity: the synchronous case (no loop) already shares the caller's context, + # so this passed before the fix too — it guards against a regression that + # would break the common path. + async def read_probe() -> str: + return _probe.get() + + token = _probe.set("sync-tenant") + try: + assert run_async_in_sync(read_probe) == "sync-tenant" + finally: + _probe.reset(token) From 86d16c0e8d50cf8b9c45c9d86bf2bf93380646d1 Mon Sep 17 00:00:00 2001 From: Salah Pichen Date: Wed, 15 Jul 2026 16:13:29 +0400 Subject: [PATCH 26/34] fix(langgraph): clear inherited async-library marker in the worker thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit copy_context() (added so the case-3 worker thread inherits the caller's contextvars) also copies sniffio's current_async_library marker. That fresh thread has no running loop, so anyio.run() would then refuse with "Already running in this thread". Clear the marker in the worker before anyio.run() — anyio sets its own for the loop it starts — while keeping the app contextvars (tenant/user identity, OTEL trace context) intact. Add a regression test that sets the marker (the anyio-managed-caller case) and would otherwise raise. --- .../adapters/langgraph/mcp_utils.py | 11 +++++++++- .../langgraph/test_run_async_in_sync.py | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py b/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py index 6f911bea..a80ede02 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py @@ -13,7 +13,11 @@ import anyio from anyio import from_thread -from sniffio import AsyncLibraryNotFoundError, current_async_library +from sniffio import ( + AsyncLibraryNotFoundError, + current_async_library, + current_async_library_cvar, +) from pyagentspec._lazy_loader import LazyLoader @@ -174,6 +178,11 @@ def run_async_in_sync( ctx = contextvars.copy_context() def thread_target() -> T: + # The copied context may carry the caller's async-library marker + # (sniffio); this new thread has no running loop, so clear it or + # anyio.run would refuse with "Already running in this + # thread". anyio.run sets its own marker for the loop it starts. + current_async_library_cvar.set(None) return anyio.run(async_function, *args) future = ThreadPoolExecutor(max_workers=1).submit(ctx.run, thread_target) diff --git a/pyagentspec/tests/adapters/langgraph/test_run_async_in_sync.py b/pyagentspec/tests/adapters/langgraph/test_run_async_in_sync.py index 4d9a975a..882cd9fe 100644 --- a/pyagentspec/tests/adapters/langgraph/test_run_async_in_sync.py +++ b/pyagentspec/tests/adapters/langgraph/test_run_async_in_sync.py @@ -45,6 +45,26 @@ async def read_probe() -> str: _probe.reset(token) +@pytest.mark.anyio +async def test_run_async_in_sync_when_async_library_marker_is_set() -> None: + # Simulate an anyio-managed caller: the sniffio async-library marker is set, + # so copy_context() carries it into the worker thread. A naive anyio.run() + # there raises "Already running in this thread"; the fix must clear the + # inherited marker AND still propagate the caller's contextvars. + from sniffio import current_async_library_cvar + + async def read_probe() -> str: + return _probe.get() + + marker = current_async_library_cvar.set("asyncio") + probe = _probe.set("azaaza") + try: + assert run_async_in_sync(read_probe) == "azaaza" + finally: + _probe.reset(probe) + current_async_library_cvar.reset(marker) + + def test_run_async_in_sync_runs_in_plain_sync_context() -> None: # Sanity: the synchronous case (no loop) already shares the caller's context, # so this passed before the fix too — it guards against a regression that From b5931a123715366795b4602370c793d748555b8b Mon Sep 17 00:00:00 2001 From: "GitLab CI (deployments)" Date: Thu, 6 Aug 2026 18:35:48 +0400 Subject: [PATCH 27/34] fix(langgraph): agent node output no longer shadowed by same-named input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node inputs are seeded into the agent's invoke state, so the invoke result carries each input back under its port title. When an input port shares its title with an output port — e.g. an {{output}} prompt placeholder wired from an upstream agent's default 'output' port, the exact shape the data-edge auto-weaving produces — extract_outputs_from_invoke_result preferred that echoed input over the agent's final message, and the flow silently returned the upstream agent's text while this agent's actual reply was discarded. Drop result entries that still hold the exact seeded input value before extraction, so the single-string fallback (or structured response) supplies the node output; a value the graph genuinely rewrote is kept. --- .../adapters/langgraph/_node_execution.py | 23 ++++- .../langgraph/flows/test_agentnode.py | 96 +++++++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index 98914c51..4bcb1260 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -670,7 +670,9 @@ def _prepare_agent_and_inputs( } return agent, inputs - def _format_agent_result(self, result: Dict[str, Any]) -> ExecuteOutput: + def _format_agent_result( + self, result: Dict[str, Any], node_inputs: Dict[str, Any] + ) -> ExecuteOutput: if not self.node.outputs: generated_message = result["messages"][-1] generated_messages: List[MessageLike] = [ @@ -678,18 +680,33 @@ def _format_agent_result(self, result: Dict[str, Any]) -> ExecuteOutput: ] return {}, NodeExecutionDetails(generated_messages=generated_messages) + # Node inputs are seeded into the invoke state (_prepare_agent_and_inputs), + # so `result` still carries each input under its port title. When an input + # port shares its title with an output port (e.g. an `{{output}}` prompt + # placeholder wired from an upstream agent's default `output` port), that + # echoed input would shadow the agent's generated reply in + # extract_outputs_from_invoke_result. Drop result entries that still hold + # the exact seeded value so extraction falls through to the structured + # response or the final message; a value the graph rewrote is kept. + result = { + key: value + for key, value in result.items() + if not (key in node_inputs and value == node_inputs[key]) + } outputs = extract_outputs_from_invoke_result(result, self.node.outputs or []) return outputs, NodeExecutionDetails() def _execute(self, inputs: Dict[str, Any], messages: Messages) -> ExecuteOutput: + node_inputs = dict(inputs) agent, prepared_inputs = self._prepare_agent_and_inputs(inputs, messages) result = agent.invoke(prepared_inputs, self.config) - return self._format_agent_result(result) + return self._format_agent_result(result, node_inputs) async def _aexecute(self, inputs: Dict[str, Any], messages: Messages) -> ExecuteOutput: + node_inputs = dict(inputs) agent, prepared_inputs = self._prepare_agent_and_inputs(inputs, messages) result = await agent.ainvoke(prepared_inputs, self.config) - return self._format_agent_result(result) + return self._format_agent_result(result, node_inputs) class InputMessageNodeExecutor(NodeExecutor): diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py b/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py index 8cc4f5fb..06f28143 100644 --- a/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py +++ b/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py @@ -190,6 +190,102 @@ class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): assert result["outputs"]["answer"] == "42" +def test_output_not_shadowed_by_same_named_input() -> None: + """An input port named like the single string output port must not leak + through as the node's output — the agent's generated reply wins. + + Regression: node inputs are seeded into the invoke state + (``_prepare_agent_and_inputs``), so the result still carries each input + under its port title. With an input wired as ``{{output}}`` — the name a + data edge from an upstream agent's default ``output`` port requires — + ``extract_outputs_from_invoke_result`` preferred that echoed input over the + final message, and the flow returned the upstream agent's text verbatim + while this agent's actual reply was discarded. + """ + from unittest.mock import patch + + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + english_joke = "Why don't programmers like nature? It has too many bugs." + french_joke = "Pourquoi les programmeurs n'aiment pas la nature ? Trop de bugs." + fake_llm = _FakeModel(responses=[AIMessage(content=french_joke)]) + + output_in = StringProperty(title="output") + output_out = StringProperty(title="output") + agent = Agent( + name="translator", + llm_config=OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null"), + system_prompt="Translate the following joke into French:\n\n{{output}}", + inputs=[output_in], + outputs=[output_out], + ) + agent_node = AgentNode(name="agent_node", agent=agent) + start_node = StartNode(name="start", inputs=[output_in]) + end_node = EndNode(name="end", outputs=[output_out]) + flow = Flow( + name="flow", + start_node=start_node, + nodes=[start_node, agent_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=agent_node), + ControlFlowEdge(name="node_to_end", from_node=agent_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="input_edge", + source_node=start_node, + source_output="output", + destination_node=agent_node, + destination_input="output", + ), + DataFlowEdge( + name="output_edge", + source_node=agent_node, + source_output="output", + destination_node=end_node, + destination_input="output", + ), + ], + outputs=[output_out], + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ): + compiled = loader.load_component(flow) + result = compiled.invoke( + { + "inputs": {"output": english_joke}, + "messages": [{"role": "user", "content": "tell me a joke"}], + }, + {"configurable": {"thread_id": "agentnode-shadowed-output"}}, + ) + + assert result["outputs"]["output"] == french_joke + + @pytest.mark.anyio @retry_test(max_attempts=3, wait_between_tries=2) async def test_agentnode_can_be_executed_async(agent_flow: Flow) -> None: From e44ea010678f21f6005ea28d371b428f3d8384b6 Mon Sep 17 00:00:00 2001 From: abdelmoumen_mezhoud Date: Mon, 10 Aug 2026 17:33:58 +0400 Subject: [PATCH 28/34] fix(langgraph): declare structured_response on multi-agent parent graphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An AgentNode whose agent has sub-agents compiles as a ManagerWorkers (or Swarm) graph. The group manager — and any swarm member with declared outputs — is a react agent built with `response_format`, so it writes its answer to the `structured_response` channel of its own subgraph state. Both parent graphs were built over plain `MessagesState` / `SwarmState`, and LangGraph drops a subgraph's updates to channels the parent does not declare, so the answer died one step after being produced. The node's declared outputs then arrived unresolved and `_cast_values_and_add_defaults` raised, at the producing node: ValueError: Expected node `` to have a value for property `` A single string output hid this behind the free-text fallback; two or more outputs failed every run, whatever the model replied. Both parent schemas now declare the channel, which is the whole fix — the value arrives exactly as a plain agent's does and the existing `structured_response` path in `extract_outputs_from_invoke_result` picks it up. Only the manager writes it: `_wrap_worker_for_subgraph` returns `messages` alone, and in a swarm only members with declared outputs are given a `response_format`. Verified end to end for both graph types by the two tests added here, which fail with the ValueError above without the change. Confirmed against a live model whose provider satisfies `response_format` with native JSON rather than a tool call, since `create_agent` normalizes both strategies into the same channel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../adapters/langgraph/_langgraphconverter.py | 20 ++- .../flows/test_managerworkers_node.py | 121 ++++++++++++++++++ .../langgraph/flows/test_swarm_node.py | 117 +++++++++++++++++ 3 files changed, 257 insertions(+), 1 deletion(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 7e9896e4..b38009c5 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -1200,9 +1200,18 @@ def _swarm_convert_to_langgraph( ], ) ) + + # Same reason as the ManagerWorkers parent graph: a member built with + # `response_format` writes `structured_response` in its own subgraph state, and + # the swarm's state schema has to declare the channel for that update to survive. + # Only members with declared outputs get a `response_format`, so only they write it. + class _SwarmState(langgraph_swarm.SwarmState, total=False): # type: ignore[misc] + structured_response: Dict[str, Any] + return langgraph_swarm.create_swarm( agents=langgraph_members, # type: ignore default_active_agent=agentspec_component.first_agent.name, + state_schema=_SwarmState, ).compile(name=agentspec_component.name, checkpointer=checkpointer) def _manager_workers_convert_to_langgraph( @@ -1339,6 +1348,15 @@ def _manager_workers_convert_to_langgraph( # streaming surfaces them with ``subgraph=True``. from langgraph.graph import MessagesState # local: optional dep + # The group manager is a react agent built with `response_format`, so it writes + # its structured answer to the `structured_response` channel of its own subgraph + # state. LangGraph drops a subgraph's updates to channels the parent does not + # declare, so the parent declares it too — otherwise the answer dies here and an + # AgentNode wrapping this ManagerWorkers sees its declared outputs unresolved. + # Only the manager writes it: `_wrap_worker_for_subgraph` returns `messages` alone. + class _ManagerWorkersState(MessagesState, total=False): + structured_response: Dict[str, Any] + manager_node_key = _MANAGER_NODE_KEY if manager_node_key in worker_graphs: raise ValueError( @@ -1346,7 +1364,7 @@ def _manager_workers_convert_to_langgraph( f"manager node in ManagerWorkers; rename the worker." ) - builder = StateGraph(MessagesState) + builder = StateGraph(_ManagerWorkersState) builder.add_node(manager_node_key, manager_graph) for node_name, worker_graph in worker_graphs.items(): builder.add_node( diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py index f6f99009..2b9fe18a 100644 --- a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py +++ b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py @@ -132,3 +132,124 @@ class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): assert "outputs" in result assert result["outputs"]["translated"] == "لماذا..." + + +def test_managerworkers_node_resolves_multiple_structured_outputs() -> None: + """A ManagerWorkers flow step resolves several structured outputs. + + A manager graph runs over ``MessagesState``, which has no ``structured_response`` + channel, so the structured answer the group manager generated cannot come back + through the graph's return value. With a single string output the free-text + fallback hides that; with two or more declared outputs nothing filled them and + ``_cast_values_and_add_defaults`` raised ``ValueError: Expected node ... to have + a value for property ...`` at the producing node. The values are still present in + the message history as the arguments of the structured-output tool call, so they + are recovered from there. + """ + from unittest.mock import patch + + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge + from pyagentspec.flows.flow import Flow + from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + fake_llm = _FakeModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "name": "AgentOutputModel", + "args": {"route": "art", "brief": "a pixel dragon"}, + "id": "call_structured_output", + } + ], + ) + ] + ) + + cfg = OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null") + request = StringProperty(title="request") + route = StringProperty(title="route") + brief = StringProperty(title="brief") + + manager = Agent( + name="manager", + llm_config=cfg, + system_prompt="Triage this request:\n\n{{request}}", + outputs=[route, brief], + ) + worker = Agent(name="worker", llm_config=cfg, system_prompt="You help.") + mw = ManagerWorkers(name="triage", group_manager=manager, workers=[worker]) + + manager_node = AgentNode(name="manager_node", agent=mw) + start_node = StartNode(name="start", inputs=[request]) + end_node = EndNode(name="end", outputs=[route, brief]) + flow = Flow( + name="flow", + start_node=start_node, + nodes=[start_node, manager_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=manager_node), + ControlFlowEdge(name="node_to_end", from_node=manager_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="request_edge", + source_node=start_node, + source_output=request.title, + destination_node=manager_node, + destination_input=request.title, + ), + DataFlowEdge( + name="route_edge", + source_node=manager_node, + source_output=route.title, + destination_node=end_node, + destination_input=route.title, + ), + DataFlowEdge( + name="brief_edge", + source_node=manager_node, + source_output=brief.title, + destination_node=end_node, + destination_input=brief.title, + ), + ], + outputs=[route, brief], + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ): + compiled = loader.load_component(flow) + result = compiled.invoke( + { + "inputs": {"request": "make me a dragon"}, + "messages": [{"role": "user", "content": ""}], + }, + {"configurable": {"thread_id": "managerworkers-node-structured"}}, + ) + + assert result["outputs"]["route"] == "art" + assert result["outputs"]["brief"] == "a pixel dragon" diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_swarm_node.py b/pyagentspec/tests/adapters/langgraph/flows/test_swarm_node.py index 992c9200..1462721c 100644 --- a/pyagentspec/tests/adapters/langgraph/flows/test_swarm_node.py +++ b/pyagentspec/tests/adapters/langgraph/flows/test_swarm_node.py @@ -135,3 +135,120 @@ class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): assert "outputs" in result assert result["outputs"]["translated"] == "لماذا..." + + +def test_swarm_node_resolves_multiple_structured_outputs() -> None: + """A Swarm flow step resolves several structured outputs. + + Symmetric with ``test_managerworkers_node_resolves_multiple_structured_outputs``: a + swarm also runs over ``MessagesState`` and returns no ``structured_response``, so two + or more declared outputs used to raise ``ValueError`` at the producing node. The + generated values are recovered from the structured-output tool call in the history. + """ + from unittest.mock import patch + + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge + from pyagentspec.flows.flow import Flow + from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + fake_llm = _FakeModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "name": "AgentOutputModel", + "args": {"route": "art", "brief": "a pixel dragon"}, + "id": "call_structured_output", + } + ], + ) + ] + ) + + cfg = OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null") + request = StringProperty(title="request") + route = StringProperty(title="route") + brief = StringProperty(title="brief") + + first = Agent( + name="first", + llm_config=cfg, + system_prompt="Triage this request:\n\n{{request}}", + outputs=[route, brief], + ) + second = Agent(name="second", llm_config=cfg, system_prompt="You help.") + swarm = Swarm(name="triage", first_agent=first, relationships=[(first, second)]) + + swarm_node = AgentNode(name="swarm_node", agent=swarm) + start_node = StartNode(name="start", inputs=[request]) + end_node = EndNode(name="end", outputs=[route, brief]) + flow = Flow( + name="flow", + start_node=start_node, + nodes=[start_node, swarm_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=swarm_node), + ControlFlowEdge(name="node_to_end", from_node=swarm_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="request_edge", + source_node=start_node, + source_output=request.title, + destination_node=swarm_node, + destination_input=request.title, + ), + DataFlowEdge( + name="route_edge", + source_node=swarm_node, + source_output=route.title, + destination_node=end_node, + destination_input=route.title, + ), + DataFlowEdge( + name="brief_edge", + source_node=swarm_node, + source_output=brief.title, + destination_node=end_node, + destination_input=brief.title, + ), + ], + outputs=[route, brief], + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ): + compiled = loader.load_component(flow) + result = compiled.invoke( + { + "inputs": {"request": "make me a dragon"}, + "messages": [{"role": "user", "content": ""}], + }, + {"configurable": {"thread_id": "swarm-node-structured"}}, + ) + + assert result["outputs"]["route"] == "art" + assert result["outputs"]["brief"] == "a pixel dragon" From 64b7cb52309ebfca642a274512f8fb7321ff0757 Mon Sep 17 00:00:00 2001 From: "GitLab CI (deployments)" <nh.salah@gmail.com> Date: Tue, 11 Aug 2026 13:55:45 +0400 Subject: [PATCH 29/34] fix(adapters): map bare object schemas to passthrough dicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool parameter whose JSON schema is a bare {"type": "object"} (no declared properties) was converted into an empty create_model() pydantic model. Pydantic defaults to extra="ignore", so validating the LLM's arguments against that model silently stripped every key — the tool received {} (or [{}] for arrays of objects) no matter what the model sent. Bare object schemas now map to Dict[str, Any], matching JSON Schema semantics (an object with no property constraints accepts any object). Schemas that declare properties or set additionalProperties: false still build typed models as before. --- .../src/pyagentspec/adapters/_utils.py | 9 +++- pyagentspec/tests/test_bare_object_schema.py | 47 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 pyagentspec/tests/test_bare_object_schema.py diff --git a/pyagentspec/src/pyagentspec/adapters/_utils.py b/pyagentspec/src/pyagentspec/adapters/_utils.py index ed8ce67a..55218b80 100644 --- a/pyagentspec/src/pyagentspec/adapters/_utils.py +++ b/pyagentspec/src/pyagentspec/adapters/_utils.py @@ -185,6 +185,14 @@ def _build_type_from_schema( return List[item_type] # type: ignore # objects if t == "object" or ("properties" in schema or "required" in schema): + props = schema.get("properties", {}) or {} + # An object schema with no declared properties accepts any object + # (JSON Schema semantics). Building an empty create_model() here would + # silently strip every key on validation (pydantic defaults to + # extra="ignore"), so the tool receives {} instead of the LLM's + # arguments. Map it to a passthrough dict instead. + if not props and schema.get("additionalProperties") is not False: + return Dict[str, Any] # Create or reuse a Pydantic model for this object schema model_name = schema.get("title") or name unique_name = model_name @@ -193,7 +201,6 @@ def _build_type_from_schema( suffix += 1 unique_name = f"{model_name}_{suffix}" - props = schema.get("properties", {}) or {} required = set(schema.get("required", [])) fields: Dict[str, Tuple[Any, Any]] = {} diff --git a/pyagentspec/tests/test_bare_object_schema.py b/pyagentspec/tests/test_bare_object_schema.py new file mode 100644 index 00000000..ac1e0acb --- /dev/null +++ b/pyagentspec/tests/test_bare_object_schema.py @@ -0,0 +1,47 @@ +"""A bare ``{"type": "object"}`` schema (no declared properties) must map to a +passthrough dict, not an empty pydantic model that silently strips every key +the LLM supplies (pydantic defaults to ``extra="ignore"``).""" + +from typing import Any, Dict + +from pyagentspec.adapters._utils import create_pydantic_model_from_properties +from pyagentspec.property import Property + + +def _model_for(json_schema: Dict[str, Any]) -> Any: + prop = Property(title="components", json_schema=json_schema) + return create_pydantic_model_from_properties("ToolArgs", [prop]) + + +def test_array_of_bare_objects_keeps_item_keys() -> None: + model = _model_for({"type": "array", "items": {"type": "object"}}) + + parsed = model( + components=[{"id": "root", "component": "Card", "child": "title"}] + ) + + assert parsed.components == [ + {"id": "root", "component": "Card", "child": "title"} + ] + + +def test_bare_object_keeps_keys() -> None: + model = _model_for({"type": "object"}) + + parsed = model(components={"id": "root", "nested": {"a": 1}}) + + assert parsed.components == {"id": "root", "nested": {"a": 1}} + + +def test_object_with_declared_properties_still_builds_a_model() -> None: + model = _model_for( + { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + ) + + parsed = model(components={"name": "Alice"}) + + assert parsed.components.name == "Alice" From c5e9c4d7f24156764d161fad86d752b2f6aa5eac Mon Sep 17 00:00:00 2001 From: abdelmoumen_mezhoud <abdelmoumen.mezhoud@openinnovation.ai> Date: Tue, 11 Aug 2026 16:12:26 +0400 Subject: [PATCH 30/34] fix(adapters): say what a remote tool returned when it isn't JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A remote tool's response is always parsed as JSON, so a backend that answers with an error page — or nothing — failed with only Expecting value: line 1 column 1 (char 0) which names neither the tool, the status, nor the body. The status was no help either: it is checked only when a retry policy is configured, and oi-apps-core's tool builder has no retry-policy field, so every HTTP tool created through the UI takes the unchecked path. A 401 from a misconfigured backend and a 502 from a gateway were indistinguishable from malformed JSON. The decode is now wrapped to report the tool name, the status, the content type and the start of the body. The status check is left exactly as it was: a non-2xx carrying a JSON error body is still returned for the agent to read, which is what the existing guard allows. Reproduced against a URL serving HTML (200 text/html) and a 404 error page; both now say so, and a JSON body — including a JSON error body — is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../src/pyagentspec/adapters/_tools_common.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/pyagentspec/src/pyagentspec/adapters/_tools_common.py b/pyagentspec/src/pyagentspec/adapters/_tools_common.py index fa320b99..60ee002d 100644 --- a/pyagentspec/src/pyagentspec/adapters/_tools_common.py +++ b/pyagentspec/src/pyagentspec/adapters/_tools_common.py @@ -90,7 +90,20 @@ def _remote_tool(**kwargs: Any) -> Any: response = _request_with_retry(remote_tool.retry_policy, request_kwargs) if remote_tool.retry_policy is not None and not response.is_success: response.raise_for_status() - return response.json() + try: + return response.json() + except ValueError as exc: + # A body that isn't JSON is almost always an error page the status + # would have explained — but a tool with no retry policy doesn't + # check the status (a non-2xx JSON error body is handed to the agent + # to read), so the decode failure was the only thing surfaced: + # "Expecting value: line 1 column 1 (char 0)", with no clue that the + # backend answered 401 or served HTML. + raise ValueError( + f"{remote_tool.name} returned {response.status_code} " + f"{response.headers.get('content-type', 'no content-type')}, " + f"which is not JSON: {response.text[:200]!r}" + ) from exc return _remote_tool From 2edcad766fed123d8136c9982822675c4a9c1f48 Mon Sep 17 00:00:00 2001 From: abdelmoumen_mezhoud <abdelmoumen.mezhoud@openinnovation.ai> Date: Tue, 11 Aug 2026 16:30:29 +0400 Subject: [PATCH 31/34] style: format with the pinned black `black --check pyagentspec/` was failing on oi-fork for eight files that predate this branch, so every pull request against it starts red and the real gates are never reached (the matrix fail-fast cancels the rest). Mechanical only: `black --config pyagentspec/pyproject.toml pyagentspec/` with the version the CI pins (26.3.1). No behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../src/pyagentspec/adapters/_tools_common.py | 6 +- .../src/pyagentspec/adapters/_utils.py | 5 +- .../adapters/langgraph/_langgraphconverter.py | 54 ++++----------- .../langgraph/flows/test_agentnode.py | 5 +- .../flows/test_managerworkers_node.py | 5 +- .../adapters/langgraph/test_managerworkers.py | 67 +++++++++++-------- .../tests/adapters/test_template_rendering.py | 8 ++- pyagentspec/tests/test_bare_object_schema.py | 8 +-- 8 files changed, 75 insertions(+), 83 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/_tools_common.py b/pyagentspec/src/pyagentspec/adapters/_tools_common.py index 60ee002d..01925e70 100644 --- a/pyagentspec/src/pyagentspec/adapters/_tools_common.py +++ b/pyagentspec/src/pyagentspec/adapters/_tools_common.py @@ -16,7 +16,11 @@ maybe_warn_about_unrestricted_templated_url, validate_url_against_allow_list, ) -from pyagentspec.adapters._utils import render_nested_json_template, render_nested_object_template, render_template +from pyagentspec.adapters._utils import ( + render_nested_json_template, + render_nested_object_template, + render_template, +) from pyagentspec.retrypolicy import RetryPolicy from pyagentspec.tools.remotetool import RemoteTool as AgentSpecRemoteTool diff --git a/pyagentspec/src/pyagentspec/adapters/_utils.py b/pyagentspec/src/pyagentspec/adapters/_utils.py index 55218b80..6c92d38a 100644 --- a/pyagentspec/src/pyagentspec/adapters/_utils.py +++ b/pyagentspec/src/pyagentspec/adapters/_utils.py @@ -106,7 +106,10 @@ def render_nested_json_template(object: Any, inputs: Dict[str, Any]) -> Any: elif isinstance(object, bytes): return render_nested_json_template(object.decode("utf-8", errors="replace"), inputs) elif isinstance(object, dict): - return {render_template(k, inputs): render_nested_json_template(v, inputs) for k, v in object.items()} + return { + render_template(k, inputs): render_nested_json_template(v, inputs) + for k, v in object.items() + } elif isinstance(object, list) or isinstance(object, set) or isinstance(object, tuple): return object.__class__([render_nested_json_template(item, inputs) for item in object]) else: diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index b38009c5..c765d5e8 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -840,9 +840,7 @@ def _tool_node_convert_to_langgraph( agentspec_tool, config=config, raise_on_denial=True ) elif isinstance(agentspec_tool, AgentSpecClientTool): - tool = self._client_tool_convert_to_langgraph( - agentspec_tool, raise_on_denial=True - ) + tool = self._client_tool_convert_to_langgraph(agentspec_tool, raise_on_denial=True) else: raise ValueError( f"Tool '{agentspec_tool.name}' of type " @@ -1270,8 +1268,7 @@ def _manager_workers_convert_to_langgraph( ) worker_node_names: List[str] = [ - _safe_node_name(worker.name, fallback_id=worker.id) - for worker in mw.workers + _safe_node_name(worker.name, fallback_id=worker.id) for worker in mw.workers ] if len(set(worker_node_names)) != len(worker_node_names): raise ValueError( @@ -1306,8 +1303,7 @@ def _manager_workers_convert_to_langgraph( # placeholder — the parent graph intercepts the manager's tool # call before it executes and routes to the worker node. delegation_tools: List[Any] = [ - _make_worker_delegation_tool(node_name) - for node_name in worker_node_names + _make_worker_delegation_tool(node_name) for node_name in worker_node_names ] # 3b. When this ManagerWorkers is a Swarm member, synthesize one @@ -1375,9 +1371,7 @@ class _ManagerWorkersState(MessagesState, total=False): # Path-map covers delegate-to-worker and the END branch so langgraph # can statically validate the routing; the Swarm-handoff branch is # added only when this MW is a swarm member with handoff destinations. - routing_path_map: Dict[str, str] = { - node_name: node_name for node_name in worker_node_names - } + routing_path_map: Dict[str, str] = {node_name: node_name for node_name in worker_node_names} routing_path_map[langgraph_graph.END] = langgraph_graph.END if handoff_dest_by_tool_name: if _HANDOFF_NODE_KEY in worker_graphs: @@ -1403,9 +1397,7 @@ class _ManagerWorkersState(MessagesState, total=False): # Command(goto=<sibling>, graph=PARENT) that exits this graph into the # Swarm, so looping it back to the manager would be wrong. - compiled_graph = builder.compile( - checkpointer=checkpointer, name=mw.name - ) + compiled_graph = builder.compile(checkpointer=checkpointer, name=mw.name) # 6. Tracing — wrap stream/astream so ManagerWorkersExecutionSpan # surrounds each run. Mirrors the patches applied to Agent and @@ -2402,8 +2394,7 @@ def _append_workers_roster( if not entries: return system_prompt lines = [ - f"- {name}: {_WHITESPACE_RE.sub(' ', description).strip()}" - for name, description in entries + f"- {name}: {_WHITESPACE_RE.sub(' ', description).strip()}" for name, description in entries ] roster = "Available workers:\n" + "\n".join(lines) return f"{system_prompt}\n\n{roster}" if system_prompt else roster @@ -2571,11 +2562,7 @@ def _forward(state: Dict[str, Any]) -> Any: last = messages[-1] if messages else None tool_calls = getattr(last, "tool_calls", None) or [] transfer_call = next( - ( - tc - for tc in tool_calls - if _tc_get(tc, "name") in handoff_dest_by_tool_name - ), + (tc for tc in tool_calls if _tc_get(tc, "name") in handoff_dest_by_tool_name), None, ) if transfer_call is None: @@ -2584,9 +2571,7 @@ def _forward(state: Dict[str, Any]) -> Any: destination = handoff_dest_by_tool_name[_tc_get(transfer_call, "name")] transfer_id = _tc_get(transfer_call, "id") or "" already_answered = { - getattr(m, "tool_call_id", None) - for m in messages - if getattr(m, "type", None) == "tool" + getattr(m, "tool_call_id", None) for m in messages if getattr(m, "type", None) == "tool" } tool_messages: List[Any] = [] for tc in tool_calls: @@ -2596,10 +2581,7 @@ def _forward(state: Dict[str, Any]) -> Any: if call_id == transfer_id: content = f"Successfully transferred to {destination}" else: - content = ( - f"Not executed: the conversation was handed off to " - f"{destination}." - ) + content = f"Not executed: the conversation was handed off to " f"{destination}." tool_messages.append( ToolMessage( content=content, @@ -2675,7 +2657,7 @@ def _route_manager_to_worker_handoff_or_end(state: Dict[str, Any]) -> Any: for tc in tool_calls: name = _tc_get(tc, "name") if isinstance(name, str) and name.startswith(_DELEGATE_TOOL_PREFIX): - worker_node_name = name[len(_DELEGATE_TOOL_PREFIX):] + worker_node_name = name[len(_DELEGATE_TOOL_PREFIX) :] args = _tc_get(tc, "args") or {} sends.append( Send( @@ -2729,9 +2711,7 @@ def _extract_pending(state: Dict[str, Any]) -> Tuple[str, str]: # is recoverable this way, which is why routing prefers Send. messages = state.get("messages") or [] if not messages: - raise RuntimeError( - f"Worker '{worker_node_name}' was invoked with empty manager state." - ) + raise RuntimeError(f"Worker '{worker_node_name}' was invoked with empty manager state.") last_ai = messages[-1] tool_calls = getattr(last_ai, "tool_calls", None) or [] pending_call = next( @@ -2785,9 +2765,7 @@ def _worker_input(task: str) -> Dict[str, Any]: "messages": [ HumanMessage( content=task, - additional_kwargs={ - _DELEGATION_TASK_MARKER_KEY: _DELEGATION_TASK_MARKER_VALUE - }, + additional_kwargs={_DELEGATION_TASK_MARKER_KEY: _DELEGATION_TASK_MARKER_VALUE}, ) ] } @@ -2867,9 +2845,7 @@ def patched_stream(*args: Any, **kwargs: Any) -> Generator[Any, Any, None]: span_name = f"ManagerWorkersExecution[{mw.name}]" inputs = _coerce_inputs(kwargs) with AgentSpecManagerWorkersExecutionSpan(name=span_name, managerworkers=mw) as span: - span.add_event( - AgentSpecManagerWorkersExecutionStart(managerworkers=mw, inputs=inputs) - ) + span.add_event(AgentSpecManagerWorkersExecutionStart(managerworkers=mw, inputs=inputs)) last_chunk: Dict[str, Any] = {} for chunk in original_stream(*args, **kwargs): yield chunk @@ -2891,9 +2867,7 @@ async def patched_astream(*args: Any, **kwargs: Any) -> AsyncGenerator[Any, Any] except NotImplementedError: span.start() try: - start_event = AgentSpecManagerWorkersExecutionStart( - managerworkers=mw, inputs=inputs - ) + start_event = AgentSpecManagerWorkersExecutionStart(managerworkers=mw, inputs=inputs) try: await span.add_event_async(start_event) except NotImplementedError: diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py b/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py index 06f28143..8930aede 100644 --- a/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py +++ b/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py @@ -104,10 +104,7 @@ def test_is_single_string_output() -> None: assert is_single_string_output([StringProperty(title="x")]) is True assert is_single_string_output([]) is False assert is_single_string_output([IntegerProperty(title="n")]) is False - assert ( - is_single_string_output([StringProperty(title="a"), StringProperty(title="b")]) - is False - ) + assert is_single_string_output([StringProperty(title="a"), StringProperty(title="b")]) is False def test_single_string_output_taken_from_final_message_without_structured_generation() -> None: diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py index 2b9fe18a..28f5bb92 100644 --- a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py +++ b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py @@ -126,7 +126,10 @@ class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): ): compiled = loader.load_component(flow) result = compiled.invoke( - {"inputs": {"joke": "Why did the car..."}, "messages": [{"role": "user", "content": ""}]}, + { + "inputs": {"joke": "Why did the car..."}, + "messages": [{"role": "user", "content": ""}], + }, {"configurable": {"thread_id": "managerworkers-node"}}, ) diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index c2c8e2ad..b199526e 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -18,7 +18,6 @@ import pytest - # ─── Shared helpers ────────────────────────────────────────────────────────── @@ -97,9 +96,7 @@ def test_route_manager_to_worker_or_end_sends_to_pending_delegation() -> None: delegating = AIMessage( content="", - tool_calls=[ - {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "c1"} - ], + tool_calls=[{"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "c1"}], ) sends = _route_manager_to_worker_handoff_or_end({"messages": [delegating]}) # One delegation → a single Send to the worker node carrying the task @@ -112,11 +109,11 @@ def test_route_manager_to_worker_or_end_sends_to_pending_delegation() -> None: def test_route_manager_to_worker_or_end_returns_end_when_no_delegation() -> None: from langchain_core.messages import AIMessage + from langgraph.graph import END from pyagentspec.adapters.langgraph._langgraphconverter import ( _route_manager_to_worker_handoff_or_end, ) - from langgraph.graph import END not_delegating = AIMessage(content="Done.", tool_calls=[]) assert _route_manager_to_worker_handoff_or_end({"messages": [not_delegating]}) == END @@ -161,8 +158,8 @@ def test_manager_workers_compiles_to_hierarchical_graph_topology() -> None: from pyagentspec.adapters.langgraph import AgentSpecLoader from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, _MANAGER_NODE_KEY, + AgentSpecToLangGraphConverter, ) from pyagentspec.agent import Agent from pyagentspec.managerworkers import ManagerWorkers @@ -226,8 +223,8 @@ def test_manager_workers_renders_workers_roster_into_manager_prompt() -> None: from pyagentspec.adapters.langgraph import AgentSpecLoader from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, _MANAGER_NODE_KEY, + AgentSpecToLangGraphConverter, ) from pyagentspec.agent import Agent from pyagentspec.managerworkers import ManagerWorkers @@ -508,16 +505,14 @@ def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: # ...carrying the delegation marker so consumers can tell it apart from a # real end-user turn (and drop/relabel it) instead of rendering it as one. assert ( - task_msg.additional_kwargs.get(_DELEGATION_TASK_MARKER_KEY) - == _DELEGATION_TASK_MARKER_VALUE + task_msg.additional_kwargs.get(_DELEGATION_TASK_MARKER_KEY) == _DELEGATION_TASK_MARKER_VALUE ) # ...and the task was NOT smuggled in as a SystemMessage (which would leave # the model with no user turn to respond to). The worker's own system # prompt is still a SystemMessage, so assert on the task content, not the # mere presence of a SystemMessage. assert not any( - isinstance(m, SystemMessage) and "Look up Saturn" in (m.content or "") - for m in first_call + isinstance(m, SystemMessage) and "Look up Saturn" in (m.content or "") for m in first_call ) @@ -532,10 +527,10 @@ def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: tool-result sequence that made the manager hallucinate the missing replies. This asserts all three calls get matched ToolMessages. """ - from langchain_core.messages import AIMessage, HumanMessage from langchain_core.language_models.fake_chat_models import ( FakeMessagesListChatModel, ) + from langchain_core.messages import AIMessage, HumanMessage from langgraph.checkpoint.memory import MemorySaver from pyagentspec.adapters.langgraph import AgentSpecLoader @@ -614,9 +609,11 @@ def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: } answered = [m.tool_call_id for m in messages if type(m).__name__ == "ToolMessage"] assert requested == {"call_1", "call_2", "call_3"} - assert sorted(answered) == ["call_1", "call_2", "call_3"], ( - f"unanswered delegations: {requested - set(answered)}" - ) + assert sorted(answered) == [ + "call_1", + "call_2", + "call_3", + ], f"unanswered delegations: {requested - set(answered)}" # No duplicate replies, and each carries a worker poem. assert len(answered) == 3 tool_msgs = [m for m in messages if type(m).__name__ == "ToolMessage"] @@ -737,9 +734,16 @@ def test_workers_with_name_slug_collision_are_rejected() -> None: a = Agent(name="Helper A", description="x", system_prompt=".", llm_config=_llm_cfg("a")) b = Agent(name="helper-a", description="x", system_prompt=".", llm_config=_llm_cfg("b")) # Both normalize to "helper_a". - mw = ManagerWorkers(name="T", group_manager=Agent( - name="M", description="m", system_prompt=".", llm_config=_llm_cfg("m"), - ), workers=[a, b]) + mw = ManagerWorkers( + name="T", + group_manager=Agent( + name="M", + description="m", + system_prompt=".", + llm_config=_llm_cfg("m"), + ), + workers=[a, b], + ) loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) with pytest.raises(ValueError, match="collide after normalization"): @@ -786,7 +790,11 @@ def _manager(state: Any) -> Any: AIMessage( content="", tool_calls=[ - {"name": "delegate_to_research_helper", "args": {"task": "Saturn"}, "id": "c1"} + { + "name": "delegate_to_research_helper", + "args": {"task": "Saturn"}, + "id": "c1", + } ], ) ] @@ -881,9 +889,7 @@ def test_worker_error_recovers_call_id_from_manager_ai_message() -> None: ai = AIMessage( content="", - tool_calls=[ - {"name": "delegate_to_researcher", "args": {"task": "find X"}, "id": "c9"} - ], + tool_calls=[{"name": "delegate_to_researcher", "args": {"task": "find X"}, "id": "c9"}], ) node = _wrap_worker_for_subgraph(_raising_worker_graph(), "researcher") @@ -940,7 +946,11 @@ def _manager(state: Any) -> Any: AIMessage( content="", tool_calls=[ - {"name": "delegate_to_research_helper", "args": {"task": "Saturn"}, "id": "c1"} + { + "name": "delegate_to_research_helper", + "args": {"task": "Saturn"}, + "id": "c1", + } ], ) ] @@ -1048,10 +1058,10 @@ def test_manager_workers_as_swarm_member_hands_off_to_sibling() -> None: the handoff to the Swarm, which routes to the sibling Agent and lets it answer — proving a sub-agent-bearing agent can participate in a Swarm (the case the LangGraph adapter used to reject).""" - from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.language_models.fake_chat_models import ( FakeMessagesListChatModel, ) + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langgraph.checkpoint.memory import MemorySaver from pyagentspec.adapters.langgraph import AgentSpecLoader @@ -1131,8 +1141,7 @@ def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: transferred = [ m for m in messages - if isinstance(m, ToolMessage) - and m.content == "Successfully transferred to Specialist" + if isinstance(m, ToolMessage) and m.content == "Successfully transferred to Specialist" ] assert transferred and transferred[0].tool_call_id == "call_h1" @@ -1143,9 +1152,9 @@ def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: for tc in getattr(m, "tool_calls", None) or []: open_call_ids.add(tc["id"]) if isinstance(m, ToolMessage): - assert m.tool_call_id in open_call_ids, ( - f"orphan ToolMessage {m.tool_call_id} with no preceding tool_call" - ) + assert ( + m.tool_call_id in open_call_ids + ), f"orphan ToolMessage {m.tool_call_id} with no preceding tool_call" def test_swarm_rejects_unsupported_member_type() -> None: diff --git a/pyagentspec/tests/adapters/test_template_rendering.py b/pyagentspec/tests/adapters/test_template_rendering.py index 4414b7b8..e81c7c43 100644 --- a/pyagentspec/tests/adapters/test_template_rendering.py +++ b/pyagentspec/tests/adapters/test_template_rendering.py @@ -145,7 +145,13 @@ def test_json_template_are_properly_rendered( "topic": None, "type": "group", }, - {"input": {"members": [{"userId": "u1", "roles": None}], "topic": None, "type": "group"}}, + { + "input": { + "members": [{"userId": "u1", "roles": None}], + "topic": None, + "type": "group", + } + }, ), ], ) diff --git a/pyagentspec/tests/test_bare_object_schema.py b/pyagentspec/tests/test_bare_object_schema.py index ac1e0acb..b3931233 100644 --- a/pyagentspec/tests/test_bare_object_schema.py +++ b/pyagentspec/tests/test_bare_object_schema.py @@ -16,13 +16,9 @@ def _model_for(json_schema: Dict[str, Any]) -> Any: def test_array_of_bare_objects_keeps_item_keys() -> None: model = _model_for({"type": "array", "items": {"type": "object"}}) - parsed = model( - components=[{"id": "root", "component": "Card", "child": "title"}] - ) + parsed = model(components=[{"id": "root", "component": "Card", "child": "title"}]) - assert parsed.components == [ - {"id": "root", "component": "Card", "child": "title"} - ] + assert parsed.components == [{"id": "root", "component": "Card", "child": "title"}] def test_bare_object_keeps_keys() -> None: From 96ea923e9d230b630909e7f0c6e4dfc2a0092053 Mon Sep 17 00:00:00 2001 From: abdelmoumen_mezhoud <abdelmoumen.mezhoud@openinnovation.ai> Date: Tue, 11 Aug 2026 16:34:09 +0400 Subject: [PATCH 32/34] style: add the missing copyright header `flake8 --select C801 --copyright-check` was failing on `tests/test_bare_object_schema.py`, which arrived without the header the check requires. It was masked until now because the black step failed first and the matrix cancels the remaining jobs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- pyagentspec/tests/test_bare_object_schema.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyagentspec/tests/test_bare_object_schema.py b/pyagentspec/tests/test_bare_object_schema.py index b3931233..96d48463 100644 --- a/pyagentspec/tests/test_bare_object_schema.py +++ b/pyagentspec/tests/test_bare_object_schema.py @@ -1,3 +1,9 @@ +# Copyright © 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + """A bare ``{"type": "object"}`` schema (no declared properties) must map to a passthrough dict, not an empty pydantic model that silently strips every key the LLM supplies (pydantic defaults to ``extra="ignore"``).""" From 4c4312e0a0561dc202fba2f386525eff3d96eb84 Mon Sep 17 00:00:00 2001 From: Salah Pichen <salah.pichen@openinnovation.ai> Date: Thu, 13 Aug 2026 12:03:52 +0400 Subject: [PATCH 33/34] fix(langgraph): propagate interrupts from an AgentNode's inner agent AgentNodeExecutor invoked the inner agent's compiled graph with the conversion-time config, which carries none of the flow task's pregel context. The agent therefore ran as a standalone root graph: an interrupt() raised inside it (a ClientTool, a requires_confirmation tool) was absorbed into that root run's own state, invoke() returned with __interrupt__ in the result, the executor formatted the tool-call message as the node output, and the flow carried on as if the agent had answered. Invoke the inner agent with the current pregel task's config (langgraph.config.get_config()) so it runs as a true subgraph of the flow: the interrupt propagates to the flow's run and pauses it, and a Command(resume=...) replays back into the inner agent. --- .../adapters/langgraph/_node_execution.py | 26 ++- .../langgraph/flows/test_agentnode.py | 155 ++++++++++++++++++ 2 files changed, 179 insertions(+), 2 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index 4bcb1260..30308a2b 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -696,16 +696,38 @@ def _format_agent_result( outputs = extract_outputs_from_invoke_result(result, self.node.outputs or []) return outputs, NodeExecutionDetails() + def _invoke_config(self) -> RunnableConfig: + """Config to run the inner agent with: the current pregel task's config. + + The conversion-time ``self.config`` carries none of the flow task's + ``__pregel_*`` context, so invoking the inner agent with it runs the + agent as a standalone root graph: an ``interrupt()`` raised inside it + (a ClientTool, a requires_confirmation tool) is absorbed into that + root run's own state, ``invoke`` returns with ``__interrupt__``, and + the flow carries on as if the agent had answered. With the live task + config the agent runs as a true subgraph of the flow — the interrupt + propagates to the flow's run, and a ``Command(resume=...)`` replays + back into the agent. + """ + from langgraph.config import get_config + + try: + return get_config() + except RuntimeError: + # Not inside a pregel task (e.g. an executor driven directly in + # tests): keep the conversion-time config. + return self.config + def _execute(self, inputs: Dict[str, Any], messages: Messages) -> ExecuteOutput: node_inputs = dict(inputs) agent, prepared_inputs = self._prepare_agent_and_inputs(inputs, messages) - result = agent.invoke(prepared_inputs, self.config) + result = agent.invoke(prepared_inputs, self._invoke_config()) return self._format_agent_result(result, node_inputs) async def _aexecute(self, inputs: Dict[str, Any], messages: Messages) -> ExecuteOutput: node_inputs = dict(inputs) agent, prepared_inputs = self._prepare_agent_and_inputs(inputs, messages) - result = await agent.ainvoke(prepared_inputs, self.config) + result = await agent.ainvoke(prepared_inputs, self._invoke_config()) return self._format_agent_result(result, node_inputs) diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py b/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py index 8930aede..d74489bb 100644 --- a/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py +++ b/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py @@ -283,6 +283,161 @@ class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): assert result["outputs"]["output"] == french_joke +def _build_client_tool_flow() -> Flow: + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + from pyagentspec.tools import ClientTool + + client_tool = ClientTool( + name="ask_user", + description="Ask the user a question", + inputs=[StringProperty(title="question")], + ) + agent = Agent( + name="agent", + llm_config=OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null"), + system_prompt="You are helpful.", + tools=[client_tool], + ) + agent_node = AgentNode(name="agent_node", agent=agent) + start_node = StartNode(name="start") + end_node = EndNode(name="end") + return Flow( + name="flow", + start_node=start_node, + nodes=[start_node, agent_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=agent_node), + ControlFlowEdge(name="node_to_end", from_node=agent_node, to_node=end_node), + ], + data_flow_connections=[], + ) + + +def _client_tool_fake_model(): + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs): + # ChatOpenAI's _agenerate would win the MRO and call the real API; + # route the async path through the fake sync implementation. + return self._generate(messages, stop=stop, **kwargs) + + return _FakeModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "name": "ask_user", + "args": {"question": "capital of India?"}, + "id": "call_1", + "type": "tool_call", + } + ], + ), + AIMessage(content="You answered: New Delhi"), + ] + ) + + +def _client_tool_flow_patches(fake_llm): + from unittest.mock import patch + + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + + return ( + patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), + patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ), + ) + + +def test_agentnode_client_tool_interrupt_propagates_and_resumes() -> None: + """A ClientTool interrupt raised inside an AgentNode's agent must pause the + flow, and a resume must replay into the agent. + + Regression: the executor invoked the inner agent with the conversion-time + ``self.config`` (no ``__pregel_*`` task context), so the agent ran as a + standalone root graph — the interrupt was absorbed into that run's own + state, the node formatted the tool-call message as its output, and the + flow carried on as if the agent had answered. + """ + from langchain_core.runnables import RunnableConfig + from langgraph.checkpoint.memory import MemorySaver + from langgraph.types import Command + + from pyagentspec.adapters.langgraph import AgentSpecLoader + + fake_llm = _client_tool_fake_model() + llm_patch, bind_patch = _client_tool_flow_patches(fake_llm) + with llm_patch, bind_patch: + compiled = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()).load_component( + _build_client_tool_flow() + ) + config = RunnableConfig({"configurable": {"thread_id": "agentnode-client-tool"}}) + result = compiled.invoke( + {"inputs": {}, "messages": [{"role": "user", "content": "ask me a question"}]}, + config=config, + ) + assert "__interrupt__" in result + interrupt_value = result["__interrupt__"][0].value + assert interrupt_value["type"] == "client_tool_request" + assert interrupt_value["name"] == "ask_user" + assert interrupt_value["inputs"]["kwargs"] == {"question": "capital of India?"} + + resumed = compiled.invoke(Command(resume="New Delhi"), config=config) + + assert "__interrupt__" not in resumed + assert resumed["messages"][-1].content == "You answered: New Delhi" + + +@pytest.mark.anyio +async def test_agentnode_client_tool_interrupt_propagates_and_resumes_async() -> None: + """Async variant of the ClientTool interrupt round-trip through an AgentNode.""" + from langchain_core.runnables import RunnableConfig + from langgraph.checkpoint.memory import MemorySaver + from langgraph.types import Command + + from pyagentspec.adapters.langgraph import AgentSpecLoader + + fake_llm = _client_tool_fake_model() + llm_patch, bind_patch = _client_tool_flow_patches(fake_llm) + with llm_patch, bind_patch: + compiled = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()).load_component( + _build_client_tool_flow() + ) + config = RunnableConfig({"configurable": {"thread_id": "agentnode-client-tool-async"}}) + result = await compiled.ainvoke( + {"inputs": {}, "messages": [{"role": "user", "content": "ask me a question"}]}, + config=config, + ) + assert "__interrupt__" in result + assert result["__interrupt__"][0].value["type"] == "client_tool_request" + + resumed = await compiled.ainvoke(Command(resume="New Delhi"), config=config) + + assert "__interrupt__" not in resumed + assert resumed["messages"][-1].content == "You answered: New Delhi" + + @pytest.mark.anyio @retry_test(max_attempts=3, wait_between_tries=2) async def test_agentnode_can_be_executed_async(agent_flow: Flow) -> None: From c83e113fed71b5f10d3260b399262c8fbea9eee5 Mon Sep 17 00:00:00 2001 From: Mohamed Ali <mohamed.su37@gmail.com> Date: Sun, 16 Aug 2026 23:01:53 +0400 Subject: [PATCH 34/34] fix(langgraph): offload sync remote tool coroutines Signed-off-by: Mohamed Ali <mohamed.ali@openinnovation.ai> --- .../adapters/langgraph/_langgraphconverter.py | 3 +- .../tests/adapters/langgraph/test_tools.py | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index c765d5e8..4524e094 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -5,6 +5,7 @@ # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. +import asyncio import inspect import logging import re @@ -2229,7 +2230,7 @@ def _as_structured_tool_coroutine( return func async def _wrapped_async(*args: Any, **kwargs: Any) -> Any: - return func(*args, **kwargs) + return await asyncio.to_thread(func, *args, **kwargs) return _wrapped_async diff --git a/pyagentspec/tests/adapters/langgraph/test_tools.py b/pyagentspec/tests/adapters/langgraph/test_tools.py index eb454b83..a2fffb8f 100644 --- a/pyagentspec/tests/adapters/langgraph/test_tools.py +++ b/pyagentspec/tests/adapters/langgraph/test_tools.py @@ -4,7 +4,9 @@ # (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. +import asyncio import threading +import time from typing import Any from unittest.mock import patch @@ -1107,6 +1109,39 @@ def test_server_tool_missing_from_registry_raises() -> None: AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()).load_component(flow) +@pytest.mark.anyio +async def test_remote_tool_coroutine_does_not_block_event_loop() -> None: + from pyagentspec.adapters.langgraph import AgentSpecLoader + + def mock_request(*args: Any, **kwargs: Any) -> DummyResponse: + time.sleep(0.2) + return DummyResponse({"ok": True, "body": kwargs["json"]}) + + remote_tool = RemoteTool( + name="remote_echo", + description="Echoes the input value", + url="https://example.com/echo", + http_method="POST", + data={"x": "{{x}}"}, + inputs=[IntegerProperty(title="x")], + outputs=[Property(title="result", json_schema={})], + ) + + lang_tool = AgentSpecLoader().load_component(remote_tool) + + assert lang_tool.coroutine is not None + + with patch("httpx.request", side_effect=mock_request): + started_at = time.monotonic() + task = asyncio.create_task(lang_tool.coroutine(x=5)) + + await asyncio.sleep(0.01) + + assert time.monotonic() - started_at < 0.1 + assert not task.done() + assert await task == {"ok": True, "body": {"x": "5"}} + + @pytest.mark.anyio async def test_async_server_tool_callable_converts_to_structured_tool_coroutine() -> None: from pyagentspec.adapters.langgraph import AgentSpecLoader