From fdfa0570a07e42a87e911e0776e19b833be3e046 Mon Sep 17 00:00:00 2001 From: Salah Date: Sat, 25 Jul 2026 15:22:50 +0400 Subject: [PATCH 01/14] feat(adapters/langgraph): compile ManagerWorkers into a hierarchical graph --- .../adapters/langgraph/_langgraphconverter.py | 161 ++ .../adapters/langgraph/_managerworkers.py | 661 ++++++++ .../adapters/langgraph/_node_execution.py | 60 +- pyagentspec/src/pyagentspec/managerworkers.py | 19 + .../flows/test_managerworkers_node.py | 157 ++ .../adapters/langgraph/test_managerworkers.py | 1343 +++++++++++++++++ 6 files changed, 2400 insertions(+), 1 deletion(-) create mode 100644 pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py create mode 100644 pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py 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 549e24b2..4a09ce1b 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -37,6 +37,16 @@ _build_type_from_schema, create_pydantic_model_from_properties, ) +from pyagentspec.adapters.langgraph._managerworkers import ( + _MANAGER_NODE_KEY, + _append_workers_roster, + _make_worker_delegation_tool, + _patch_hide_delegation_in_astream_events, + _patch_with_manager_workers_execution_span, + _route_manager_to_worker_or_end, + _safe_node_name, + _wrap_worker_for_subgraph, +) from pyagentspec.adapters.langgraph._node_execution import ( NodeExecutor, extract_outputs_from_invoke_result, @@ -103,6 +113,7 @@ ) 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 @@ -274,6 +285,15 @@ 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, + middleware=middleware, + ) elif isinstance(agentspec_component, AgentSpecLlmConfig): return self._llm_convert_to_langgraph(agentspec_component, config=config) elif isinstance(agentspec_component, AgentSpecClientTransport): @@ -1123,6 +1143,147 @@ 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, + middleware: List[Any], + ) -> 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, + middleware=middleware, + ) + + # 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, + middleware=middleware, + 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 + 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), + ) + + # Path-map covers delegate-to-worker and the END branch so langgraph + # can statically validate the routing. + 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 + + builder.add_edge(langgraph_graph.START, manager_node_key) + builder.add_conditional_edges( + manager_node_key, + _route_manager_to_worker_or_end, + routing_path_map, + ) + 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) + # 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( self, *, diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py new file mode 100644 index 00000000..d63d24db --- /dev/null +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py @@ -0,0 +1,661 @@ +# Copyright © 2025, 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. + +"""ManagerWorkers LangGraph compilation helpers. + +Module-level building blocks for compiling a ``ManagerWorkers`` into LangGraph. +The ``AgentSpecToLangGraphConverter`` method +``_manager_workers_convert_to_langgraph`` orchestrates these helpers; the +helpers themselves are pure functions with no dependency on the converter, +which is why they live here rather than bloating the converter module. +""" + +import logging +import re +from typing import Any, AsyncGenerator, Dict, Generator, List, Optional, Tuple + +from pyagentspec.adapters.langgraph._types import CompiledStateGraph, langgraph_graph +from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers +from pyagentspec.tracing.events import ( + ManagerWorkersExecutionEnd as AgentSpecManagerWorkersExecutionEnd, +) +from pyagentspec.tracing.events import ( + ManagerWorkersExecutionStart as AgentSpecManagerWorkersExecutionStart, +) +from pyagentspec.tracing.spans import ( + ManagerWorkersExecutionSpan as AgentSpecManagerWorkersExecutionSpan, +) + +# ─── 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_" + +# 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+") + + +def _normalize_identifier(s: str) -> str: + """Lowercase, collapse non-alphanumerics to underscores, strip surrounding + underscores. The single source of truth for turning a spec name into an + ASCII identifier, so a worker node name and the ``delegate_to_`` + tool name addressing it always agree.""" + return re.sub(r"[^a-z0-9]+", "_", (s or "").lower()).strip("_") + + +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 normalize via + :func:`_normalize_identifier`, 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. + """ + return _normalize_identifier(name) or _normalize_identifier(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 _messages_of(state: Any) -> List[Any]: + """Read the ``messages`` list off a state that may be a dict or an + attribute-bearing object (langgraph injects either into a tool).""" + if isinstance(state, dict): + return list(state.get("messages") or []) + return list(getattr(state, "messages", []) or []) + + +def _surface_to_parent_command(state: Any) -> Any: + """The delegation tool's body: break out of the manager's react loop and + project the subgraph's messages — including the AIMessage carrying the + triggering tool call — onto the PARENT state, carrying **no** ``goto`` + (routing is the parent graph's job). The ``add_messages`` reducer dedupes + by id, so re-surfacing existing messages is a no-op. Modelled on + ``langgraph_swarm.create_handoff_tool``.""" + from langgraph.types import Command + + return Command(graph=Command.PARENT, update={"messages": _messages_of(state)}) + + +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 + lines = [ + 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 + + +def _make_worker_delegation_tool(worker_node_name: str) -> Any: + """Build the ``delegate_to_`` tool the manager's LLM emits to route to a + worker. The body carries **no** ``goto`` — routing fans out one ``Send`` per + delegation (:func:`_route_manager_to_worker_or_end`); a ``goto`` here would + collapse multiple same-turn delegations into one parent Command, leaving the other + ``tool_call_id``s unanswered. + """ + 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. + """ + del task, tool_call_id # recovered from the surfaced AIMessage by the routing edge + return _surface_to_parent_command(state) + + _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]) -> Any: + """Inspect the manager's last AIMessage and route the parent graph: one + ``Send`` per ``delegate_to_`` tool call, or ``END`` when the manager + emitted none. + + A single manager turn may emit several ``delegate_to_`` calls; each gets its + own ``Send`` carrying the ``task`` + ``tool_call_id``, so every call is answered + independently (an unanswered delegation breaks the manager's next-turn + tool-call/result sequence). Multiple ``Send``s to one worker run independently; plain + tool calls already ran inside the manager's react loop. + """ + 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 _is_delegate_name(name): + args = _tc_get(tc, "args") or {} + sends.append( + Send( + name[len(_DELEGATE_TOOL_PREFIX) :], + { + _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( + 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 langchain_core.messages import HumanMessage, ToolMessage + + 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]: + # 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(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(tc, "name") == 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 = _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]: + return {"messages": [ToolMessage(content=reply, tool_call_id=call_id)]} + + def _worker_input(task: str) -> Dict[str, Any]: + # Pass NO explicit config so the worker inherits this node's ambient run config: + # its ``checkpoint_ns`` (``:``) is what streams the worker's + # token events under the worker node, and the distinct per-superstep namespace + # keeps repeated delegations isolated without a fresh thread_id. + return {"messages": [HumanMessage(content=task)]} + + 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) + 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) + result = await worker_graph.ainvoke(_worker_input(task)) + return _tool_message_from(_last_message_content(result), call_id) + + return RunnableLambda( + func=_run_sync, + afunc=_run_async, + name=f"worker:{worker_node_name}", + ) + + +# ─── 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 / 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): + return payload, False + 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: + 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, +) -> 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/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index f1999aef..8619545e 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_composite_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, + cached by the rendered group-manager prompt. + + Such a graph runs over ``MessagesState`` and can't carry structured inputs to its + inner agents, so the node inputs are baked into the ``group_manager``'s + ``system_prompt`` and the now-satisfied input ports dropped, so declared == + inferred for the downstream span re-validation. A non-Agent group manager is + passed through unchanged so the converter raises its own clear error. + """ + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + + converter = AgentSpecToLangGraphConverter() + component = self.node.agent + if not isinstance(component, AgentSpecManagerWorkers): + raise TypeError( + "_create_composite_graph_with_given_input_values requires a ManagerWorkers" + ) + + entry_agent = component.group_manager + is_agent_entry = isinstance(entry_agent, AgentSpecAgent) + cache_key = ( + render_template(entry_agent.system_prompt, inputs) if is_agent_entry else component.id + ) + if cache_key not in self._agents_cache: + rendered = ( + component.model_copy( + update={ + "group_manager": entry_agent.model_copy( + update={"system_prompt": cache_key, "inputs": []} + ), + "inputs": [], + } + ) + if is_agent_entry + else component + ) + self._agents_cache[cache_key] = converter._manager_workers_convert_to_langgraph( + rendered, + 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: + # node inputs were baked into the group-manager's prompt, so the graph is + # driven by messages alone (not the agent's remaining_steps state). + graph = self._create_composite_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..130ad4de 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,24 @@ 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]: + """Outputs of the group manager; see :meth:`_get_inferred_inputs`.""" + 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..d38d0005 --- /dev/null +++ b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py @@ -0,0 +1,157 @@ +# 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_infers_outputs_from_group_manager() -> None: + """Symmetric with inputs: a ManagerWorkers exposes the group manager's outputs, + so a flow AgentNode wrapping it can wire its result downstream (or surface it as a + leaf).""" + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + cfg = OpenAiCompatibleConfig(name="m", model_id="fake", url="null") + answer = StringProperty(title="answer") + manager = Agent( + name="manager", + llm_config=cfg, + system_prompt="Answer the question.", + outputs=[answer], + ) + worker = Agent(name="worker", llm_config=cfg, system_prompt="You help.") + mw = ManagerWorkers(name="mw", group_manager=manager, workers=[worker]) + + assert [p.title for p in (mw.outputs or [])] == ["answer"] + + +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"] == "لماذا..." diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py new file mode 100644 index 00000000..769f92c6 --- /dev/null +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -0,0 +1,1343 @@ +# 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._managerworkers 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._managerworkers 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._managerworkers 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._managerworkers 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_sends_to_pending_delegation() -> None: + from langchain_core.messages import AIMessage + from langgraph.types import Send + + from pyagentspec.adapters.langgraph._managerworkers import ( + _DELEGATE_CALL_ID_KEY, + _DELEGATE_TASK_KEY, + _route_manager_to_worker_or_end, + ) + + delegating = AIMessage( + content="", + tool_calls=[{"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "c1"}], + ) + 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: + from langchain_core.messages import AIMessage + from langgraph.graph import END + + from pyagentspec.adapters.langgraph._managerworkers import ( + _route_manager_to_worker_or_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_fans_out_every_delegation() -> None: + from langchain_core.messages import AIMessage + from langgraph.types import Send + + from pyagentspec.adapters.langgraph._managerworkers import ( + _DELEGATE_CALL_ID_KEY, + _DELEGATE_TASK_KEY, + _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"}, + ], + ) + 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) ────────── + + +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 START + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.adapters.langgraph._managerworkers import ( + _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, + ) + from pyagentspec.adapters.langgraph._managerworkers import ( + _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._managerworkers 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 + + +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.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 + 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 ────────────────────────────────────────────────────── + + +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) + + +# ─── 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._managerworkers 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._managerworkers 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._managerworkers 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._managerworkers 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_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._managerworkers 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 pyagentspec.adapters.langgraph._managerworkers 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_delegation_filter_strips_delegate_from_invalid_tool_calls() -> None: + """A delegation call whose args failed to parse arrives in + ``invalid_tool_calls`` rather than ``tool_calls`` — it must still be + scrubbed so the consumer never sees the routing protocol.""" + from langchain_core.messages import AIMessage + + from pyagentspec.adapters.langgraph._managerworkers import _DelegationEventFilter + + f = _DelegationEventFilter() + msg = AIMessage( + content="", + invalid_tool_calls=[ + { + "name": "delegate_to_research_helper", + "args": "{bad", + "id": "call_1", + "error": "parse error", + "type": "invalid_tool_call", + } + ], + ) + 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"].invalid_tool_calls == [] + # The original (graph-state) message is never mutated. + assert ( + msg.invalid_tool_calls + and msg.invalid_tool_calls[0]["name"] == "delegate_to_research_helper" + ) + + +def test_delegation_filter_strips_provider_native_tool_calls_on_full_message() -> None: + """Some providers (e.g. OpenAI) carry the tool call only in + ``additional_kwargs['tool_calls']``; a delegation call there must be + stripped and its id recorded so the worker reply can later be dropped.""" + from langchain_core.messages import AIMessage, ToolMessage + + from pyagentspec.adapters.langgraph._managerworkers import _DelegationEventFilter + + f = _DelegationEventFilter() + msg = AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "function": {"name": "delegate_to_research_helper", "arguments": ""}, + "type": "function", + } + ] + }, + ) + out = f.scrub( + {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": msg}} + ) + assert out is not None + assert "tool_calls" not in (out["data"]["output"].additional_kwargs or {}) + + # Recording the id means the worker's reply ToolMessage is dropped too. + reply = ToolMessage(content="done", tool_call_id="call_1") + dropped = f.scrub( + { + "event": "on_chain_end", + "name": "worker:research_helper", + "run_id": "w", + "data": {"output": {"messages": [reply]}}, + } + ) + assert dropped is None + + +def test_delegation_filter_scrubs_input_payload_messages() -> None: + """The worker's reply ToolMessage must be dropped wherever it surfaces — + including a node's ``input`` payload, not only ``output`` / ``chunk``.""" + from langchain_core.messages import AIMessage, ToolMessage + + from pyagentspec.adapters.langgraph._managerworkers import _DelegationEventFilter + + f = _DelegationEventFilter() + delegate = AIMessage( + content="", + tool_calls=[{"name": "delegate_to_research_helper", "args": {"task": "x"}, "id": "call_1"}], + ) + f.scrub( + {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": delegate}} + ) + + reply = ToolMessage(content="done", tool_call_id="call_1") + out = f.scrub( + { + "event": "on_chain_start", + "name": "worker:research_helper", + "run_id": "w", + "data": {"input": {"messages": [reply]}}, + } + ) + # The only message was the delegate reply → payload empties → event dropped. + assert out is None + + +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._managerworkers 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._managerworkers 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" + + +# ─── Shared low-level helper unit tests (no LLM) ───────────────────────────── + + +def test_normalize_identifier_lowercases_collapses_and_strips() -> None: + """The single normalization used for both worker node names and + ``transfer_to_`` tool names.""" + from pyagentspec.adapters.langgraph._managerworkers import _normalize_identifier + + assert _normalize_identifier("Research Helper") == "research_helper" + assert _normalize_identifier("My-Worker!! v2") == "my_worker_v2" + # Punctuation-only / empty slugify to the empty string (callers add a fallback). + assert _normalize_identifier("!!!") == "" + assert _normalize_identifier("") == "" + + +def test_messages_of_reads_dict_and_object_state() -> None: + """The delegation tool receives state as a dict or an attribute-bearing + object depending on the langgraph injection path.""" + from langchain_core.messages import AIMessage + + from pyagentspec.adapters.langgraph._managerworkers import _messages_of + + msg = AIMessage(content="hi") + assert _messages_of({"messages": [msg]}) == [msg] + assert _messages_of({"messages": None}) == [] + assert _messages_of({}) == [] + + class _State: + messages = [msg] + + assert _messages_of(_State()) == [msg] + + class _Empty: + pass + + assert _messages_of(_Empty()) == [] + + +def test_surface_to_parent_command_projects_messages_with_no_goto() -> None: + """The placeholder tool's body: break to the parent graph, project the + subgraph messages, carry no ``goto`` (routing is the parent's job).""" + from langchain_core.messages import AIMessage + from langgraph.types import Command + + from pyagentspec.adapters.langgraph._managerworkers import _surface_to_parent_command + + m1, m2 = AIMessage(content="a"), AIMessage(content="b") + cmd = _surface_to_parent_command({"messages": [m1, m2]}) + + assert isinstance(cmd, Command) + assert cmd.graph == Command.PARENT + assert cmd.goto == () # no goto — the parent graph decides where to go + assert cmd.update == {"messages": [m1, m2]} + + +def test_delegation_tool_exposes_expected_name_and_description() -> None: + """The placeholder tool the manager's LLM addresses by name.""" + from pyagentspec.adapters.langgraph._managerworkers import _make_worker_delegation_tool + + delegate = _make_worker_delegation_tool("research_helper") + assert delegate.name == "delegate_to_research_helper" + assert "research_helper" in delegate.description + + +# ─── _wrap_worker_for_subgraph: pending-delegation extraction (no LLM) ──────── + + +def _echo_worker_graph(reply: str = "WORKER REPLY") -> Any: + """A trivial worker CompiledStateGraph whose only node returns a fixed + AIMessage — enough to exercise the wrapper without an LLM.""" + from langchain_core.messages import AIMessage + from langgraph.graph import END, START, MessagesState, StateGraph + + wb = StateGraph(MessagesState) + wb.add_node("agent", lambda state: {"messages": [AIMessage(content=reply)]}) + wb.add_edge(START, "agent") + wb.add_edge("agent", END) + return wb.compile() + + +def test_wrap_worker_uses_send_payload_task_and_call_id() -> None: + """Fan-out path: the routing edge's ``Send`` payload carries the task and + the originating tool_call_id directly, so the worker reply ToolMessage is + matched to that call.""" + from langchain_core.messages import ToolMessage + + from pyagentspec.adapters.langgraph._managerworkers import ( + _DELEGATE_CALL_ID_KEY, + _DELEGATE_TASK_KEY, + _wrap_worker_for_subgraph, + ) + + node = _wrap_worker_for_subgraph(_echo_worker_graph("DONE"), "research_helper") + out = node.invoke({_DELEGATE_TASK_KEY: "do it", _DELEGATE_CALL_ID_KEY: "call_9"}) + + (reply,) = out["messages"] + assert isinstance(reply, ToolMessage) + assert reply.content == "DONE" + assert reply.tool_call_id == "call_9" + + +def test_wrap_worker_recovers_task_from_manager_message_on_direct_edge() -> None: + """Direct-edge path (no Send payload): the task and call id are recovered + from the manager's last AIMessage delegation tool call.""" + from langchain_core.messages import AIMessage, ToolMessage + + from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph + + node = _wrap_worker_for_subgraph(_echo_worker_graph("ANSWER"), "research_helper") + manager_ai = AIMessage( + content="", + tool_calls=[{"name": "delegate_to_research_helper", "args": {"task": "T"}, "id": "c1"}], + ) + out = node.invoke({"messages": [manager_ai]}) + + (reply,) = out["messages"] + assert isinstance(reply, ToolMessage) + assert reply.content == "ANSWER" + assert reply.tool_call_id == "c1" + + +def test_wrap_worker_raises_on_empty_manager_state() -> None: + from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph + + node = _wrap_worker_for_subgraph(_echo_worker_graph(), "research_helper") + with pytest.raises(RuntimeError, match="empty manager state"): + node.invoke({"messages": []}) + + +def test_wrap_worker_raises_when_no_matching_delegation_call() -> None: + from langchain_core.messages import AIMessage + + from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph + + node = _wrap_worker_for_subgraph(_echo_worker_graph(), "research_helper") + not_for_me = AIMessage( + content="", + tool_calls=[{"name": "delegate_to_other", "args": {"task": "x"}, "id": "c1"}], + ) + with pytest.raises(RuntimeError, match="delegate_to_research_helper"): + node.invoke({"messages": [not_for_me]}) From 249333bb8e0466312740ef320f7e9ebc9da810f2 Mon Sep 17 00:00:00 2001 From: Salah Date: Sun, 26 Jul 2026 00:46:45 +0400 Subject: [PATCH 02/14] refactor(adapters/langgraph): dedupe execution-span patching, tidy ManagerWorkers Quality pass over the ManagerWorkers adapter. No behaviour change intended. Wrapping stream/astream in an execution span was already duplicated between the Agent and Flow paths, and ManagerWorkers added a third near-verbatim copy. Extract one `patch_with_execution_span` into `_execution_span.py`, parameterized by span and event factories, and route all three through it. The `except NotImplementedError` fallback ladder for the async span protocol was written 12 times; it is now one helper. Move the flow-step compile onto `AgentNodeExecutor` as `_create_manager_workers_with_given_input_values`, next to its react-agent twin. It was living in `_managerworkers.py` while importing the converter and taking the executor's private cache as a parameter. Replace the six per-worker closures in `_wrap_worker_for_subgraph` with module-level helpers and a `_WorkerSubgraphNode` holding only the graph, so a long-lived node no longer pins the whole factory frame. Memoize `_make_worker_delegation_tool`, which depends only on the node name yet re-ran the `@tool` decorator on every compile. In the converter, collapse the parallel worker list and dict into one list of pairs, hoist the repeated conversion kwargs, and use the module's existing lazy `langgraph_graph` instead of a local `MessagesState` import. Drop `is_delegation_event`: no callers, not exported, unreachable through any supported import path. `is_delegation_tool_name` stays. Tests: collapse six copies of the loader and double-patch stack into one `_load_with_fake_llms` helper, and drop assertions in the roster test that compared a locally computed string against itself. Add an `allow_llm_config_construction` fixture so tests that only build an LLM config and stub the conversion are not skipped by the blanket SKIP_LLM_TESTS guard. This un-skips the three flow-step tests that the guard was hiding. --- .../adapters/langgraph/_execution_span.py | 88 ++ .../adapters/langgraph/_langgraphconverter.py | 323 ++----- .../adapters/langgraph/_managerworkers.py | 654 +++------------ .../adapters/langgraph/_node_execution.py | 101 ++- pyagentspec/src/pyagentspec/managerworkers.py | 21 +- pyagentspec/tests/adapters/conftest.py | 37 + .../flows/test_managerworkers_node.py | 19 +- .../adapters/langgraph/test_managerworkers.py | 794 +++--------------- 8 files changed, 533 insertions(+), 1504 deletions(-) create mode 100644 pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py new file mode 100644 index 00000000..21817f46 --- /dev/null +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py @@ -0,0 +1,88 @@ +# Copyright © 2025, 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. + +"""Wrap a compiled graph's ``stream``/``astream`` in an Agent Spec execution span. + +Agent, Flow and ManagerWorkers graphs all need the same wrapper: open a span, emit a +Start event carrying the invocation inputs, replay the chunks the underlying stream +yields while remembering the last state chunk, then emit an End event built from that +final state. Only the span class and the two event payloads differ, so they come in +as factories. + +``invoke``/``ainvoke`` need no patch of their own; they go through ``stream``/ +``astream`` internally. +""" + +from typing import Any, AsyncGenerator, Callable, Dict, Generator + +from pyagentspec.adapters.langgraph._types import CompiledStateGraph + + +def _invocation_inputs(kwargs: Dict[str, Any]) -> Dict[str, Any]: + """The ``input=`` argument of the patched call, or ``{}`` when it isn't a dict.""" + inputs = kwargs.get("input", {}) + return inputs if isinstance(inputs, dict) else {} + + +def _final_state(chunk: Any, so_far: Any) -> Any: + """Fold one streamed chunk into the running "last state seen". + + State arrives as ``(namespace, state)`` tuples; other chunk shapes aren't + something to build the End event from, so they leave the fold untouched. + """ + return chunk[1] if isinstance(chunk, tuple) else so_far + + +async def _async_or_sync(async_call: Callable[..., Any], sync_call: Callable[..., Any], *args: Any): + """Await ``async_call``, falling back to ``sync_call`` for spans that don't + implement the async half of the tracing protocol.""" + try: + await async_call(*args) + except NotImplementedError: + sync_call(*args) + + +def patch_with_execution_span( + compiled_graph: CompiledStateGraph[Any, Any, Any], + *, + make_span: Callable[[], Any], + make_start_event: Callable[[Dict[str, Any]], Any], + make_end_event: Callable[[Dict[str, Any]], Any], +) -> None: + """Monkey-patch ``compiled_graph.stream`` / ``.astream`` to run inside a span. + + ``make_start_event`` receives the invocation inputs; ``make_end_event`` + receives the final state chunk (``{}`` when the run produced none). + """ + original_stream = compiled_graph.stream + original_astream = compiled_graph.astream + + def patched_stream(*args: Any, **kwargs: Any) -> Generator[Any, Any, None]: + with make_span() as span: + span.add_event(make_start_event(_invocation_inputs(kwargs))) + state: Any = {} + for chunk in original_stream(*args, **kwargs): + yield chunk + state = _final_state(chunk, state) + span.add_event(make_end_event(state if isinstance(state, dict) else {})) + + async def patched_astream(*args: Any, **kwargs: Any) -> AsyncGenerator[Any, Any]: + span = make_span() + await _async_or_sync(span.start_async, span.start) + try: + start_event = make_start_event(_invocation_inputs(kwargs)) + await _async_or_sync(span.add_event_async, span.add_event, start_event) + state: Any = {} + async for chunk in original_astream(*args, **kwargs): + yield chunk + state = _final_state(chunk, state) + end_event = make_end_event(state if isinstance(state, dict) else {}) + await _async_or_sync(span.add_event_async, span.add_event, end_event) + finally: + await _async_or_sync(span.end_async, span.end) + + compiled_graph.stream = patched_stream # type: ignore[assignment] + compiled_graph.astream = patched_astream # type: ignore[assignment] diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 4a09ce1b..59aee1d0 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -12,11 +12,9 @@ from typing import ( TYPE_CHECKING, Any, - AsyncGenerator, Awaitable, Callable, Dict, - Generator, List, Optional, Tuple, @@ -37,11 +35,11 @@ _build_type_from_schema, create_pydantic_model_from_properties, ) +from pyagentspec.adapters.langgraph._execution_span import patch_with_execution_span from pyagentspec.adapters.langgraph._managerworkers import ( _MANAGER_NODE_KEY, _append_workers_roster, _make_worker_delegation_tool, - _patch_hide_delegation_in_astream_events, _patch_with_manager_workers_execution_span, _route_manager_to_worker_or_end, _safe_node_name, @@ -80,6 +78,7 @@ AgentSpecToolCallbackHandler, ) from pyagentspec.agent import Agent as AgentSpecAgent +from pyagentspec.agenticcomponent import AgenticComponent as AgentSpecAgenticComponent from pyagentspec.flows.edges import ControlFlowEdge as AgentSpecControlFlowEdge from pyagentspec.flows.edges import DataFlowEdge as AgentSpecDataFlowEdge from pyagentspec.flows.flow import Flow as AgentSpecFlow @@ -495,87 +494,18 @@ def _find_property(properties: List[AgentSpecProperty], name: str) -> AgentSpecP "Prefer invoke/stream or upgrade to Python 3.11+ for ainvoke/astream." ) - # To enable flow execution traces monkey patch all the functions that invoke the compiled graph - - original_stream = compiled_graph.stream - - def patch_with_flow_execution_span(*args: Any, **kwargs: Any) -> Generator[Any, Any, None]: - span_name = f"FlowExecution[{flow.name}]" - inputs = kwargs.get("input", {}) - if not isinstance(inputs, dict): - inputs = {} - with AgentSpecFlowExecutionSpan(name=span_name, flow=flow) as span: - span.add_event(AgentSpecFlowExecutionStart(flow=flow, inputs=inputs)) - original_result: dict[str, Any] | Any = {} - result: dict[str, Any] - # This is going to patch stream and astream, that return iterators and yield chunks - for chunk in original_stream(*args, **kwargs): - yield chunk - if isinstance(chunk, tuple): - original_result = chunk[1] - if not isinstance(original_result, dict): - result = {} - else: - result = original_result - span.add_event( - AgentSpecFlowExecutionEnd( - flow=flow, - outputs=result.get("outputs", {}), - branch_selected=result.get("node_execution_details", {}).get("branch", ""), - ) - ) - - original_astream = compiled_graph.astream - - async def patch_async_with_flow_execution_span( - *args: Any, **kwargs: Any - ) -> AsyncGenerator[Any, Any]: - span_name = f"FlowExecution[{flow.name}]" - inputs = kwargs.get("input", {}) - if not isinstance(inputs, dict): - inputs = {} - span = AgentSpecFlowExecutionSpan(name=span_name, flow=flow) - try: - await span.start_async() - except NotImplementedError: - span.start() - try: - try: - await span.add_event_async( - AgentSpecFlowExecutionStart(flow=flow, inputs=inputs) - ) - except NotImplementedError: - span.add_event(AgentSpecFlowExecutionStart(flow=flow, inputs=inputs)) - original_result: dict[str, Any] | Any = {} - result: dict[str, Any] - # This is going to patch stream and astream, that return iterators and yield chunks - async for chunk in original_astream(*args, **kwargs): - yield chunk - if isinstance(chunk, tuple): - original_result = chunk[1] - if not isinstance(original_result, dict): - result = {} - else: - result = original_result - span_end_event = AgentSpecFlowExecutionEnd( - flow=flow, - outputs=result.get("outputs", {}), - branch_selected=result.get("node_execution_details", {}).get("branch", ""), - ) - try: - await span.add_event_async(span_end_event) - except NotImplementedError: - span.add_event(span_end_event) - finally: - try: - await span.end_async() - except NotImplementedError: - span.end() - - # Monkey patch invocation functions to inject tracing - # No need to patch `(a)invoke` as the internally use `(a)stream` - compiled_graph.stream = patch_with_flow_execution_span # type: ignore - compiled_graph.astream = patch_async_with_flow_execution_span # type: ignore + patch_with_execution_span( + compiled_graph, + make_span=lambda: AgentSpecFlowExecutionSpan( + name=f"FlowExecution[{flow.name}]", flow=flow + ), + make_start_event=lambda inputs: AgentSpecFlowExecutionStart(flow=flow, inputs=inputs), + make_end_event=lambda result: AgentSpecFlowExecutionEnd( + flow=flow, + outputs=result.get("outputs", {}), + branch_selected=result.get("node_execution_details", {}).get("branch", ""), + ), + ) return compiled_graph def _node_convert_to_langgraph( @@ -1156,77 +1086,61 @@ def _manager_workers_convert_to_langgraph( Topology:: - ┌─ delegate_to_w1 ─→ worker_1 ─┐ + ┌─ 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(...)``. + └─ delegate_to_w2 ─→ worker_2 ─┘ + │ + └─ no tool_call ─→ END + + The manager is a react-agent holding one synthetic ``delegate_to_`` + tool per worker. The parent graph's conditional edge inspects its last + AIMessage to pick the next node, and the worker node runs in an isolated + message context and answers with a ``ToolMessage`` matched to the pending + delegation id. + + Workers are converted recursively and wired in as subgraph nodes, so + ``astream_events`` still exposes the parent/child boundary + (``subgraph=True``) for tracing and SSE streaming. Workers that are + themselves ``ManagerWorkers`` compose 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. + # The manager has to decide which worker to delegate to, so it needs a + # chat-LLM that emits tool_calls. Only Agent (and its SpecializedAgent + # subclass) has that shape; a Flow, Swarm or nested ManagerWorkers gives + # us nothing to 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 + named_workers: List[Tuple[str, AgentSpecAgenticComponent]] = [ + (_safe_node_name(worker.name, fallback_id=worker.id), worker) for worker in mw.workers ] + worker_node_names = [node_name for node_name, _ in named_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, - middleware=middleware, - ) + conversion_kwargs: Dict[str, Any] = { + "tool_registry": tool_registry, + "converted_components": converted_components, + "checkpointer": checkpointer, + "config": config, + "middleware": middleware, + } - # 2. Render the workers roster into the manager's system prompt - # so the LLM knows which delegation tool maps to which worker. + # The roster tells the LLM 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) - ], + [(node_name, worker.description or "") for node_name, worker in named_workers], ) - # 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. + # The delegation tools do execute inside the react loop: their body returns a + # Command(graph=PARENT), which is how the call escapes the react subgraph so + # the conditional edge below can route on it. manager_graph = self._create_react_agent_with_given_info( name=manager_agent.name, system_prompt=rendered_prompt, @@ -1236,52 +1150,33 @@ def _manager_workers_convert_to_langgraph( 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, - middleware=middleware, - additional_langgraph_tools=delegation_tools, + additional_langgraph_tools=[ + _make_worker_delegation_tool(node_name) for node_name in worker_node_names + ], + **conversion_kwargs, ) - # 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 - 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), - ) + # Manager and workers all go in as compiled subgraph nodes, which is what + # makes LangGraph stream them with ``subgraph=True``. + builder = StateGraph(langgraph_graph.MessagesState) + builder.add_node(_MANAGER_NODE_KEY, manager_graph) + for node_name, worker in named_workers: + worker_graph = self.convert(worker, **conversion_kwargs) + builder.add_node(node_name, _wrap_worker_for_subgraph(worker_graph, node_name)) + builder.add_edge(node_name, _MANAGER_NODE_KEY) - # Path-map covers delegate-to-worker and the END branch so langgraph - # can statically validate the routing. - 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 - - builder.add_edge(langgraph_graph.START, manager_node_key) + builder.add_edge(langgraph_graph.START, _MANAGER_NODE_KEY) builder.add_conditional_edges( - manager_node_key, + _MANAGER_NODE_KEY, _route_manager_to_worker_or_end, - routing_path_map, + # The path map covers every worker plus END, so langgraph can validate + # the routing statically. + {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) - # 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( @@ -1374,81 +1269,17 @@ def _create_react_agent_with_given_info( **create_agent_kwargs ) - # To enable flow execution traces monkey patch all the functions that invoke the compiled graph - - original_stream = compiled_graph.stream - - def patch_with_agent_execution_span(*args: Any, **kwargs: Any) -> Generator[Any, Any, Any]: - span_name = f"AgentExecution[{agent.name}]" - inputs = kwargs.get("input", {}) - if not isinstance(inputs, dict): - inputs = {} - with AgentSpecAgentExecutionSpan(name=span_name, agent=agent) as span: - span.add_event(AgentSpecAgentExecutionStart(agent=agent, inputs=inputs)) - original_result: dict[str, Any] | Any = {} - result: dict[str, Any] - # This is going to patch stream and astream, that return iterators and yield chunks - for chunk in original_stream(*args, **kwargs): - yield chunk - if isinstance(chunk, tuple): - original_result = chunk[1] - if not isinstance(original_result, dict): - result = {} - else: - result = original_result - outputs = extract_outputs_from_invoke_result(result, agent.outputs or []) - span.add_event(AgentSpecAgentExecutionEnd(agent=agent, outputs=outputs)) - - original_astream = compiled_graph.astream - - async def patch_async_with_agent_execution_span( - *args: Any, **kwargs: Any - ) -> AsyncGenerator[Any, Any]: - span_name = f"AgentExecution[{agent.name}]" - inputs = kwargs.get("input", {}) - if not isinstance(inputs, dict): - inputs = {} - span = AgentSpecAgentExecutionSpan(name=span_name, agent=agent) - try: - await span.start_async() - except NotImplementedError: - span.start() - try: - try: - await span.add_event_async( - AgentSpecAgentExecutionStart(agent=agent, inputs=inputs) - ) - except NotImplementedError: - span.add_event(AgentSpecAgentExecutionStart(agent=agent, inputs=inputs)) - original_result: dict[str, Any] | Any = {} - result: dict[str, Any] - # This is going to patch stream and astream, that return iterators and yield chunks - async for chunk in original_astream(*args, **kwargs): - yield chunk - if isinstance(chunk, tuple): - original_result = chunk[1] - if not isinstance(original_result, dict): - result = {} - else: - result = original_result - - outputs = extract_outputs_from_invoke_result(result, agent.outputs or []) - try: - await span.add_event_async( - AgentSpecAgentExecutionEnd(agent=agent, outputs=outputs) - ) - except NotImplementedError: - span.add_event(AgentSpecAgentExecutionEnd(agent=agent, outputs=outputs)) - finally: - try: - await span.end_async() - except NotImplementedError: - span.end() - - # Monkey patch invocation functions to inject tracing - # No need to patch `(a)invoke` as they internally use `(a)stream` - compiled_graph.stream = patch_with_agent_execution_span # type: ignore - compiled_graph.astream = patch_async_with_agent_execution_span # type: ignore + patch_with_execution_span( + compiled_graph, + make_span=lambda: AgentSpecAgentExecutionSpan( + name=f"AgentExecution[{agent.name}]", agent=agent + ), + make_start_event=lambda inputs: AgentSpecAgentExecutionStart(agent=agent, inputs=inputs), + make_end_event=lambda result: AgentSpecAgentExecutionEnd( + agent=agent, + outputs=extract_outputs_from_invoke_result(result, agent.outputs or []), + ), + ) return compiled_graph def _agent_convert_to_langgraph( diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py index d63d24db..971fb2ae 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py @@ -4,19 +4,22 @@ # (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. -"""ManagerWorkers LangGraph compilation helpers. +"""Helpers for compiling a ``ManagerWorkers`` into LangGraph. -Module-level building blocks for compiling a ``ManagerWorkers`` into LangGraph. -The ``AgentSpecToLangGraphConverter`` method -``_manager_workers_convert_to_langgraph`` orchestrates these helpers; the -helpers themselves are pure functions with no dependency on the converter, -which is why they live here rather than bloating the converter module. +``AgentSpecToLangGraphConverter._manager_workers_convert_to_langgraph`` orchestrates +these; they live here to keep the converter module from growing further. + +Nothing hides the routing protocol. ``delegate_to_`` calls stream like any +other tool call, because which worker got which task is usually the most useful thing +a run reports. Consumers that would rather not render it can filter on +:func:`is_delegation_tool_name`. """ -import logging import re -from typing import Any, AsyncGenerator, Dict, Generator, List, Optional, Tuple +from functools import lru_cache +from typing import Any, Dict, List, Tuple +from pyagentspec.adapters.langgraph._execution_span import patch_with_execution_span from pyagentspec.adapters.langgraph._types import CompiledStateGraph, langgraph_graph from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers from pyagentspec.tracing.events import ( @@ -29,76 +32,65 @@ ManagerWorkersExecutionSpan as AgentSpecManagerWorkersExecutionSpan, ) -# ─── 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_]). +# Cannot collide with a normalized worker node name, which is always [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_" +#: Prefix the manager's LLM uses to address a delegation tool, suffixed with the +#: normalized worker node name. Public so consumers can recognize the protocol. +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. +# Carried on the per-delegation ``Send`` payload so a worker run knows its task and +# which ``tool_call_id`` its reply must answer. Routing per delegation instead of off +# shared state lets one manager turn delegate to several workers at once. _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+") +def is_delegation_tool_name(name: Any) -> bool: + """True for the synthetic ``delegate_to_`` tool names a manager emits.""" + return isinstance(name, str) and name.startswith(DELEGATE_TOOL_PREFIX) + + def _normalize_identifier(s: str) -> str: - """Lowercase, collapse non-alphanumerics to underscores, strip surrounding - underscores. The single source of truth for turning a spec name into an - ASCII identifier, so a worker node name and the ``delegate_to_`` - tool name addressing it always agree.""" - return re.sub(r"[^a-z0-9]+", "_", (s or "").lower()).strip("_") + """Lowercase, collapse non-alphanumerics to underscores, strip leading/trailing ones.""" + return re.sub(r"[^a-z0-9]+", "_", s.lower()).strip("_") 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 normalize via - :func:`_normalize_identifier`, 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. + The LLM sees ``delegate_to_`` as a tool name and has to emit it + reliably, so node names stay ASCII identifiers. Falls back to the component id, + normalized the same way, when the name slugifies to nothing. """ return _normalize_identifier(name) or _normalize_identifier(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).""" + """Read ``key`` off a tool call, which langchain emits as a dict or an object + depending on the message source.""" if isinstance(tool_call, dict): return tool_call.get(key) return getattr(tool_call, key, None) def _messages_of(state: Any) -> List[Any]: - """Read the ``messages`` list off a state that may be a dict or an - attribute-bearing object (langgraph injects either into a tool).""" + """Read ``messages`` off a state, which langgraph injects as a dict or an object.""" if isinstance(state, dict): return list(state.get("messages") or []) return list(getattr(state, "messages", []) or []) def _surface_to_parent_command(state: Any) -> Any: - """The delegation tool's body: break out of the manager's react loop and - project the subgraph's messages — including the AIMessage carrying the - triggering tool call — onto the PARENT state, carrying **no** ``goto`` - (routing is the parent graph's job). The ``add_messages`` reducer dedupes - by id, so re-surfacing existing messages is a no-op. Modelled on - ``langgraph_swarm.create_handoff_tool``.""" + """Break out of the manager's react loop, projecting the subgraph's messages onto + the parent state (including the AIMessage carrying the triggering tool call). + + Carries no ``goto``: routing is the parent graph's job. The ``add_messages`` + reducer dedupes by id, so re-surfacing existing messages is a no-op. Modelled on + ``langgraph_swarm.create_handoff_tool``. + """ from langgraph.types import Command return Command(graph=Command.PARENT, update={"messages": _messages_of(state)}) @@ -108,12 +100,10 @@ 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. + """Append an ``Available workers:`` block listing ``- : ``. - 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. + Descriptions are flattened to one line each, since the LLM routes off the block's + one-line-per-worker shape. """ if not entries: return system_prompt @@ -124,12 +114,19 @@ def _append_workers_roster( return f"{system_prompt}\n\n{roster}" if system_prompt else roster +@lru_cache(maxsize=256) def _make_worker_delegation_tool(worker_node_name: str) -> Any: """Build the ``delegate_to_`` tool the manager's LLM emits to route to a - worker. The body carries **no** ``goto`` — routing fans out one ``Send`` per - delegation (:func:`_route_manager_to_worker_or_end`); a ``goto`` here would - collapse multiple same-turn delegations into one parent Command, leaving the other + worker. + + The body carries no ``goto``. Routing fans out one ``Send`` per delegation in + :func:`_route_manager_to_worker_or_end`; a ``goto`` here would collapse several + same-turn delegations into one parent Command and leave the other ``tool_call_id``s unanswered. + + Memoized because the tool depends only on the node name and holds no per-graph + state. Without it every compile re-runs the ``@tool`` decorator, which costs a + ``get_type_hints`` pass and a pydantic args-schema build. """ from typing import Annotated @@ -137,41 +134,35 @@ def _make_worker_delegation_tool(worker_node_name: str) -> Any: from langgraph.prebuilt import InjectedState from langgraph.types import Command - tool_name = f"{_DELEGATE_TOOL_PREFIX}{worker_node_name}" + tool_name = f"{DELEGATE_TOOL_PREFIX}{worker_node_name}" + 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." + ) - @tool(tool_name) + @tool(tool_name, description=description) 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. - """ - del task, tool_call_id # recovered from the surfaced AIMessage by the routing edge + # Declared for the LLM-facing schema but unused here: executing is only how the + # call escapes the react subgraph, and the routing edge recovers both off the + # surfaced AIMessage's tool_calls. + del task, tool_call_id return _surface_to_parent_command(state) - _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]) -> Any: - """Inspect the manager's last AIMessage and route the parent graph: one - ``Send`` per ``delegate_to_`` tool call, or ``END`` when the manager - emitted none. - - A single manager turn may emit several ``delegate_to_`` calls; each gets its - own ``Send`` carrying the ``task`` + ``tool_call_id``, so every call is answered - independently (an unanswered delegation breaks the manager's next-turn - tool-call/result sequence). Multiple ``Send``s to one worker run independently; plain - tool calls already ran inside the manager's react loop. + """Route the parent graph off the manager's last AIMessage: one ``Send`` per + ``delegate_to_`` tool call, or ``END`` when it emitted none. + + Every delegation gets its own ``Send`` carrying the task and ``tool_call_id``, so + each is answered independently. An unanswered one breaks the manager's next-turn + tool-call/result sequence. Plain tool calls already ran inside the react loop. """ from langgraph.types import Send @@ -183,11 +174,11 @@ def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: sends = [] for tc in tool_calls: name = _tc_get(tc, "name") - if _is_delegate_name(name): + if is_delegation_tool_name(name): args = _tc_get(tc, "args") or {} sends.append( Send( - name[len(_DELEGATE_TOOL_PREFIX) :], + name[len(DELEGATE_TOOL_PREFIX) :], { _DELEGATE_TASK_KEY: args.get("task") or "", _DELEGATE_CALL_ID_KEY: _tc_get(tc, "id") or "", @@ -197,465 +188,86 @@ def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: return sends or 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``. +def _worker_input(state: Dict[str, Any]) -> Dict[str, Any]: + """The single-message context a worker run starts from. + + Passes no explicit config, so the worker inherits this node's ambient run config. + Its ``checkpoint_ns`` (``:``) streams the worker's token + events under the worker node, and the per-superstep namespace keeps repeated + delegations isolated without a fresh thread_id. """ - from langchain_core.messages import HumanMessage, ToolMessage + from langchain_core.messages import HumanMessage - from pyagentspec.adapters.langgraph._types import RunnableLambda + return {"messages": [HumanMessage(content=state.get(_DELEGATE_TASK_KEY) or "")]} - 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(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(tc, "name") == 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 = _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]: - return {"messages": [ToolMessage(content=reply, tool_call_id=call_id)]} - - def _worker_input(task: str) -> Dict[str, Any]: - # Pass NO explicit config so the worker inherits this node's ambient run config: - # its ``checkpoint_ns`` (``:``) is what streams the worker's - # token events under the worker node, and the distinct per-superstep namespace - # keeps repeated delegations isolated without a fresh thread_id. - return {"messages": [HumanMessage(content=task)]} - - 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) - 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) - result = await worker_graph.ainvoke(_worker_input(task)) - 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 _worker_reply(state: Dict[str, Any], result: Any) -> Dict[str, Any]: + """The worker's last message, as a ToolMessage answering this delegation.""" + from langchain_core.messages import ToolMessage -# ─── ManagerWorkers: hide the delegation protocol from astream_events ───────── + messages = result.get("messages") if isinstance(result, dict) else None + content = (getattr(messages[-1], "content", "") if messages else "") or "" + return { + "messages": [ + ToolMessage(content=content, tool_call_id=state.get(_DELEGATE_CALL_ID_KEY) or "") + ] + } -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) +class _WorkerSubgraphNode: + """Runs one worker subgraph as a node of the ManagerWorkers parent graph. + Hierarchical rather than shared-state like a Swarm: workers never see each other's + messages, and each run is handed only the manager's chosen task. The worker's last + message comes back as the ToolMessage content, so the manager's react loop sees a + well-formed tool response on its next turn. -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 - ) + A class rather than a closure, so the long-lived node holds only the graph instead + of keeping a whole factory frame alive. + """ + __slots__ = ("_graph",) -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 / 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): - return payload, False - 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: - 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, worker_graph: CompiledStateGraph[Any, Any, Any]) -> None: + self._graph = worker_graph - 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. + def run(self, state: Dict[str, Any]) -> Dict[str, Any]: + return _worker_reply(state, self._graph.invoke(_worker_input(state))) + + async def arun(self, state: Dict[str, Any]) -> Dict[str, Any]: + return _worker_reply(state, await self._graph.ainvoke(_worker_input(state))) + + +def _wrap_worker_for_subgraph( + worker_graph: CompiledStateGraph[Any, Any, Any], + worker_node_name: str, +) -> Any: + """Wrap a worker subgraph as a node exposing sync and async entrypoints. + + LangGraph picks between them depending on whether the parent graph was invoked + via ``invoke`` or ``ainvoke``. """ - 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 + from pyagentspec.adapters.langgraph._types import RunnableLambda - compiled_graph.astream_events = patched_astream_events # type: ignore[assignment] + node = _WorkerSubgraphNode(worker_graph) + return RunnableLambda(func=node.run, afunc=node.arun, 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] + """Wrap ``stream``/``astream`` so each run emits a ``ManagerWorkersExecutionSpan``, + using the same patcher as the Agent and Flow graphs.""" + patch_with_execution_span( + compiled_graph, + make_span=lambda: AgentSpecManagerWorkersExecutionSpan( + name=f"ManagerWorkersExecution[{mw.name}]", managerworkers=mw + ), + make_start_event=lambda inputs: AgentSpecManagerWorkersExecutionStart( + managerworkers=mw, inputs=inputs + ), + make_end_event=lambda result: AgentSpecManagerWorkersExecutionEnd( + managerworkers=mw, outputs={"messages": result.get("messages", [])} + ), + ) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index 8619545e..f8fb156e 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -500,6 +500,16 @@ def __init__( self._middleware: List[Any] = list(middleware or []) self._agents_cache: Dict[str, CompiledStateGraph[Any, Any]] = {} + def _conversion_kwargs(self) -> Dict[str, Any]: + """The converter arguments every compile from this executor passes through.""" + return { + "tool_registry": self.tool_registry, + "converted_components": self.converted_components, + "checkpointer": self.checkpointer, + "config": self.config, + "middleware": self._middleware, + } + def _create_react_agent_with_given_input_values( self, inputs: Dict[str, Any] ) -> CompiledStateGraph[Any, Any]: @@ -522,64 +532,49 @@ def _create_react_agent_with_given_input_values( toolboxes=agentspec_component.toolboxes, inputs=agentspec_component.inputs or [], outputs=agentspec_component.outputs or [], - tool_registry=self.tool_registry, - converted_components=self.converted_components, - checkpointer=self.checkpointer, - config=self.config, - middleware=self._middleware, + **self._conversion_kwargs(), ) return self._agents_cache[system_prompt] - def _create_composite_graph_with_given_input_values( - self, inputs: Dict[str, Any] + def _create_manager_workers_with_given_input_values( + self, component: AgentSpecManagerWorkers, inputs: Dict[str, Any] ) -> CompiledStateGraph[Any, Any]: - """Compile the node's ``ManagerWorkers`` into a runnable graph for these inputs, - cached by the rendered group-manager prompt. - - Such a graph runs over ``MessagesState`` and can't carry structured inputs to its - inner agents, so the node inputs are baked into the ``group_manager``'s - ``system_prompt`` and the now-satisfied input ports dropped, so declared == - inferred for the downstream span re-validation. A non-Agent group manager is - passed through unchanged so the converter raises its own clear error. + """Compile a ``ManagerWorkers`` that this node runs as a flow step. + + The graph runs over ``MessagesState``, which can't carry structured inputs + inward to the group manager, so the node inputs are rendered into its + ``system_prompt`` and the satisfied ports dropped from both the manager and + the component. That keeps declared and inferred ports equal for the + downstream span re-validation, and the graph runs on messages alone. + + Cached by rendered prompt, the same key + :meth:`_create_react_agent_with_given_input_values` uses. """ - from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, - ) + from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter converter = AgentSpecToLangGraphConverter() - component = self.node.agent - if not isinstance(component, AgentSpecManagerWorkers): - raise TypeError( - "_create_composite_graph_with_given_input_values requires a ManagerWorkers" + entry_agent = component.group_manager + if not isinstance(entry_agent, AgentSpecAgent): + # Not routable. Nothing to render or cache, and the converter owns the + # error message for this case. + return converter._manager_workers_convert_to_langgraph( + component, **self._conversion_kwargs() ) - entry_agent = component.group_manager - is_agent_entry = isinstance(entry_agent, AgentSpecAgent) - cache_key = ( - render_template(entry_agent.system_prompt, inputs) if is_agent_entry else component.id - ) - if cache_key not in self._agents_cache: - rendered = ( - component.model_copy( - update={ - "group_manager": entry_agent.model_copy( - update={"system_prompt": cache_key, "inputs": []} - ), - "inputs": [], - } - ) - if is_agent_entry - else component + system_prompt = render_template(entry_agent.system_prompt, inputs) + if system_prompt not in self._agents_cache: + rendered = component.model_copy( + update={ + "group_manager": entry_agent.model_copy( + update={"system_prompt": system_prompt, "inputs": []} + ), + "inputs": [], + } ) - self._agents_cache[cache_key] = converter._manager_workers_convert_to_langgraph( - rendered, - tool_registry=self.tool_registry, - converted_components=self.converted_components, - checkpointer=self.checkpointer, - config=self.config, - middleware=self._middleware, + self._agents_cache[system_prompt] = converter._manager_workers_convert_to_langgraph( + rendered, **self._conversion_kwargs() ) - return self._agents_cache[cache_key] + return self._agents_cache[system_prompt] def _prepare_agent_and_inputs( self, inputs: Dict[str, Any], messages: Messages @@ -590,11 +585,13 @@ def _prepare_agent_and_inputs( # 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: - # node inputs were baked into the group-manager's prompt, so the graph is - # driven by messages alone (not the agent's remaining_steps state). - graph = self._create_composite_graph_with_given_input_values(inputs) + agentspec_component = self.node.agent + if isinstance(agentspec_component, AgentSpecManagerWorkers): + # Inputs were baked into the group-manager's prompt, so this graph runs on + # messages alone rather than the agent's remaining_steps state. + graph = self._create_manager_workers_with_given_input_values( + agentspec_component, inputs + ) return graph, {"messages": messages} agent = self._create_react_agent_with_given_input_values(inputs) inputs |= { diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index 130ad4de..8b4c3b32 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -69,20 +69,21 @@ class ManagerWorkers(AgenticComponent): 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. + The group manager drives the conversation and is the component whose prompt the + runtime renders, so the group accepts exactly the inputs the manager accepts + (for an ``Agent`` manager, its ``{{placeholder}}`` inputs). The base default + infers none, which would leave a ``ManagerWorkers`` used as a flow ``AgentNode`` + with no input ports for a data-flow edge to resolve against. + + The ``hasattr`` guard matches :meth:`Flow._get_inferred_inputs` and + :meth:`AgentNode._get_inferred_inputs`: error-accumulating validators can run + this against a partially-constructed model with no ``group_manager`` assigned. """ - group_manager = getattr(self, "group_manager", None) - return list(getattr(group_manager, "inputs", None) or []) + return (self.group_manager.inputs or []) if hasattr(self, "group_manager") else [] def _get_inferred_outputs(self) -> List[Property]: """Outputs of the group manager; see :meth:`_get_inferred_inputs`.""" - group_manager = getattr(self, "group_manager", None) - return list(getattr(group_manager, "outputs", None) or []) + return (self.group_manager.outputs or []) if hasattr(self, "group_manager") else [] @model_validator_with_error_accumulation def _validate_one_or_more_workers(self) -> Self: diff --git a/pyagentspec/tests/adapters/conftest.py b/pyagentspec/tests/adapters/conftest.py index d5732212..b999abc7 100644 --- a/pyagentspec/tests/adapters/conftest.py +++ b/pyagentspec/tests/adapters/conftest.py @@ -6,6 +6,8 @@ import os import ssl +from contextlib import ExitStack +from importlib import import_module from pathlib import Path from typing import Any from unittest.mock import patch @@ -112,6 +114,41 @@ def _skip(*_args, **_kwargs): p.stop() +def _resolve(dotted: str) -> Any: + module_path, _, attr = dotted.rpartition(".") + module_path, _, cls_name = module_path.rpartition(".") + return getattr(getattr(import_module(module_path), cls_name), attr) + + +# Captured at conftest import, before any session fixture starts patching, so these +# are the genuine constructors rather than a skip stub. +_REAL_LLM_INITS = {dotted: _resolve(dotted) for dotted in LLM_MOCKED_METHODS} + + +@pytest.fixture +def allow_llm_config_construction(): + """Opt out of the blanket ``SKIP_LLM_TESTS=1`` construction guard. + + That guard skips a test the moment it constructs an LLM config. Right for tests + that go on to call a model, wrong for tests that only need a config object and + stub the conversion: those should run offline, and instead they skip silently in + CI, leaving the code path they cover unverified. + + Restores the real constructors for one test, overriding the guards in both this + conftest and ``tests/conftest.py``. + + Only request this from a test that provably never reaches a model endpoint. + """ + if not should_skip_llm_test(): + # Nothing patched the constructors, so there is nothing to restore. + yield + return + with ExitStack() as stack: + for dotted, real in _REAL_LLM_INITS.items(): + stack.enter_context(patch(dotted, new=real)) + yield + + @pytest.fixture(scope="package") def json_server(json_server_port: int): api_server = Path(__file__).parent / "api_server.py" diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py index d38d0005..35ea5e5a 100644 --- a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py +++ b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py @@ -15,11 +15,20 @@ manager's prompt and returning its result. """ +import pytest + from pyagentspec.agent import Agent from pyagentspec.managerworkers import ManagerWorkers from pyagentspec.property import StringProperty +@pytest.fixture(autouse=True) +def _offline(allow_llm_config_construction: None) -> None: + """These tests only need an LLM *config* object: the two inference tests never + convert at all, and the flow-step test stubs the chat model. Without this the + SKIP_LLM_TESTS guard skips all three and the flow-step path goes unverified.""" + + 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"} @@ -58,12 +67,12 @@ def test_managerworkers_infers_outputs_from_group_manager() -> None: def test_managerworkers_runs_as_a_flow_step_with_data_edge_inputs() -> None: - """A ManagerWorkers flow step loads (data edge resolves) and executes offline. + """A ManagerWorkers flow step loads with its data edge resolved, and executes. - 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. + The model is stubbed, so there is no delegation: the manager produces a final + message and routes straight to END. Loading proves the manager node exposes the + ``joke`` input the data edge targets; running proves the manager's answer comes + back as the node's single string output. """ from unittest.mock import patch diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index 769f92c6..0f9f28d2 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -21,6 +21,13 @@ # ─── Shared helpers ────────────────────────────────────────────────────────── +@pytest.fixture(autouse=True) +def _offline(allow_llm_config_construction: None) -> None: + """Every test in this module stubs the chat model (``_fake_manager`` or an + explicitly patched ``_llm_convert_to_langgraph``) and never reaches an + endpoint, so the SKIP_LLM_TESTS construction guard would only hide them.""" + + def _llm_cfg(name: str) -> Any: from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig @@ -39,6 +46,39 @@ class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): return _FakeModel(responses=list(ai_responses)) +def _load_with_fake_llms(mw: Any, default: Any = None, **fakes_by_llm_name: Any) -> Any: + """Compile ``mw`` offline, answering each LLM config with a queued fake. + + Keys are ``llm_config.name``; ``default`` answers any config not named. + + ``create_agent`` calls ``model.bind_tools(...)``, and ``FakeMessagesListChatModel`` + inherits ``bind_tools`` from the real ``ChatOpenAI``, which calls out to OpenAI. + Binding is stubbed to return the same fake, preserving its response queue. + """ + 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 + + def _dispatch(_self: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: + fake = fakes_by_llm_name.get(llm_config.name, default) + if fake is None: + raise AssertionError(f"unexpected llm_config: {llm_config.name}") + return fake + + 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 + ): + return loader.load_component(mw) + + # ─── Pure-helper unit tests (no LLM) ──────────────────────────────────────── @@ -161,13 +201,8 @@ def test_route_manager_to_worker_or_end_fans_out_every_delegation() -> None: 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 START - from pyagentspec.adapters.langgraph import AgentSpecLoader - from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, - ) from pyagentspec.adapters.langgraph._managerworkers import ( _MANAGER_NODE_KEY, ) @@ -198,15 +233,8 @@ def test_manager_workers_compiles_to_hierarchical_graph_topology() -> None: 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) + compiled = _load_with_fake_llms(mw, default=_fake_manager(AIMessage(content="Done."))) - # 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 @@ -218,23 +246,19 @@ def test_manager_workers_compiles_to_hierarchical_graph_topology() -> None: 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. + # Manager → worker is a conditional edge, and branches live separately from + # plain edges 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. +def test_manager_workers_registers_a_delegation_tool_per_worker() -> None: + """Each worker gets a ``delegate_to_`` tool on the manager, matching the + ``Available workers:`` roster the converter renders into its system prompt (the + roster text itself is covered by the ``_append_workers_roster`` unit tests). """ 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.adapters.langgraph._managerworkers import ( _MANAGER_NODE_KEY, ) @@ -259,33 +283,11 @@ def test_manager_workers_renders_workers_roster_into_manager_prompt() -> None: 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) + compiled = _load_with_fake_llms(mw, default=_fake_manager(AIMessage(content="Done."))) - # 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. + # The manager react-agent is itself a subgraph; the delegation tool the roster + # advertises is registered on its tools node, so the LLM has the matching contract. 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._managerworkers 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 @@ -294,20 +296,15 @@ def test_manager_workers_renders_workers_roster_into_manager_prompt() -> None: 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.""" + """End to end, the path that proves subgraph composition works. + + The manager emits a delegate_to_ call, the parent graph routes to the + worker subgraph in an isolated message context, the worker's answer comes back + as a ToolMessage matched to the pending tool_call_id, and the manager's next + turn terminates the graph. + """ 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 @@ -347,62 +344,29 @@ def test_manager_workers_delegates_and_routes_back_with_tool_message() -> None: # 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, + compiled = _load_with_fake_llms( + mw, + manager_llm=_fake_manager(*manager_responses), + worker_llm=_fake_manager(*worker_responses), ) - 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. + # Sync invocation only. FakeMessagesListChatModel overrides ``_generate`` but not + # ``_agenerate``, so the async path would resolve up the MRO to the real + # ``ChatOpenAI._agenerate`` and call OpenAI. 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. + # Matching the pending delegation 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 @@ -414,21 +378,12 @@ def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: 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. + Before the fix the parent graph routed only the first delegation and left the + other tool_call_ids unanswered. That is an invalid tool-call/tool-result + sequence, and the manager hallucinated the missing replies. """ - 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 - from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, - ) from pyagentspec.agent import Agent from pyagentspec.managerworkers import ManagerWorkers @@ -462,28 +417,11 @@ def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: # 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) + compiled = _load_with_fake_llms( + mw, + manager_llm=_fake_manager(*manager_responses), + worker_llm=_fake_manager(*worker_responses), + ) result = compiled.invoke( {"messages": [HumanMessage(content="Write 3 poems via sub-agents.")]}, @@ -516,16 +454,10 @@ def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: 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.""" + """A worker that is itself a ManagerWorkers compiles through the same dispatch, + becoming a CompiledStateGraph 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 @@ -558,29 +490,23 @@ def test_nested_manager_workers_compiles_recursively() -> None: 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) + compiled = _load_with_fake_llms(outer_mw, default=_fake_manager(AIMessage(content="Done."))) # 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.""" + """group_manager must be an Agent. Pyagentspec accepts any AgenticComponent, but + the adapter needs a chat-LLM emitting tool_calls to decide where to delegate.""" 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. + # A nested ManagerWorkers as group_manager: valid per the pyagentspec + # validators, unsupported here. leaf = Agent( name="Leaf", description="L", @@ -642,394 +568,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._managerworkers 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._managerworkers 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._managerworkers 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._managerworkers 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_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._managerworkers 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 pyagentspec.adapters.langgraph._managerworkers 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_delegation_filter_strips_delegate_from_invalid_tool_calls() -> None: - """A delegation call whose args failed to parse arrives in - ``invalid_tool_calls`` rather than ``tool_calls`` — it must still be - scrubbed so the consumer never sees the routing protocol.""" - from langchain_core.messages import AIMessage - - from pyagentspec.adapters.langgraph._managerworkers import _DelegationEventFilter - - f = _DelegationEventFilter() - msg = AIMessage( - content="", - invalid_tool_calls=[ - { - "name": "delegate_to_research_helper", - "args": "{bad", - "id": "call_1", - "error": "parse error", - "type": "invalid_tool_call", - } - ], - ) - 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"].invalid_tool_calls == [] - # The original (graph-state) message is never mutated. - assert ( - msg.invalid_tool_calls - and msg.invalid_tool_calls[0]["name"] == "delegate_to_research_helper" - ) - - -def test_delegation_filter_strips_provider_native_tool_calls_on_full_message() -> None: - """Some providers (e.g. OpenAI) carry the tool call only in - ``additional_kwargs['tool_calls']``; a delegation call there must be - stripped and its id recorded so the worker reply can later be dropped.""" - from langchain_core.messages import AIMessage, ToolMessage - - from pyagentspec.adapters.langgraph._managerworkers import _DelegationEventFilter - - f = _DelegationEventFilter() - msg = AIMessage( - content="", - additional_kwargs={ - "tool_calls": [ - { - "index": 0, - "id": "call_1", - "function": {"name": "delegate_to_research_helper", "arguments": ""}, - "type": "function", - } - ] - }, - ) - out = f.scrub( - {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": msg}} - ) - assert out is not None - assert "tool_calls" not in (out["data"]["output"].additional_kwargs or {}) - - # Recording the id means the worker's reply ToolMessage is dropped too. - reply = ToolMessage(content="done", tool_call_id="call_1") - dropped = f.scrub( - { - "event": "on_chain_end", - "name": "worker:research_helper", - "run_id": "w", - "data": {"output": {"messages": [reply]}}, - } - ) - assert dropped is None - - -def test_delegation_filter_scrubs_input_payload_messages() -> None: - """The worker's reply ToolMessage must be dropped wherever it surfaces — - including a node's ``input`` payload, not only ``output`` / ``chunk``.""" - from langchain_core.messages import AIMessage, ToolMessage - - from pyagentspec.adapters.langgraph._managerworkers import _DelegationEventFilter - - f = _DelegationEventFilter() - delegate = AIMessage( - content="", - tool_calls=[{"name": "delegate_to_research_helper", "args": {"task": "x"}, "id": "call_1"}], - ) - f.scrub( - {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": delegate}} - ) - - reply = ToolMessage(content="done", tool_call_id="call_1") - out = f.scrub( - { - "event": "on_chain_start", - "name": "worker:research_helper", - "run_id": "w", - "data": {"input": {"messages": [reply]}}, - } - ) - # The only message was the delegate reply → payload empties → event dropped. - assert out is None - - 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 @@ -1106,100 +644,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._managerworkers 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" - - # ─── Shared low-level helper unit tests (no LLM) ───────────────────────────── @@ -1251,7 +695,7 @@ def test_surface_to_parent_command_projects_messages_with_no_goto() -> None: assert isinstance(cmd, Command) assert cmd.graph == Command.PARENT - assert cmd.goto == () # no goto — the parent graph decides where to go + assert cmd.goto == () # no goto; the parent graph decides where to go assert cmd.update == {"messages": [m1, m2]} @@ -1268,8 +712,8 @@ def test_delegation_tool_exposes_expected_name_and_description() -> None: def _echo_worker_graph(reply: str = "WORKER REPLY") -> Any: - """A trivial worker CompiledStateGraph whose only node returns a fixed - AIMessage — enough to exercise the wrapper without an LLM.""" + """A worker CompiledStateGraph whose only node returns a fixed AIMessage, enough + to exercise the wrapper without an LLM.""" from langchain_core.messages import AIMessage from langgraph.graph import END, START, MessagesState, StateGraph @@ -1301,43 +745,53 @@ def test_wrap_worker_uses_send_payload_task_and_call_id() -> None: assert reply.tool_call_id == "call_9" -def test_wrap_worker_recovers_task_from_manager_message_on_direct_edge() -> None: - """Direct-edge path (no Send payload): the task and call id are recovered - from the manager's last AIMessage delegation tool call.""" - from langchain_core.messages import AIMessage, ToolMessage +# ─── Delegation visibility: the public consumer-side filter ────────────────── - from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph - node = _wrap_worker_for_subgraph(_echo_worker_graph("ANSWER"), "research_helper") - manager_ai = AIMessage( - content="", - tool_calls=[{"name": "delegate_to_research_helper", "args": {"task": "T"}, "id": "c1"}], +def test_is_delegation_tool_name_matches_only_the_synthetic_prefix() -> None: + from pyagentspec.adapters.langgraph._managerworkers import ( + DELEGATE_TOOL_PREFIX, + is_delegation_tool_name, ) - out = node.invoke({"messages": [manager_ai]}) - - (reply,) = out["messages"] - assert isinstance(reply, ToolMessage) - assert reply.content == "ANSWER" - assert reply.tool_call_id == "c1" + assert DELEGATE_TOOL_PREFIX == "delegate_to_" + assert is_delegation_tool_name("delegate_to_research_helper") + assert not is_delegation_tool_name("get_weather") + # A real tool merely *containing* the prefix mid-name is not a delegation. + assert not is_delegation_tool_name("please_delegate_to_someone") + assert not is_delegation_tool_name(None) + assert not is_delegation_tool_name(123) -def test_wrap_worker_raises_on_empty_manager_state() -> None: - from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph - node = _wrap_worker_for_subgraph(_echo_worker_graph(), "research_helper") - with pytest.raises(RuntimeError, match="empty manager state"): - node.invoke({"messages": []}) +def test_manager_workers_leaves_astream_events_unwrapped() -> None: + """The delegation protocol is deliberately visible: nothing wraps + ``astream_events`` to scrub it. Only stream/astream are patched, for the + ManagerWorkersExecutionSpan.""" + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers -def test_wrap_worker_raises_when_no_matching_delegation_call() -> None: - from langchain_core.messages import AIMessage + 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"), + ), + ], + ) - from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph + compiled = _load_with_fake_llms(mw, default=_fake_manager()) - node = _wrap_worker_for_subgraph(_echo_worker_graph(), "research_helper") - not_for_me = AIMessage( - content="", - tool_calls=[{"name": "delegate_to_other", "args": {"task": "x"}, "id": "c1"}], - ) - with pytest.raises(RuntimeError, match="delegate_to_research_helper"): - node.invoke({"messages": [not_for_me]}) + assert getattr(compiled.astream_events, "__name__", "") != "patched_astream_events" + # The execution-span patches are still applied. + assert getattr(compiled.stream, "__name__", "") == "patched_stream" + assert getattr(compiled.astream, "__name__", "") == "patched_astream" From f6adf29433ed04fdaf4bdb0d0d70dd53b4b1e2b6 Mon Sep 17 00:00:00 2001 From: Salah Date: Sun, 2 Aug 2026 16:25:01 +0400 Subject: [PATCH 03/14] refactor(adapters/langgraph): align ManagerWorkers code and tests with codebase style Trim reviewer-directed comments to codebase density; drop the lru_cache on the delegation-tool factory, the _WorkerSubgraphNode class (now closures) and the dict-or-object tool-call shim. Rewrite the tests to repo conventions: module-level pyagentspec imports, a shared _agent() helper, pytestmark instead of an empty autouse fixture, no section dividers, and behavior-level coverage instead of micro-tests of private helpers. Also fix a real bug the flow-step test exposed: the ManagerWorkers parent graph runs over MessagesState, so a structured_response can never reach the node executor and any declared output raised ValueError. A ManagerWorkers flow step now answers its single string output with the manager's final message, and rejects other output shapes with NotImplementedError. --- .../adapters/langgraph/_execution_span.py | 12 +- .../adapters/langgraph/_langgraphconverter.py | 29 +- .../adapters/langgraph/_managerworkers.py | 155 ++--- .../adapters/langgraph/_node_execution.py | 22 +- pyagentspec/src/pyagentspec/managerworkers.py | 18 +- pyagentspec/tests/adapters/conftest.py | 15 +- .../flows/test_managerworkers_node.py | 66 +- .../adapters/langgraph/test_managerworkers.py | 567 ++++-------------- 8 files changed, 216 insertions(+), 668 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py index 21817f46..cb22b199 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py @@ -7,13 +7,11 @@ """Wrap a compiled graph's ``stream``/``astream`` in an Agent Spec execution span. Agent, Flow and ManagerWorkers graphs all need the same wrapper: open a span, emit a -Start event carrying the invocation inputs, replay the chunks the underlying stream -yields while remembering the last state chunk, then emit an End event built from that -final state. Only the span class and the two event payloads differ, so they come in -as factories. - -``invoke``/``ainvoke`` need no patch of their own; they go through ``stream``/ -``astream`` internally. +Start event carrying the invocation inputs, yield the chunks the underlying stream +produces while remembering the last state chunk, then emit an End event built from +that final state. Only the span class and the two event payloads differ, so they come +in as factories. ``invoke``/``ainvoke`` need no patch; they use ``stream``/``astream`` +internally. """ from typing import Any, AsyncGenerator, Callable, Dict, Generator diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 59aee1d0..a98733d8 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -1093,21 +1093,15 @@ def _manager_workers_convert_to_langgraph( └─ no tool_call ─→ END The manager is a react-agent holding one synthetic ``delegate_to_`` - tool per worker. The parent graph's conditional edge inspects its last - AIMessage to pick the next node, and the worker node runs in an isolated - message context and answers with a ``ToolMessage`` matched to the pending - delegation id. - - Workers are converted recursively and wired in as subgraph nodes, so - ``astream_events`` still exposes the parent/child boundary - (``subgraph=True``) for tracing and SSE streaming. Workers that are - themselves ``ManagerWorkers`` compose through ``self.convert(...)``. + tool per worker. A conditional edge routes each delegation to its worker, + which runs in an isolated message context and answers with a ``ToolMessage`` + matched to the pending delegation id. Workers are converted recursively and + wired in as subgraph nodes, so ``astream_events`` still exposes the + parent/child boundary (``subgraph=True``) for tracing and streaming. """ if not isinstance(mw.group_manager, AgentSpecAgent): - # The manager has to decide which worker to delegate to, so it needs a - # chat-LLM that emits tool_calls. Only Agent (and its SpecializedAgent - # subclass) has that shape; a Flow, Swarm or nested ManagerWorkers gives - # us nothing to route on. + # Delegation is routed off the manager's tool_calls, so the manager needs + # a chat-LLM; a Flow, Swarm or nested ManagerWorkers gives nothing to route on. raise NotImplementedError( f"ManagerWorkers.group_manager must be an Agent for LangGraph " f"conversion; got {type(mw.group_manager).__name__}." @@ -1138,9 +1132,8 @@ def _manager_workers_convert_to_langgraph( [(node_name, worker.description or "") for node_name, worker in named_workers], ) - # The delegation tools do execute inside the react loop: their body returns a - # Command(graph=PARENT), which is how the call escapes the react subgraph so - # the conditional edge below can route on it. + # The delegation tools execute inside the react loop: their Command(graph=PARENT) + # is how the call escapes the subgraph so the conditional edge below can route on it. manager_graph = self._create_react_agent_with_given_info( name=manager_agent.name, system_prompt=rendered_prompt, @@ -1274,7 +1267,9 @@ def _create_react_agent_with_given_info( make_span=lambda: AgentSpecAgentExecutionSpan( name=f"AgentExecution[{agent.name}]", agent=agent ), - make_start_event=lambda inputs: AgentSpecAgentExecutionStart(agent=agent, inputs=inputs), + make_start_event=lambda inputs: AgentSpecAgentExecutionStart( + agent=agent, inputs=inputs + ), make_end_event=lambda result: AgentSpecAgentExecutionEnd( agent=agent, outputs=extract_outputs_from_invoke_result(result, agent.outputs or []), diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py index 971fb2ae..c331722e 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py @@ -4,19 +4,15 @@ # (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. -"""Helpers for compiling a ``ManagerWorkers`` into LangGraph. +"""Helpers for compiling a ``ManagerWorkers`` into LangGraph, orchestrated by +``AgentSpecToLangGraphConverter._manager_workers_convert_to_langgraph``. -``AgentSpecToLangGraphConverter._manager_workers_convert_to_langgraph`` orchestrates -these; they live here to keep the converter module from growing further. - -Nothing hides the routing protocol. ``delegate_to_`` calls stream like any -other tool call, because which worker got which task is usually the most useful thing -a run reports. Consumers that would rather not render it can filter on +The delegation protocol is visible on purpose: ``delegate_to_`` calls stream +like any other tool call. Consumers that would rather not render them can filter on :func:`is_delegation_tool_name`. """ import re -from functools import lru_cache from typing import Any, Dict, List, Tuple from pyagentspec.adapters.langgraph._execution_span import patch_with_execution_span @@ -35,13 +31,13 @@ # Cannot collide with a normalized worker node name, which is always [a-z0-9_]. _MANAGER_NODE_KEY = "__manager__" -#: Prefix the manager's LLM uses to address a delegation tool, suffixed with the -#: normalized worker node name. Public so consumers can recognize the protocol. +#: Prefix of the synthetic ``delegate_to_`` tool names the manager's LLM uses +#: to address a worker. Public so consumers can recognize the protocol. DELEGATE_TOOL_PREFIX = "delegate_to_" -# Carried on the per-delegation ``Send`` payload so a worker run knows its task and -# which ``tool_call_id`` its reply must answer. Routing per delegation instead of off -# shared state lets one manager turn delegate to several workers at once. +# Keys of the per-delegation ``Send`` payload: the task to run, and the tool_call_id +# the worker's reply must answer. Routing per delegation (instead of off shared state) +# lets one manager turn delegate to several workers at once. _DELEGATE_TASK_KEY = "__delegate_task__" _DELEGATE_CALL_ID_KEY = "__delegate_tool_call_id__" @@ -61,21 +57,13 @@ def _normalize_identifier(s: str) -> str: def _safe_node_name(name: str, fallback_id: str) -> str: """Normalize a worker name into a LangGraph node identifier. - The LLM sees ``delegate_to_`` as a tool name and has to emit it - reliably, so node names stay ASCII identifiers. Falls back to the component id, - normalized the same way, when the name slugifies to nothing. + The LLM has to emit ``delegate_to_`` reliably as a tool name, so node + names stay ASCII identifiers. Falls back to the normalized component id when the + name slugifies to nothing. """ return _normalize_identifier(name) or _normalize_identifier(fallback_id) or "worker" -def _tc_get(tool_call: Any, key: str) -> Any: - """Read ``key`` off a tool call, which langchain emits as a dict or an object - depending on the message source.""" - if isinstance(tool_call, dict): - return tool_call.get(key) - return getattr(tool_call, key, None) - - def _messages_of(state: Any) -> List[Any]: """Read ``messages`` off a state, which langgraph injects as a dict or an object.""" if isinstance(state, dict): @@ -83,23 +71,7 @@ def _messages_of(state: Any) -> List[Any]: return list(getattr(state, "messages", []) or []) -def _surface_to_parent_command(state: Any) -> Any: - """Break out of the manager's react loop, projecting the subgraph's messages onto - the parent state (including the AIMessage carrying the triggering tool call). - - Carries no ``goto``: routing is the parent graph's job. The ``add_messages`` - reducer dedupes by id, so re-surfacing existing messages is a no-op. Modelled on - ``langgraph_swarm.create_handoff_tool``. - """ - from langgraph.types import Command - - return Command(graph=Command.PARENT, update={"messages": _messages_of(state)}) - - -def _append_workers_roster( - system_prompt: str, - entries: List[Tuple[str, str]], -) -> str: +def _append_workers_roster(system_prompt: str, entries: List[Tuple[str, str]]) -> str: """Append an ``Available workers:`` block listing ``- : ``. Descriptions are flattened to one line each, since the LLM routes off the block's @@ -114,19 +86,15 @@ def _append_workers_roster( return f"{system_prompt}\n\n{roster}" if system_prompt else roster -@lru_cache(maxsize=256) def _make_worker_delegation_tool(worker_node_name: str) -> Any: """Build the ``delegate_to_`` tool the manager's LLM emits to route to a worker. - The body carries no ``goto``. Routing fans out one ``Send`` per delegation in - :func:`_route_manager_to_worker_or_end`; a ``goto`` here would collapse several - same-turn delegations into one parent Command and leave the other - ``tool_call_id``s unanswered. - - Memoized because the tool depends only on the node name and holds no per-graph - state. Without it every compile re-runs the ``@tool`` decorator, which costs a - ``get_type_hints`` pass and a pydantic args-schema build. + Executing the tool is only how the call escapes the react subgraph: its body + surfaces the subgraph messages to the parent with ``Command(graph=PARENT)`` and no + ``goto``. Routing stays in :func:`_route_manager_to_worker_or_end`; a ``goto`` here + would collapse several same-turn delegations into one parent Command and leave the + other ``tool_call_id``s unanswered. """ from typing import Annotated @@ -136,9 +104,8 @@ def _make_worker_delegation_tool(worker_node_name: str) -> Any: tool_name = f"{DELEGATE_TOOL_PREFIX}{worker_node_name}" 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." + f"Delegate a task to the {worker_node_name} worker and receive its response. " + f"Use this when the task fits the worker's described capability." ) @tool(tool_name, description=description) @@ -146,12 +113,12 @@ def _delegate( task: str, state: Annotated[Any, InjectedState], tool_call_id: Annotated[str, InjectedToolCallId], - ) -> Command: - # Declared for the LLM-facing schema but unused here: executing is only how the - # call escapes the react subgraph, and the routing edge recovers both off the - # surfaced AIMessage's tool_calls. + ) -> Any: + # task and tool_call_id are declared for the LLM-facing schema; the routing + # edge recovers both off the surfaced AIMessage's tool_calls. The + # add_messages reducer dedupes by id, so re-surfacing messages is a no-op. del task, tool_call_id - return _surface_to_parent_command(state) + return Command(graph=Command.PARENT, update={"messages": _messages_of(state)}) return _delegate @@ -160,28 +127,25 @@ def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: """Route the parent graph off the manager's last AIMessage: one ``Send`` per ``delegate_to_`` tool call, or ``END`` when it emitted none. - Every delegation gets its own ``Send`` carrying the task and ``tool_call_id``, so - each is answered independently. An unanswered one breaks the manager's next-turn - tool-call/result sequence. Plain tool calls already ran inside the react loop. + Every delegation gets its own ``Send``, so each tool_call_id is answered + independently; an unanswered one breaks the manager's next-turn tool-call/result + sequence. Plain tool calls already ran inside the react loop. """ 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 [] + last = messages[-1] if messages else None sends = [] - for tc in tool_calls: - name = _tc_get(tc, "name") + for tool_call in getattr(last, "tool_calls", None) or []: + name = tool_call.get("name") if is_delegation_tool_name(name): - args = _tc_get(tc, "args") or {} + args = tool_call.get("args") or {} sends.append( Send( name[len(DELEGATE_TOOL_PREFIX) :], { _DELEGATE_TASK_KEY: args.get("task") or "", - _DELEGATE_CALL_ID_KEY: _tc_get(tc, "id") or "", + _DELEGATE_CALL_ID_KEY: tool_call.get("id") or "", }, ) ) @@ -189,13 +153,7 @@ def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: def _worker_input(state: Dict[str, Any]) -> Dict[str, Any]: - """The single-message context a worker run starts from. - - Passes no explicit config, so the worker inherits this node's ambient run config. - Its ``checkpoint_ns`` (``:``) streams the worker's token - events under the worker node, and the per-superstep namespace keeps repeated - delegations isolated without a fresh thread_id. - """ + """The single-message context a worker run starts from.""" from langchain_core.messages import HumanMessage return {"messages": [HumanMessage(content=state.get(_DELEGATE_TASK_KEY) or "")]} @@ -214,51 +172,34 @@ def _worker_reply(state: Dict[str, Any], result: Any) -> Dict[str, Any]: } -class _WorkerSubgraphNode: - """Runs one worker subgraph as a node of the ManagerWorkers parent graph. - - Hierarchical rather than shared-state like a Swarm: workers never see each other's - messages, and each run is handed only the manager's chosen task. The worker's last - message comes back as the ToolMessage content, so the manager's react loop sees a - well-formed tool response on its next turn. - - A class rather than a closure, so the long-lived node holds only the graph instead - of keeping a whole factory frame alive. - """ - - __slots__ = ("_graph",) - - def __init__(self, worker_graph: CompiledStateGraph[Any, Any, Any]) -> None: - self._graph = worker_graph - - def run(self, state: Dict[str, Any]) -> Dict[str, Any]: - return _worker_reply(state, self._graph.invoke(_worker_input(state))) - - async def arun(self, state: Dict[str, Any]) -> Dict[str, Any]: - return _worker_reply(state, await self._graph.ainvoke(_worker_input(state))) - - def _wrap_worker_for_subgraph( worker_graph: CompiledStateGraph[Any, Any, Any], worker_node_name: str, ) -> Any: - """Wrap a worker subgraph as a node exposing sync and async entrypoints. + """Wrap a worker subgraph as a node of the ManagerWorkers parent graph. - LangGraph picks between them depending on whether the parent graph was invoked - via ``invoke`` or ``ainvoke``. + Hierarchical rather than shared-state like a Swarm: each run is handed only the + manager's chosen task, and the worker's answer comes back as a ToolMessage so the + manager's react loop sees a well-formed tool response on its next turn. The worker + is invoked with no explicit config and inherits this node's ambient run config, + which streams its token events under the worker node's checkpoint namespace. """ from pyagentspec.adapters.langgraph._types import RunnableLambda - node = _WorkerSubgraphNode(worker_graph) - return RunnableLambda(func=node.run, afunc=node.arun, name=f"worker:{worker_node_name}") + def run(state: Dict[str, Any]) -> Dict[str, Any]: + return _worker_reply(state, worker_graph.invoke(_worker_input(state))) + + async def arun(state: Dict[str, Any]) -> Dict[str, Any]: + return _worker_reply(state, await worker_graph.ainvoke(_worker_input(state))) + + return RunnableLambda(func=run, afunc=arun, 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 run emits a ``ManagerWorkersExecutionSpan``, - using the same patcher as the Agent and Flow graphs.""" + """Wrap ``stream``/``astream`` so each run emits a ``ManagerWorkersExecutionSpan``.""" patch_with_execution_span( compiled_graph, make_span=lambda: AgentSpecManagerWorkersExecutionSpan( diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index f8fb156e..f3df8873 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -543,20 +543,15 @@ def _create_manager_workers_with_given_input_values( The graph runs over ``MessagesState``, which can't carry structured inputs inward to the group manager, so the node inputs are rendered into its - ``system_prompt`` and the satisfied ports dropped from both the manager and - the component. That keeps declared and inferred ports equal for the - downstream span re-validation, and the graph runs on messages alone. - - Cached by rendered prompt, the same key - :meth:`_create_react_agent_with_given_input_values` uses. + ``system_prompt`` and the satisfied ports dropped. Cached by rendered prompt, + the same key :meth:`_create_react_agent_with_given_input_values` uses. """ from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter converter = AgentSpecToLangGraphConverter() entry_agent = component.group_manager if not isinstance(entry_agent, AgentSpecAgent): - # Not routable. Nothing to render or cache, and the converter owns the - # error message for this case. + # Nothing to render or cache; the converter owns the error for this case. return converter._manager_workers_convert_to_langgraph( component, **self._conversion_kwargs() ) @@ -609,6 +604,17 @@ def _format_agent_result(self, result: Dict[str, Any]) -> ExecuteOutput: ] return {}, NodeExecutionDetails(generated_messages=generated_messages) + if isinstance(self.node.agent, AgentSpecManagerWorkers): + # The hierarchical graph runs over MessagesState, which cannot carry a + # structured_response outward: the manager's final message is the result. + outputs = self.node.outputs + if len(outputs) != 1 or outputs[0].type != "string": + raise NotImplementedError( + "A ManagerWorkers flow step supports a single string output; " + f"node `{self.node.name}` declares {[o.title for o in outputs]}." + ) + return {outputs[0].title: result["messages"][-1].content}, NodeExecutionDetails() + outputs = extract_outputs_from_invoke_result(result, self.node.outputs or []) return outputs, NodeExecutionDetails() diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index 8b4c3b32..f89853db 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -67,22 +67,14 @@ class ManagerWorkers(AgenticComponent): ) def _get_inferred_inputs(self) -> List[Property]: - """A ``ManagerWorkers`` exposes the inputs of its group manager. - - The group manager drives the conversation and is the component whose prompt the - runtime renders, so the group accepts exactly the inputs the manager accepts - (for an ``Agent`` manager, its ``{{placeholder}}`` inputs). The base default - infers none, which would leave a ``ManagerWorkers`` used as a flow ``AgentNode`` - with no input ports for a data-flow edge to resolve against. - - The ``hasattr`` guard matches :meth:`Flow._get_inferred_inputs` and - :meth:`AgentNode._get_inferred_inputs`: error-accumulating validators can run - this against a partially-constructed model with no ``group_manager`` assigned. - """ + # The group manager drives the conversation and is the component whose prompt + # the runtime renders, so the group accepts exactly the inputs the manager + # accepts. The hasattr guard matches Flow._get_inferred_inputs: validators can + # run this against a partially-constructed model with no group_manager yet. return (self.group_manager.inputs or []) if hasattr(self, "group_manager") else [] def _get_inferred_outputs(self) -> List[Property]: - """Outputs of the group manager; see :meth:`_get_inferred_inputs`.""" + # Symmetric with the inferred inputs: the group manager's outputs. return (self.group_manager.outputs or []) if hasattr(self, "group_manager") else [] @model_validator_with_error_accumulation diff --git a/pyagentspec/tests/adapters/conftest.py b/pyagentspec/tests/adapters/conftest.py index b999abc7..8b5714ba 100644 --- a/pyagentspec/tests/adapters/conftest.py +++ b/pyagentspec/tests/adapters/conftest.py @@ -127,17 +127,10 @@ def _resolve(dotted: str) -> Any: @pytest.fixture def allow_llm_config_construction(): - """Opt out of the blanket ``SKIP_LLM_TESTS=1`` construction guard. - - That guard skips a test the moment it constructs an LLM config. Right for tests - that go on to call a model, wrong for tests that only need a config object and - stub the conversion: those should run offline, and instead they skip silently in - CI, leaving the code path they cover unverified. - - Restores the real constructors for one test, overriding the guards in both this - conftest and ``tests/conftest.py``. - - Only request this from a test that provably never reaches a model endpoint. + """ + Opt out of the SKIP_LLM_TESTS=1 construction guard for one test, restoring the + real LLM config constructors. Only request this from a test that stubs the model + and never reaches an endpoint; such tests should run offline instead of skipping. """ if not should_skip_llm_test(): # Nothing patched the constructors, so there is nothing to restore. diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py index 35ea5e5a..db551602 100644 --- a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py +++ b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py @@ -4,37 +4,28 @@ # (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 unittest.mock import patch import pytest from pyagentspec.agent import Agent +from pyagentspec.llms import OpenAiCompatibleConfig from pyagentspec.managerworkers import ManagerWorkers from pyagentspec.property import StringProperty +# These tests only need an LLM config object and stub the chat model, so they run +# offline even under SKIP_LLM_TESTS=1. +pytestmark = pytest.mark.usefixtures("allow_llm_config_construction") -@pytest.fixture(autouse=True) -def _offline(allow_llm_config_construction: None) -> None: - """These tests only need an LLM *config* object: the two inference tests never - convert at all, and the flow-step test stubs the chat model. Without this the - SKIP_LLM_TESTS guard skips all three and the flow-step path goes unverified.""" +def _llm_config() -> OpenAiCompatibleConfig: + return OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null") -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) +def test_managerworkers_infers_inputs_from_group_manager_prompt() -> None: + """A ManagerWorkers exposes the group manager's prompt placeholders as inputs, so + a flow AgentNode wrapping it declares input ports a DataFlowEdge can resolve.""" + cfg = _llm_config() manager = Agent( name="manager", llm_config=cfg, @@ -47,18 +38,13 @@ def test_managerworkers_infers_inputs_from_group_manager_prompt() -> None: def test_managerworkers_infers_outputs_from_group_manager() -> None: - """Symmetric with inputs: a ManagerWorkers exposes the group manager's outputs, - so a flow AgentNode wrapping it can wire its result downstream (or surface it as a - leaf).""" - from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig - - cfg = OpenAiCompatibleConfig(name="m", model_id="fake", url="null") - answer = StringProperty(title="answer") + """Symmetric with inputs: a ManagerWorkers exposes the group manager's outputs.""" + cfg = _llm_config() manager = Agent( name="manager", llm_config=cfg, system_prompt="Answer the question.", - outputs=[answer], + outputs=[StringProperty(title="answer")], ) worker = Agent(name="worker", llm_config=cfg, system_prompt="You help.") mw = ManagerWorkers(name="mw", group_manager=manager, workers=[worker]) @@ -67,36 +53,27 @@ def test_managerworkers_infers_outputs_from_group_manager() -> None: def test_managerworkers_runs_as_a_flow_step_with_data_edge_inputs() -> None: - """A ManagerWorkers flow step loads with its data edge resolved, and executes. - - The model is stubbed, so there is no delegation: the manager produces a final - message and routes straight to END. Loading proves the manager node exposes the - ``joke`` input the data edge targets; running proves the manager's answer comes - back as the node's single string output. - """ - from unittest.mock import patch - + """A ManagerWorkers flow step loads with its data edge resolved and executes: + loading proves the node exposes the ``joke`` input the edge targets, running + proves the manager's answer comes back as the node's single string output.""" 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.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. + # The 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") + cfg = _llm_config() joke = StringProperty(title="joke") translated = StringProperty(title="translated") @@ -108,8 +85,6 @@ class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): ) 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) @@ -162,5 +137,4 @@ class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): {"configurable": {"thread_id": "managerworkers-node"}}, ) - assert "outputs" in result assert result["outputs"]["translated"] == "لماذا..." diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index 0f9f28d2..da90d28c 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -1,42 +1,35 @@ -# Copyright © 2025 Oracle and/or its affiliates. +# 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. -"""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 ────────────────────────────────────────────────────────── - +from pyagentspec.agent import Agent +from pyagentspec.llms import OpenAiCompatibleConfig +from pyagentspec.managerworkers import ManagerWorkers -@pytest.fixture(autouse=True) -def _offline(allow_llm_config_construction: None) -> None: - """Every test in this module stubs the chat model (``_fake_manager`` or an - explicitly patched ``_llm_convert_to_langgraph``) and never reaches an - endpoint, so the SKIP_LLM_TESTS construction guard would only hide them.""" +# Every test stubs the chat model and never reaches an endpoint, so they run offline +# even under SKIP_LLM_TESTS=1. +pytestmark = pytest.mark.usefixtures("allow_llm_config_construction") -def _llm_cfg(name: str) -> Any: - from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig - - return OpenAiCompatibleConfig(name=name, model_id="fake", url="null") +def _agent(name: str, llm_name: str, description: str = "", system_prompt: str = ".") -> Agent: + return Agent( + name=name, + description=description, + system_prompt=system_prompt, + llm_config=OpenAiCompatibleConfig(name=llm_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.""" +def _fake_llm(*ai_responses: Any) -> Any: + """A FakeMessagesListChatModel subclassed under ChatOpenAI so the 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 @@ -47,13 +40,11 @@ class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): def _load_with_fake_llms(mw: Any, default: Any = None, **fakes_by_llm_name: Any) -> Any: - """Compile ``mw`` offline, answering each LLM config with a queued fake. + """Compile ``mw`` offline, answering each LLM config (keyed by ``llm_config.name``) + with a queued fake; ``default`` answers any config not named. - Keys are ``llm_config.name``; ``default`` answers any config not named. - - ``create_agent`` calls ``model.bind_tools(...)``, and ``FakeMessagesListChatModel`` - inherits ``bind_tools`` from the real ``ChatOpenAI``, which calls out to OpenAI. - Binding is stubbed to return the same fake, preserving its response queue. + ``bind_tools`` is stubbed to return the same fake because + ``FakeMessagesListChatModel`` inherits it from ``ChatOpenAI``, which calls OpenAI. """ from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel from langgraph.checkpoint.memory import MemorySaver @@ -79,33 +70,18 @@ def _dispatch(_self: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: return loader.load_component(mw) -# ─── Pure-helper unit tests (no LLM) ──────────────────────────────────────── - - -def test_safe_node_name_lowercases_and_collapses_punctuation() -> None: - from pyagentspec.adapters.langgraph._managerworkers import ( - _safe_node_name, - ) +def test_safe_node_name_normalizes_and_falls_back() -> None: + from pyagentspec.adapters.langgraph._managerworkers 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._managerworkers import ( - _safe_node_name, - ) - - # Name slugifies to empty → id used (and also normalized). + # Name slugifies to empty → normalized id; both empty → constant fallback. 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._managerworkers import ( - _append_workers_roster, - ) +def test_append_workers_roster_renders_one_line_per_worker() -> None: + from pyagentspec.adapters.langgraph._managerworkers import _append_workers_roster out = _append_workers_roster( "Coordinate the team.", @@ -117,58 +93,23 @@ def test_append_workers_roster_appends_block_after_existing_prompt() -> None: "- research_helper: Handles research\n" "- drafter: Drafts text" ) - - -def test_append_workers_roster_flattens_multiline_descriptions() -> None: - from pyagentspec.adapters.langgraph._managerworkers 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. + # Multiline descriptions are flattened so the one-line-per-worker shape survives. + out = _append_workers_roster("", [("helper", "First line\nsecond line\n third line ")]) assert out == "Available workers:\n- helper: First line second line third line" -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._managerworkers import ( - _DELEGATE_CALL_ID_KEY, - _DELEGATE_TASK_KEY, - _route_manager_to_worker_or_end, - ) - - delegating = AIMessage( - content="", - tool_calls=[{"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "c1"}], - ) - 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: from langchain_core.messages import AIMessage from langgraph.graph import END - from pyagentspec.adapters.langgraph._managerworkers import ( - _route_manager_to_worker_or_end, - ) + from pyagentspec.adapters.langgraph._managerworkers import _route_manager_to_worker_or_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_fans_out_every_delegation() -> None: +def test_route_manager_to_worker_or_end_fans_out_one_send_per_delegation() -> None: from langchain_core.messages import AIMessage from langgraph.types import Send @@ -187,53 +128,31 @@ def test_route_manager_to_worker_or_end_fans_out_every_delegation() -> None: ], ) 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 + # Every delegation gets its own Send carrying the task and the tool_call_id its + # reply must answer. The non-delegation tool call already ran 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) ────────── + assert [s.arg[_DELEGATE_CALL_ID_KEY] for s in sends] == ["c1", "c2"] def test_manager_workers_compiles_to_hierarchical_graph_topology() -> None: from langchain_core.messages import AIMessage from langgraph.graph import START - from pyagentspec.adapters.langgraph._managerworkers import ( - _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"), - ) + from pyagentspec.adapters.langgraph._managerworkers import _MANAGER_NODE_KEY + mw = ManagerWorkers( name="ResearchTeam", - group_manager=manager_agent, - workers=[worker_a, worker_b], + group_manager=_agent("Coordinator", "manager_llm", system_prompt="Coordinate the team."), + workers=[ + _agent("Research Helper", "worker_a_llm", description="Handles research"), + _agent("Drafter", "worker_b_llm", description="Drafts text"), + ], ) - compiled = _load_with_fake_llms(mw, default=_fake_manager(AIMessage(content="Done."))) + compiled = _load_with_fake_llms(mw, default=_fake_llm(AIMessage(content="Done."))) builder = compiled.builder assert _MANAGER_NODE_KEY in builder.nodes @@ -246,88 +165,43 @@ def test_manager_workers_compiles_to_hierarchical_graph_topology() -> None: assert ("research_helper", _MANAGER_NODE_KEY) in edge_pairs assert ("drafter", _MANAGER_NODE_KEY) in edge_pairs - # Manager → worker is a conditional edge, and branches live separately from - # plain edges on the builder. - branches = builder.branches.get(_MANAGER_NODE_KEY) or {} - assert branches, "expected a conditional branch from the manager node" + # Manager → worker is a conditional edge. + assert builder.branches.get(_MANAGER_NODE_KEY) def test_manager_workers_registers_a_delegation_tool_per_worker() -> None: - """Each worker gets a ``delegate_to_`` tool on the manager, matching the - ``Available workers:`` roster the converter renders into its system prompt (the - roster text itself is covered by the ``_append_workers_roster`` unit tests). - """ from langchain_core.messages import AIMessage - from pyagentspec.adapters.langgraph._managerworkers import ( - _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"), - ) + from pyagentspec.adapters.langgraph._managerworkers import _MANAGER_NODE_KEY + mw = ManagerWorkers( name="Team", - group_manager=manager_agent, - workers=[worker], + group_manager=_agent("Coordinator", "manager_llm"), + workers=[_agent("Research Helper", "worker_llm", description="Handles research tasks")], ) - compiled = _load_with_fake_llms(mw, default=_fake_manager(AIMessage(content="Done."))) + compiled = _load_with_fake_llms(mw, default=_fake_llm(AIMessage(content="Done."))) - # The manager react-agent is itself a subgraph; the delegation tool the roster - # advertises is registered on its tools node, so the LLM has the matching contract. + # The delegation tool the roster advertises is registered on the manager + # react-agent's tools node, so the LLM has the matching contract. manager_subgraph = compiled.builder.nodes[_MANAGER_NODE_KEY].runnable 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, the path that proves subgraph composition works. - - The manager emits a delegate_to_ call, the parent graph routes to the - worker subgraph in an isolated message context, the worker's answer comes back - as a ToolMessage matched to the pending tool_call_id, and the manager's next - turn terminates the graph. - """ + """The manager delegates, the worker runs in an isolated message context and its + answer comes back as a ToolMessage matched to the pending tool_call_id, and the + manager's next turn terminates the graph.""" from langchain_core.messages import AIMessage, HumanMessage - 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], + group_manager=_agent("Coordinator", "manager_llm", system_prompt="You coordinate."), + workers=[_agent("Research Helper", "worker_llm", description="Handles research")], ) - # Manager turn 1: delegate to research_helper. - # Manager turn 2: produce final answer (no tool call → END). + # Manager turn 1: delegate. Manager turn 2: final answer (no tool call → END). manager_responses = [ AIMessage( content="", @@ -341,68 +215,43 @@ def test_manager_workers_delegates_and_routes_back_with_tool_message() -> None: ), AIMessage(content="The worker reports: Saturn has rings."), ] - # Worker turn 1: produce its own final answer. worker_responses = [AIMessage(content="Saturn has rings.")] compiled = _load_with_fake_llms( mw, - manager_llm=_fake_manager(*manager_responses), - worker_llm=_fake_manager(*worker_responses), + manager_llm=_fake_llm(*manager_responses), + worker_llm=_fake_llm(*worker_responses), ) - # Sync invocation only. FakeMessagesListChatModel overrides ``_generate`` but not - # ``_agenerate``, so the async path would resolve up the MRO to the real - # ``ChatOpenAI._agenerate`` and call OpenAI. + # Sync invocation only: FakeMessagesListChatModel overrides ``_generate`` but not + # ``_agenerate``, so the async path would resolve to ``ChatOpenAI._agenerate`` + # and call OpenAI. result = compiled.invoke( {"messages": [HumanMessage(content="Tell me about Saturn.")]}, {"configurable": {"thread_id": "mw-1"}}, ) messages = result["messages"] - msg_types = [type(m).__name__ for m in messages] - assert "HumanMessage" in msg_types - assert "ToolMessage" in msg_types assert isinstance(messages[-1], AIMessage) assert "Saturn has rings" in messages[-1].content - - # Matching the pending delegation 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 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 and left the - other tool_call_ids unanswered. That is an invalid tool-call/tool-result - sequence, and the manager hallucinated the missing replies. - """ + """When one manager turn emits several delegations, each must be answered by its + own ToolMessage matched to the originating tool_call_id; an unanswered one is an + invalid tool-call/result sequence the manager would hallucinate around.""" from langchain_core.messages import AIMessage, HumanMessage - 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=_agent("Coordinator", "manager_llm", system_prompt="You coordinate."), + workers=[_agent("Sub Agent", "worker_llm", description="Writes poems")], ) - 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). + # Turn 1: three delegations to the same worker in one AIMessage. Turn 2: terminate. manager_responses = [ AIMessage( content="", @@ -414,13 +263,12 @@ def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: ), 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)] compiled = _load_with_fake_llms( mw, - manager_llm=_fake_manager(*manager_responses), - worker_llm=_fake_manager(*worker_responses), + manager_llm=_fake_llm(*manager_responses), + worker_llm=_fake_llm(*worker_responses), ) result = compiled.invoke( @@ -429,138 +277,68 @@ def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: ) 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"] + answered = sorted(m.tool_call_id for m in tool_msgs) + assert answered == ["call_1", "call_2", "call_3"] assert all(m.content.startswith("poem #") for m in tool_msgs) -# ─── Recursive nesting ────────────────────────────────────────────────────── - - def test_nested_manager_workers_compiles_recursively() -> None: - """A worker that is itself a ManagerWorkers compiles through the same dispatch, - becoming a CompiledStateGraph the outer parent graph wires in as a subgraph node.""" + """A worker that is itself a ManagerWorkers compiles through the same dispatch and + is wired in as a subgraph node of the outer parent graph.""" from langchain_core.messages import AIMessage - 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"), + group_manager=_agent("InnerManager", "inner_llm", system_prompt="Manage leaves."), + workers=[_agent("Leaf", "leaf_llm", description="Leaf task")], ) outer_mw = ManagerWorkers( name="Outer", - group_manager=outer_manager, + group_manager=_agent("OuterManager", "outer_llm", system_prompt="Manage subteams."), workers=[inner_mw], ) - compiled = _load_with_fake_llms(outer_mw, default=_fake_manager(AIMessage(content="Done."))) + compiled = _load_with_fake_llms(outer_mw, default=_fake_llm(AIMessage(content="Done."))) - # 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: - """group_manager must be an Agent. Pyagentspec accepts any AgenticComponent, but - the adapter needs a chat-LLM emitting tool_calls to decide where to delegate.""" + """A nested ManagerWorkers as group_manager is valid per the pyagentspec + validators, but the adapter needs a chat-LLM emitting tool_calls to route on.""" from langgraph.checkpoint.memory import MemorySaver from pyagentspec.adapters.langgraph import AgentSpecLoader - from pyagentspec.agent import Agent - from pyagentspec.managerworkers import ManagerWorkers - - # A nested ManagerWorkers as group_manager: valid per the 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], + group_manager=_agent("Inner", "i"), + workers=[_agent("Leaf", "l")], ) outer_mw = ManagerWorkers( name="Outer", group_manager=inner_mw, - workers=[ - Agent(name="Other", description="O", system_prompt="O.", llm_config=_llm_cfg("o")), - ], + workers=[_agent("Other", "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.""" + """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". + # Both worker names 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], + group_manager=_agent("M", "m"), + workers=[_agent("Helper A", "a"), _agent("helper-a", "b")], ) loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) @@ -569,24 +347,19 @@ def test_workers_with_name_slug_collision_are_rejected() -> None: 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.""" + """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; a fresh thread_id would detach the + worker into an unattributable top-level ``agent:`` run.""" import asyncio - from langchain_core.language_models.fake_chat_models import ( - GenericFakeChatModel, - ) + 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._managerworkers import ( - _wrap_worker_for_subgraph, - ) + from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph - # A minimal worker compiled graph that streams some content. + # A minimal worker graph that streams some content. wmodel = GenericFakeChatModel(messages=iter([AIMessage(content="Saturn has rings")] * 9)) wb = StateGraph(MessagesState) @@ -598,8 +371,8 @@ async def _wagent(state: Any) -> Any: 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". + # Parent: a plain manager node emits the delegate tool call, then routes to the + # wrapped worker node. pb = StateGraph(MessagesState) def _manager(state: Any) -> Any: @@ -639,115 +412,9 @@ async def _collect() -> Any: 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 -# ─── Shared low-level helper unit tests (no LLM) ───────────────────────────── - - -def test_normalize_identifier_lowercases_collapses_and_strips() -> None: - """The single normalization used for both worker node names and - ``transfer_to_`` tool names.""" - from pyagentspec.adapters.langgraph._managerworkers import _normalize_identifier - - assert _normalize_identifier("Research Helper") == "research_helper" - assert _normalize_identifier("My-Worker!! v2") == "my_worker_v2" - # Punctuation-only / empty slugify to the empty string (callers add a fallback). - assert _normalize_identifier("!!!") == "" - assert _normalize_identifier("") == "" - - -def test_messages_of_reads_dict_and_object_state() -> None: - """The delegation tool receives state as a dict or an attribute-bearing - object depending on the langgraph injection path.""" - from langchain_core.messages import AIMessage - - from pyagentspec.adapters.langgraph._managerworkers import _messages_of - - msg = AIMessage(content="hi") - assert _messages_of({"messages": [msg]}) == [msg] - assert _messages_of({"messages": None}) == [] - assert _messages_of({}) == [] - - class _State: - messages = [msg] - - assert _messages_of(_State()) == [msg] - - class _Empty: - pass - - assert _messages_of(_Empty()) == [] - - -def test_surface_to_parent_command_projects_messages_with_no_goto() -> None: - """The placeholder tool's body: break to the parent graph, project the - subgraph messages, carry no ``goto`` (routing is the parent's job).""" - from langchain_core.messages import AIMessage - from langgraph.types import Command - - from pyagentspec.adapters.langgraph._managerworkers import _surface_to_parent_command - - m1, m2 = AIMessage(content="a"), AIMessage(content="b") - cmd = _surface_to_parent_command({"messages": [m1, m2]}) - - assert isinstance(cmd, Command) - assert cmd.graph == Command.PARENT - assert cmd.goto == () # no goto; the parent graph decides where to go - assert cmd.update == {"messages": [m1, m2]} - - -def test_delegation_tool_exposes_expected_name_and_description() -> None: - """The placeholder tool the manager's LLM addresses by name.""" - from pyagentspec.adapters.langgraph._managerworkers import _make_worker_delegation_tool - - delegate = _make_worker_delegation_tool("research_helper") - assert delegate.name == "delegate_to_research_helper" - assert "research_helper" in delegate.description - - -# ─── _wrap_worker_for_subgraph: pending-delegation extraction (no LLM) ──────── - - -def _echo_worker_graph(reply: str = "WORKER REPLY") -> Any: - """A worker CompiledStateGraph whose only node returns a fixed AIMessage, enough - to exercise the wrapper without an LLM.""" - from langchain_core.messages import AIMessage - from langgraph.graph import END, START, MessagesState, StateGraph - - wb = StateGraph(MessagesState) - wb.add_node("agent", lambda state: {"messages": [AIMessage(content=reply)]}) - wb.add_edge(START, "agent") - wb.add_edge("agent", END) - return wb.compile() - - -def test_wrap_worker_uses_send_payload_task_and_call_id() -> None: - """Fan-out path: the routing edge's ``Send`` payload carries the task and - the originating tool_call_id directly, so the worker reply ToolMessage is - matched to that call.""" - from langchain_core.messages import ToolMessage - - from pyagentspec.adapters.langgraph._managerworkers import ( - _DELEGATE_CALL_ID_KEY, - _DELEGATE_TASK_KEY, - _wrap_worker_for_subgraph, - ) - - node = _wrap_worker_for_subgraph(_echo_worker_graph("DONE"), "research_helper") - out = node.invoke({_DELEGATE_TASK_KEY: "do it", _DELEGATE_CALL_ID_KEY: "call_9"}) - - (reply,) = out["messages"] - assert isinstance(reply, ToolMessage) - assert reply.content == "DONE" - assert reply.tool_call_id == "call_9" - - -# ─── Delegation visibility: the public consumer-side filter ────────────────── - - def test_is_delegation_tool_name_matches_only_the_synthetic_prefix() -> None: from pyagentspec.adapters.langgraph._managerworkers import ( DELEGATE_TOOL_PREFIX, @@ -764,34 +431,16 @@ def test_is_delegation_tool_name_matches_only_the_synthetic_prefix() -> None: def test_manager_workers_leaves_astream_events_unwrapped() -> None: - """The delegation protocol is deliberately visible: nothing wraps - ``astream_events`` to scrub it. Only stream/astream are patched, for the - ManagerWorkersExecutionSpan.""" - - from pyagentspec.agent import Agent - from pyagentspec.managerworkers import ManagerWorkers - + """The delegation protocol is deliberately visible: only stream/astream are + patched (for the ManagerWorkersExecutionSpan); nothing wraps ``astream_events`` + to scrub the delegation tool calls.""" 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"), - ), - ], + group_manager=_agent("Coordinator", "manager_llm"), + workers=[_agent("Research Helper", "worker_llm")], ) - compiled = _load_with_fake_llms(mw, default=_fake_manager()) + compiled = _load_with_fake_llms(mw, default=_fake_llm()) - assert getattr(compiled.astream_events, "__name__", "") != "patched_astream_events" - # The execution-span patches are still applied. - assert getattr(compiled.stream, "__name__", "") == "patched_stream" - assert getattr(compiled.astream, "__name__", "") == "patched_astream" + assert "stream" in compiled.__dict__ and "astream" in compiled.__dict__ + assert "astream_events" not in compiled.__dict__ From 4ed7f7cc52810824b63adf5f6dee66d3e2f12371 Mon Sep 17 00:00:00 2001 From: Salah Date: Mon, 17 Aug 2026 21:53:31 +0400 Subject: [PATCH 04/14] refactor(adapters/langgraph): address ManagerWorkers review feedback - Remove the SKIP_LLM_TESTS opt-out fixture from tests/adapters/conftest.py and its usages; the tests now skip like every other LLM-config test. - Rename the delegation tool prefix to __delegate_to__ so it cannot collide with a real tool name, and route a delegation only when its suffix is an actual worker node. - Move flows/test_managerworkers_node.py into the main test_managerworkers.py since ManagerWorkers is not a Node. - Enforce that the I/Os of a ManagerWorkers match the I/Os of its group manager (same name and type), per the language spec decision. - Reject unsupported ManagerWorkers flow-step output shapes when the flow is converted instead of when the step runs. --- .../adapters/langgraph/_langgraphconverter.py | 12 +- .../adapters/langgraph/_managerworkers.py | 66 ++++-- .../adapters/langgraph/_node_execution.py | 21 +- pyagentspec/src/pyagentspec/managerworkers.py | 32 ++- pyagentspec/tests/adapters/conftest.py | 30 --- .../flows/test_managerworkers_node.py | 140 ----------- .../adapters/langgraph/test_managerworkers.py | 223 ++++++++++++++++-- .../test_agentic_patterns_validation.py | 67 ++++++ 8 files changed, 355 insertions(+), 236 deletions(-) delete mode 100644 pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index a98733d8..17536fdb 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -39,9 +39,9 @@ from pyagentspec.adapters.langgraph._managerworkers import ( _MANAGER_NODE_KEY, _append_workers_roster, + _make_manager_router, _make_worker_delegation_tool, _patch_with_manager_workers_execution_span, - _route_manager_to_worker_or_end, _safe_node_name, _wrap_worker_for_subgraph, ) @@ -1086,13 +1086,13 @@ def _manager_workers_convert_to_langgraph( Topology:: - ┌─ delegate_to_w1 ─→ worker_1 ─┐ - START → manager ┤ ├→ manager (loop) - └─ delegate_to_w2 ─→ worker_2 ─┘ + ┌─ __delegate_to__w1 ─→ worker_1 ─┐ + START → manager ┤ ├→ manager (loop) + └─ __delegate_to__w2 ─→ worker_2 ─┘ │ └─ no tool_call ─→ END - The manager is a react-agent holding one synthetic ``delegate_to_`` + The manager is a react-agent holding one synthetic ``__delegate_to__`` tool per worker. A conditional edge routes each delegation to its worker, which runs in an isolated message context and answers with a ``ToolMessage`` matched to the pending delegation id. Workers are converted recursively and @@ -1161,7 +1161,7 @@ def _manager_workers_convert_to_langgraph( builder.add_edge(langgraph_graph.START, _MANAGER_NODE_KEY) builder.add_conditional_edges( _MANAGER_NODE_KEY, - _route_manager_to_worker_or_end, + _make_manager_router(worker_node_names), # The path map covers every worker plus END, so langgraph can validate # the routing statically. {node_name: node_name for node_name in worker_node_names} diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py index c331722e..05077eec 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py @@ -7,13 +7,13 @@ """Helpers for compiling a ``ManagerWorkers`` into LangGraph, orchestrated by ``AgentSpecToLangGraphConverter._manager_workers_convert_to_langgraph``. -The delegation protocol is visible on purpose: ``delegate_to_`` calls stream -like any other tool call. Consumers that would rather not render them can filter on -:func:`is_delegation_tool_name`. +The delegation protocol is visible on purpose: ``__delegate_to__`` calls +stream like any other tool call. Consumers that would rather not render them can +filter on :func:`is_delegation_tool_name`. """ import re -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, Iterable, List, Tuple from pyagentspec.adapters.langgraph._execution_span import patch_with_execution_span from pyagentspec.adapters.langgraph._types import CompiledStateGraph, langgraph_graph @@ -31,9 +31,11 @@ # Cannot collide with a normalized worker node name, which is always [a-z0-9_]. _MANAGER_NODE_KEY = "__manager__" -#: Prefix of the synthetic ``delegate_to_`` tool names the manager's LLM uses -#: to address a worker. Public so consumers can recognize the protocol. -DELEGATE_TOOL_PREFIX = "delegate_to_" +#: Prefix of the synthetic ``__delegate_to__`` tool names the manager's LLM +#: uses to address a worker. The dunder prefix, like the delegation keys below, keeps +#: it from colliding with a real tool named ``delegate_to_``. Public so +#: consumers can recognize the protocol. +DELEGATE_TOOL_PREFIX = "__delegate_to__" # Keys of the per-delegation ``Send`` payload: the task to run, and the tool_call_id # the worker's reply must answer. Routing per delegation (instead of off shared state) @@ -45,7 +47,7 @@ def is_delegation_tool_name(name: Any) -> bool: - """True for the synthetic ``delegate_to_`` tool names a manager emits.""" + """True for the synthetic ``__delegate_to__`` tool names a manager emits.""" return isinstance(name, str) and name.startswith(DELEGATE_TOOL_PREFIX) @@ -57,7 +59,7 @@ def _normalize_identifier(s: str) -> str: def _safe_node_name(name: str, fallback_id: str) -> str: """Normalize a worker name into a LangGraph node identifier. - The LLM has to emit ``delegate_to_`` reliably as a tool name, so node + The LLM has to emit ``__delegate_to__`` reliably as a tool name, so node names stay ASCII identifiers. Falls back to the normalized component id when the name slugifies to nothing. """ @@ -87,12 +89,12 @@ def _append_workers_roster(system_prompt: str, entries: List[Tuple[str, str]]) - def _make_worker_delegation_tool(worker_node_name: str) -> Any: - """Build the ``delegate_to_`` tool the manager's LLM emits to route to a - worker. + """Build the ``__delegate_to__`` tool the manager's LLM emits to route to + a worker. Executing the tool is only how the call escapes the react subgraph: its body surfaces the subgraph messages to the parent with ``Command(graph=PARENT)`` and no - ``goto``. Routing stays in :func:`_route_manager_to_worker_or_end`; a ``goto`` here + ``goto``. Routing stays in the edge built by :func:`_make_manager_router`; a ``goto`` here would collapse several same-turn delegations into one parent Command and leave the other ``tool_call_id``s unanswered. """ @@ -123,33 +125,45 @@ def _delegate( return _delegate -def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: - """Route the parent graph off the manager's last AIMessage: one ``Send`` per - ``delegate_to_`` tool call, or ``END`` when it emitted none. +def _make_manager_router(worker_node_names: Iterable[str]) -> Any: + """Build the conditional edge routing the parent graph off the manager's last + AIMessage: one ``Send`` per ``__delegate_to__`` tool call, or ``END`` + when it emitted none. Every delegation gets its own ``Send``, so each tool_call_id is answered independently; an unanswered one breaks the manager's next-turn tool-call/result - sequence. Plain tool calls already ran inside the react loop. + sequence. Plain tool calls already ran inside the react loop; that includes a + real tool whose name merely starts with the prefix, which is why a suffix that + is not a worker node is not routed. """ - from langgraph.types import Send - - messages = state.get("messages") or [] - last = messages[-1] if messages else None - sends = [] - for tool_call in getattr(last, "tool_calls", None) or []: - name = tool_call.get("name") - if is_delegation_tool_name(name): + known_workers = frozenset(worker_node_names) + + def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: + from langgraph.types import Send + + messages = state.get("messages") or [] + last = messages[-1] if messages else None + sends = [] + for tool_call in getattr(last, "tool_calls", None) or []: + name = tool_call.get("name") + if not is_delegation_tool_name(name): + continue + worker_node_name = name[len(DELEGATE_TOOL_PREFIX) :] + if worker_node_name not in known_workers: + continue args = tool_call.get("args") or {} sends.append( Send( - name[len(DELEGATE_TOOL_PREFIX) :], + worker_node_name, { _DELEGATE_TASK_KEY: args.get("task") or "", _DELEGATE_CALL_ID_KEY: tool_call.get("id") or "", }, ) ) - return sends or langgraph_graph.END + return sends or langgraph_graph.END + + return _route_manager_to_worker_or_end def _worker_input(state: Dict[str, Any]) -> Dict[str, Any]: diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index f3df8873..915abc1c 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -493,6 +493,17 @@ def __init__( super().__init__(node) if not isinstance(self.node, AgentSpecAgentNode): raise TypeError("AgentNodeExecutor can only be initialized with AgentNode") + if isinstance(self.node.agent, AgentSpecManagerWorkers): + # The hierarchical graph runs over MessagesState, which cannot carry a + # structured_response outward: the manager's final message is the only + # result, so anything but a single string output cannot be honored. + # Raising here fails at conversion time rather than mid-run. + outputs = self.node.outputs or [] + if outputs and (len(outputs) != 1 or outputs[0].type != "string"): + raise NotImplementedError( + "A ManagerWorkers flow step supports a single string output; " + f"node `{self.node.name}` declares {[o.title for o in outputs]}." + ) self.tool_registry = tool_registry self.checkpointer = checkpointer self.converted_components = converted_components @@ -607,13 +618,9 @@ def _format_agent_result(self, result: Dict[str, Any]) -> ExecuteOutput: if isinstance(self.node.agent, AgentSpecManagerWorkers): # The hierarchical graph runs over MessagesState, which cannot carry a # structured_response outward: the manager's final message is the result. - outputs = self.node.outputs - if len(outputs) != 1 or outputs[0].type != "string": - raise NotImplementedError( - "A ManagerWorkers flow step supports a single string output; " - f"node `{self.node.name}` declares {[o.title for o in outputs]}." - ) - return {outputs[0].title: result["messages"][-1].content}, NodeExecutionDetails() + # __init__ already rejected any shape but a single string output. + node_outputs = self.node.outputs or [] + return {node_outputs[0].title: result["messages"][-1].content}, NodeExecutionDetails() outputs = extract_outputs_from_invoke_result(result, self.node.outputs or []) return outputs, NodeExecutionDetails() diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index f89853db..f38526a7 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -67,10 +67,11 @@ class ManagerWorkers(AgenticComponent): ) def _get_inferred_inputs(self) -> List[Property]: - # The group manager drives the conversation and is the component whose prompt - # the runtime renders, so the group accepts exactly the inputs the manager - # accepts. The hasattr guard matches Flow._get_inferred_inputs: validators can - # run this against a partially-constructed model with no group_manager yet. + # Per the language spec, the inputs of a ManagerWorkers are the inputs of its + # group manager (same name and type): the manager drives the conversation and + # is the component whose prompt the runtime renders. The hasattr guard matches + # Flow._get_inferred_inputs: validators can run this against a + # partially-constructed model with no group_manager yet. return (self.group_manager.inputs or []) if hasattr(self, "group_manager") else [] def _get_inferred_outputs(self) -> List[Property]: @@ -91,3 +92,26 @@ def _validate_group_manager_is_not_included_as_a_worker(self) -> Self: if any(self.group_manager is agent for agent in self.workers): raise ValueError("Group manager cannot be a worker.") return self + + @model_validator_with_error_accumulation + def _validate_ios_match_group_manager_ios(self) -> Self: + # Per the language spec, the I/Os of a ManagerWorkers must be the I/Os of its + # group manager, same name and type. The base ComponentWithIO validators + # already enforce matching titles; enforce matching types here. + if not hasattr(self, "group_manager"): + return self + for kind, own_properties, manager_properties in ( + ("input", self.inputs or [], self.group_manager.inputs or []), + ("output", self.outputs or [], self.group_manager.outputs or []), + ): + manager_type_by_title = {p.title: p.type for p in manager_properties} + for own_property in own_properties: + manager_type = manager_type_by_title.get(own_property.title) + if manager_type is not None and own_property.type != manager_type: + raise ValueError( + f"The {kind}s of a `ManagerWorkers` must match the {kind}s of its " + f"group manager (same name and type), but {kind} " + f"`{own_property.title}` has type `{own_property.type}` while the " + f"group manager declares `{manager_type}`." + ) + return self diff --git a/pyagentspec/tests/adapters/conftest.py b/pyagentspec/tests/adapters/conftest.py index 8b5714ba..d5732212 100644 --- a/pyagentspec/tests/adapters/conftest.py +++ b/pyagentspec/tests/adapters/conftest.py @@ -6,8 +6,6 @@ import os import ssl -from contextlib import ExitStack -from importlib import import_module from pathlib import Path from typing import Any from unittest.mock import patch @@ -114,34 +112,6 @@ def _skip(*_args, **_kwargs): p.stop() -def _resolve(dotted: str) -> Any: - module_path, _, attr = dotted.rpartition(".") - module_path, _, cls_name = module_path.rpartition(".") - return getattr(getattr(import_module(module_path), cls_name), attr) - - -# Captured at conftest import, before any session fixture starts patching, so these -# are the genuine constructors rather than a skip stub. -_REAL_LLM_INITS = {dotted: _resolve(dotted) for dotted in LLM_MOCKED_METHODS} - - -@pytest.fixture -def allow_llm_config_construction(): - """ - Opt out of the SKIP_LLM_TESTS=1 construction guard for one test, restoring the - real LLM config constructors. Only request this from a test that stubs the model - and never reaches an endpoint; such tests should run offline instead of skipping. - """ - if not should_skip_llm_test(): - # Nothing patched the constructors, so there is nothing to restore. - yield - return - with ExitStack() as stack: - for dotted, real in _REAL_LLM_INITS.items(): - stack.enter_context(patch(dotted, new=real)) - yield - - @pytest.fixture(scope="package") def json_server(json_server_port: int): api_server = Path(__file__).parent / "api_server.py" diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py deleted file mode 100644 index db551602..00000000 --- a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py +++ /dev/null @@ -1,140 +0,0 @@ -# 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. - -from unittest.mock import patch - -import pytest - -from pyagentspec.agent import Agent -from pyagentspec.llms import OpenAiCompatibleConfig -from pyagentspec.managerworkers import ManagerWorkers -from pyagentspec.property import StringProperty - -# These tests only need an LLM config object and stub the chat model, so they run -# offline even under SKIP_LLM_TESTS=1. -pytestmark = pytest.mark.usefixtures("allow_llm_config_construction") - - -def _llm_config() -> OpenAiCompatibleConfig: - return OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null") - - -def test_managerworkers_infers_inputs_from_group_manager_prompt() -> None: - """A ManagerWorkers exposes the group manager's prompt placeholders as inputs, so - a flow AgentNode wrapping it declares input ports a DataFlowEdge can resolve.""" - cfg = _llm_config() - 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_infers_outputs_from_group_manager() -> None: - """Symmetric with inputs: a ManagerWorkers exposes the group manager's outputs.""" - cfg = _llm_config() - manager = Agent( - name="manager", - llm_config=cfg, - system_prompt="Answer the question.", - outputs=[StringProperty(title="answer")], - ) - worker = Agent(name="worker", llm_config=cfg, system_prompt="You help.") - mw = ManagerWorkers(name="mw", group_manager=manager, workers=[worker]) - - assert [p.title for p in (mw.outputs or [])] == ["answer"] - - -def test_managerworkers_runs_as_a_flow_step_with_data_edge_inputs() -> None: - """A ManagerWorkers flow step loads with its data edge resolved and executes: - loading proves the node exposes the ``joke`` input the edge targets, running - proves the manager's answer comes back as the node's single string output.""" - 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 - - class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): - pass - - # The final message has no tool_calls → the manager routes to END without delegating. - fake_llm = _FakeModel(responses=[AIMessage(content="لماذا...")]) - - cfg = _llm_config() - 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]) - 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 result["outputs"]["translated"] == "لماذا..." diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index da90d28c..96cdc656 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -4,7 +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. -from typing import Any +from typing import Any, List, Optional from unittest.mock import patch import pytest @@ -12,18 +12,22 @@ from pyagentspec.agent import Agent from pyagentspec.llms import OpenAiCompatibleConfig from pyagentspec.managerworkers import ManagerWorkers +from pyagentspec.property import Property -# Every test stubs the chat model and never reaches an endpoint, so they run offline -# even under SKIP_LLM_TESTS=1. -pytestmark = pytest.mark.usefixtures("allow_llm_config_construction") - -def _agent(name: str, llm_name: str, description: str = "", system_prompt: str = ".") -> Agent: +def _agent( + name: str, + llm_name: str, + description: str = "", + system_prompt: str = ".", + outputs: Optional[List[Property]] = None, +) -> Agent: return Agent( name=name, description=description, system_prompt=system_prompt, llm_config=OpenAiCompatibleConfig(name=llm_name, model_id="fake", url="null"), + outputs=outputs, ) @@ -102,11 +106,12 @@ 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._managerworkers import _route_manager_to_worker_or_end + from pyagentspec.adapters.langgraph._managerworkers import _make_manager_router + route = _make_manager_router(["drafter"]) 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({"messages": [not_delegating]}) == END + assert route({"messages": []}) == END def test_route_manager_to_worker_or_end_fans_out_one_send_per_delegation() -> None: @@ -116,18 +121,19 @@ def test_route_manager_to_worker_or_end_fans_out_one_send_per_delegation() -> No from pyagentspec.adapters.langgraph._managerworkers import ( _DELEGATE_CALL_ID_KEY, _DELEGATE_TASK_KEY, - _route_manager_to_worker_or_end, + _make_manager_router, ) + route = _make_manager_router(["drafter", "research_helper"]) 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"}, + {"name": "__delegate_to__drafter", "args": {"task": "x"}, "id": "c1"}, + {"name": "__delegate_to__research_helper", "args": {"task": "y"}, "id": "c2"}, ], ) - sends = _route_manager_to_worker_or_end({"messages": [msg]}) + sends = route({"messages": [msg]}) # Every delegation gets its own Send carrying the task and the tool_call_id its # reply must answer. The non-delegation tool call already ran inside the manager's # react loop and is ignored by routing. @@ -137,6 +143,23 @@ def test_route_manager_to_worker_or_end_fans_out_one_send_per_delegation() -> No assert [s.arg[_DELEGATE_CALL_ID_KEY] for s in sends] == ["c1", "c2"] +def test_route_manager_ignores_prefixed_tool_whose_suffix_is_not_a_worker() -> None: + """A tool call that merely looks like a delegation must not be routed: its suffix + is not a worker node, so a Send would target a non-existing node. It already ran + as a plain tool inside the react loop.""" + from langchain_core.messages import AIMessage + from langgraph.graph import END + + from pyagentspec.adapters.langgraph._managerworkers import _make_manager_router + + route = _make_manager_router(["drafter"]) + msg = AIMessage( + content="", + tool_calls=[{"name": "__delegate_to__nobody", "args": {"task": "x"}, "id": "c1"}], + ) + assert route({"messages": [msg]}) == END + + def test_manager_workers_compiles_to_hierarchical_graph_topology() -> None: from langchain_core.messages import AIMessage from langgraph.graph import START @@ -186,7 +209,7 @@ def test_manager_workers_registers_a_delegation_tool_per_worker() -> None: # react-agent's tools node, so the LLM has the matching contract. manager_subgraph = compiled.builder.nodes[_MANAGER_NODE_KEY].runnable tools_node = manager_subgraph.builder.nodes["tools"].runnable - assert "delegate_to_research_helper" in tools_node.tools_by_name + assert "__delegate_to__research_helper" in tools_node.tools_by_name def test_manager_workers_delegates_and_routes_back_with_tool_message() -> None: @@ -207,7 +230,7 @@ def test_manager_workers_delegates_and_routes_back_with_tool_message() -> None: content="", tool_calls=[ { - "name": "delegate_to_research_helper", + "name": "__delegate_to__research_helper", "args": {"task": "Look up Saturn"}, "id": "call_1", } @@ -256,9 +279,21 @@ def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: 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"}, + { + "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."), @@ -382,7 +417,7 @@ def _manager(state: Any) -> Any: content="", tool_calls=[ { - "name": "delegate_to_research_helper", + "name": "__delegate_to__research_helper", "args": {"task": "Saturn"}, "id": "c1", } @@ -421,11 +456,13 @@ def test_is_delegation_tool_name_matches_only_the_synthetic_prefix() -> None: is_delegation_tool_name, ) - assert DELEGATE_TOOL_PREFIX == "delegate_to_" - assert is_delegation_tool_name("delegate_to_research_helper") + assert DELEGATE_TOOL_PREFIX == "__delegate_to__" + assert is_delegation_tool_name("__delegate_to__research_helper") assert not is_delegation_tool_name("get_weather") - # A real tool merely *containing* the prefix mid-name is not a delegation. - assert not is_delegation_tool_name("please_delegate_to_someone") + # A real tool plausibly named delegate_to_ is not a delegation. + assert not is_delegation_tool_name("delegate_to_someone") + # Nor is one merely *containing* the prefix mid-name. + assert not is_delegation_tool_name("please__delegate_to__someone") assert not is_delegation_tool_name(None) assert not is_delegation_tool_name(123) @@ -444,3 +481,143 @@ def test_manager_workers_leaves_astream_events_unwrapped() -> None: assert "stream" in compiled.__dict__ and "astream" in compiled.__dict__ assert "astream_events" not in compiled.__dict__ + + +def test_managerworkers_infers_inputs_from_group_manager_prompt() -> None: + """A ManagerWorkers exposes the group manager's prompt placeholders as inputs, so + a flow AgentNode wrapping it declares input ports a DataFlowEdge can resolve.""" + manager = _agent( + "manager", + "manager_llm", + system_prompt="Translate the following to Arabic:\n\n{{joke}}\n\nMake {{count}} variants.", + ) + worker = _agent("worker", "worker_llm", 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_infers_outputs_from_group_manager() -> None: + """Symmetric with inputs: a ManagerWorkers exposes the group manager's outputs.""" + from pyagentspec.property import StringProperty + + manager = _agent( + "manager", + "manager_llm", + system_prompt="Answer the question.", + outputs=[StringProperty(title="answer")], + ) + worker = _agent("worker", "worker_llm", system_prompt="You help.") + mw = ManagerWorkers(name="mw", group_manager=manager, workers=[worker]) + + assert [p.title for p in (mw.outputs or [])] == ["answer"] + + +def _flow_with_manager_workers_step(outputs: List[Property]) -> Any: + """A start → AgentNode(ManagerWorkers) → end flow whose end node exposes + ``outputs``, with the data edges resolving the manager's ``joke`` input and + every output.""" + from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge + from pyagentspec.flows.flow import Flow + from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode + from pyagentspec.property import StringProperty + + joke = StringProperty(title="joke") + manager = _agent( + "manager", + "manager_llm", + system_prompt="Translate the following to Arabic:\n\n{{joke}}", + outputs=outputs, + ) + worker = _agent("worker", "worker_llm", system_prompt="You translate.") + mw = ManagerWorkers(name="translator", group_manager=manager, workers=[worker]) + 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=outputs) + return 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=f"{output.title}_edge", + source_node=manager_node, + source_output=output.title, + destination_node=end_node, + destination_input=output.title, + ) + for output in outputs + ], + outputs=outputs, + ) + + +def test_managerworkers_runs_as_a_flow_step_with_data_edge_inputs() -> None: + """A ManagerWorkers flow step loads with its data edge resolved and executes: + loading proves the node exposes the ``joke`` input the edge targets, running + proves the manager's answer comes back as the node's single string output.""" + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + 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.property import StringProperty + + # The final message has no tool_calls → the manager routes to END without delegating. + fake_llm = _fake_llm(AIMessage(content="لماذا...")) + flow = _flow_with_manager_workers_step(outputs=[StringProperty(title="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 result["outputs"]["translated"] == "لماذا..." + + +def test_managerworkers_flow_step_with_unsupported_outputs_fails_at_conversion() -> None: + """A ManagerWorkers flow step supports a single string output only; any other + shape must be rejected when the flow is converted, not once the step runs.""" + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.property import StringProperty + + flow = _flow_with_manager_workers_step( + outputs=[StringProperty(title="translated"), StringProperty(title="notes")] + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with pytest.raises(NotImplementedError, match="single string output"): + loader.load_component(flow) diff --git a/pyagentspec/tests/validation/test_agentic_patterns_validation.py b/pyagentspec/tests/validation/test_agentic_patterns_validation.py index 81a13af8..f951ce3a 100644 --- a/pyagentspec/tests/validation/test_agentic_patterns_validation.py +++ b/pyagentspec/tests/validation/test_agentic_patterns_validation.py @@ -14,6 +14,7 @@ from pyagentspec.flows.nodes.startnode import StartNode from pyagentspec.llms import OpenAiConfig from pyagentspec.managerworkers import ManagerWorkers +from pyagentspec.property import FloatProperty, StringProperty from pyagentspec.swarm import Swarm @@ -79,6 +80,72 @@ def test_managerworkers_with_different_agentic_components_can_be_validated() -> ) +def test_managerworkers_with_ios_matching_the_group_manager_can_be_validated() -> None: + manager_agent = Agent( + name="manager_agent", + system_prompt="Answer about {{topic}}.", + llm_config=OpenAiConfig(name="default", model_id="test_model"), + outputs=[StringProperty(title="answer")], + ) + worker_agent = Agent( + name="worker_agent", + system_prompt="You help.", + llm_config=OpenAiConfig(name="default", model_id="test_model"), + ) + + # The I/Os of a ManagerWorkers must be the I/Os of its group manager (same name + # and type); redeclaring them explicitly is valid. + _ = ManagerWorkers( + name="managerworkers", + group_manager=manager_agent, + workers=[worker_agent], + inputs=[StringProperty(title="topic")], + outputs=[StringProperty(title="answer")], + ) + + +def test_managerworkers_with_ios_not_matching_the_group_manager_raises_errors() -> None: + manager_agent = Agent( + name="manager_agent", + system_prompt="Answer about {{topic}}.", + llm_config=OpenAiConfig(name="default", model_id="test_model"), + outputs=[StringProperty(title="answer")], + ) + worker_agent = Agent( + name="worker_agent", + system_prompt="You help.", + llm_config=OpenAiConfig(name="default", model_id="test_model"), + ) + + # Same title as a group manager input, but a different type. + with pytest.raises(ValueError, match="must match the inputs of its group manager"): + ManagerWorkers( + name="managerworkers", + group_manager=manager_agent, + workers=[worker_agent], + inputs=[FloatProperty(title="topic")], + ) + + # Same title as a group manager output, but a different type. + with pytest.raises(ValueError, match="must match the outputs of its group manager"): + ManagerWorkers( + name="managerworkers", + group_manager=manager_agent, + workers=[worker_agent], + outputs=[FloatProperty(title="answer")], + ) + + # A title the group manager does not declare is rejected by the base + # ComponentWithIO validation. + with pytest.raises(ValueError, match="expected only properties with the titles"): + ManagerWorkers( + name="managerworkers", + group_manager=manager_agent, + workers=[worker_agent], + outputs=[StringProperty(title="answer"), StringProperty(title="extra")], + ) + + def test_swarm_with_empty_relationships_raises_errors() -> None: first_agent = Agent( name="first_agent", From e05d4a7c7e3ff459710bfc5e4af2917288a20b8a Mon Sep 17 00:00:00 2001 From: Salah Date: Tue, 18 Aug 2026 23:01:43 +0400 Subject: [PATCH 05/14] refactor(adapters/langgraph): extract ManagerWorkersNodeExecutor - Move the ManagerWorkers flow-step behavior out of AgentNodeExecutor into a dedicated ManagerWorkersNodeExecutor, selected once at conversion time in _agent_node_convert_to_langgraph. This removes the scattered isinstance branches and brings _node_execution.py back under 1000 lines. - Document why the executor calls the private converter entry point: the public convert() caches by component id, which would collapse differently-rendered prompt copies into one graph. - Re-export DELEGATE_TOOL_PREFIX and is_delegation_tool_name from pyagentspec.adapters.langgraph so the delegation protocol is actually public API. - Tidy: hoist the stdlib Annotated import, annotate _async_or_sync -> None, correct the _MANAGER_NODE_KEY collision-safety comment. --- .../adapters/langgraph/__init__.py | 3 + .../adapters/langgraph/_execution_span.py | 4 +- .../adapters/langgraph/_langgraphconverter.py | 8 +- .../adapters/langgraph/_managerworkers.py | 11 +- .../langgraph/_managerworkers_node.py | 119 ++++++++++++++++++ .../adapters/langgraph/_node_execution.py | 76 ++--------- 6 files changed, 145 insertions(+), 76 deletions(-) create mode 100644 pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/__init__.py b/pyagentspec/src/pyagentspec/adapters/langgraph/__init__.py index baeb855b..fb75ddeb 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/__init__.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/__init__.py @@ -6,10 +6,13 @@ """Agent Spec adapter for the LangGraph agentic framework.""" +from ._managerworkers import DELEGATE_TOOL_PREFIX, is_delegation_tool_name from .agentspecexporter import AgentSpecExporter from .agentspecloader import AgentSpecLoader __all__ = [ "AgentSpecLoader", "AgentSpecExporter", + "DELEGATE_TOOL_PREFIX", + "is_delegation_tool_name", ] diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py index cb22b199..51e39e7f 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py @@ -34,7 +34,9 @@ def _final_state(chunk: Any, so_far: Any) -> Any: return chunk[1] if isinstance(chunk, tuple) else so_far -async def _async_or_sync(async_call: Callable[..., Any], sync_call: Callable[..., Any], *args: Any): +async def _async_or_sync( + async_call: Callable[..., Any], sync_call: Callable[..., Any], *args: Any +) -> None: """Await ``async_call``, falling back to ``sync_call`` for spans that don't implement the async half of the tracing protocol.""" try: diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 17536fdb..8929b1be 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -712,9 +712,15 @@ def _agent_node_convert_to_langgraph( config: RunnableConfig, middleware: List[Any], ) -> "NodeExecutor": + from pyagentspec.adapters.langgraph._managerworkers_node import ManagerWorkersNodeExecutor from pyagentspec.adapters.langgraph._node_execution import AgentNodeExecutor - return AgentNodeExecutor( + executor_class = ( + ManagerWorkersNodeExecutor + if isinstance(agent_node.agent, AgentSpecManagerWorkers) + else AgentNodeExecutor + ) + return executor_class( agent_node, tool_registry=tool_registry, converted_components=converted_components, diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py index 05077eec..ea931b2a 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py @@ -13,7 +13,7 @@ """ import re -from typing import Any, Dict, Iterable, List, Tuple +from typing import Annotated, Any, Dict, Iterable, List, Tuple from pyagentspec.adapters.langgraph._execution_span import patch_with_execution_span from pyagentspec.adapters.langgraph._types import CompiledStateGraph, langgraph_graph @@ -28,13 +28,14 @@ ManagerWorkersExecutionSpan as AgentSpecManagerWorkersExecutionSpan, ) -# Cannot collide with a normalized worker node name, which is always [a-z0-9_]. +# Cannot collide with a worker node name: _normalize_identifier strips leading and +# trailing underscores, so no normalized name ever starts with one. _MANAGER_NODE_KEY = "__manager__" #: Prefix of the synthetic ``__delegate_to__`` tool names the manager's LLM #: uses to address a worker. The dunder prefix, like the delegation keys below, keeps -#: it from colliding with a real tool named ``delegate_to_``. Public so -#: consumers can recognize the protocol. +#: it from colliding with a real tool named ``delegate_to_``. Re-exported +#: from ``pyagentspec.adapters.langgraph`` so consumers can recognize the protocol. DELEGATE_TOOL_PREFIX = "__delegate_to__" # Keys of the per-delegation ``Send`` payload: the task to run, and the tool_call_id @@ -98,8 +99,6 @@ def _make_worker_delegation_tool(worker_node_name: str) -> Any: would collapse several same-turn delegations into one parent Command and leave the other ``tool_call_id``s unanswered. """ - from typing import Annotated - from langchain_core.tools import InjectedToolCallId, tool from langgraph.prebuilt import InjectedState from langgraph.types import Command diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py new file mode 100644 index 00000000..f04c7815 --- /dev/null +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py @@ -0,0 +1,119 @@ +# 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. + +"""Runs a ``ManagerWorkers`` as a flow step. + +``AgentSpecToLangGraphConverter._agent_node_convert_to_langgraph`` selects +:class:`ManagerWorkersNodeExecutor` when the node's agent is a ``ManagerWorkers``, +so :class:`~pyagentspec.adapters.langgraph._node_execution.AgentNodeExecutor` keeps +the plain-Agent behavior only. +""" + +from typing import Any, Dict, List, Optional, Tuple + +from pyagentspec.adapters._utils import render_template +from pyagentspec.adapters.langgraph._node_execution import AgentNodeExecutor +from pyagentspec.adapters.langgraph._types import ( + Checkpointer, + CompiledStateGraph, + ExecuteOutput, + LangGraphTool, + Messages, + NodeExecutionDetails, + RunnableConfig, +) +from pyagentspec.agent import Agent as AgentSpecAgent +from pyagentspec.flows.nodes import AgentNode as AgentSpecAgentNode +from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers + + +class ManagerWorkersNodeExecutor(AgentNodeExecutor): + """Executes an ``AgentNode`` whose agent is a ``ManagerWorkers``. + + The hierarchical graph runs over ``MessagesState``, which can carry neither + structured inputs inward nor a ``structured_response`` outward. Inputs are + therefore rendered into the group-manager's system prompt before compiling, and + the manager's final message is the node's single string output. + """ + + def __init__( + self, + node: AgentSpecAgentNode, + tool_registry: Dict[str, "LangGraphTool"], + converted_components: Dict[str, Any], + checkpointer: Optional[Checkpointer], + config: RunnableConfig, + middleware: Optional[List[Any]] = None, + ) -> None: + super().__init__( + node, tool_registry, converted_components, checkpointer, config, middleware + ) + if not isinstance(node.agent, AgentSpecManagerWorkers): + raise TypeError( + "ManagerWorkersNodeExecutor requires an AgentNode holding a ManagerWorkers" + ) + self._manager_workers: AgentSpecManagerWorkers = node.agent + # Anything but a single string output cannot be honored (see class docstring); + # raising here fails at conversion time rather than mid-run. + outputs = node.outputs or [] + if outputs and (len(outputs) != 1 or outputs[0].type != "string"): + raise NotImplementedError( + "A ManagerWorkers flow step supports a single string output; " + f"node `{node.name}` declares {[o.title for o in outputs]}." + ) + + def _create_manager_workers_with_given_input_values( + self, inputs: Dict[str, Any] + ) -> CompiledStateGraph[Any, Any]: + """Compile the ``ManagerWorkers`` with the node inputs rendered into the + group-manager's ``system_prompt`` and the satisfied ports dropped. + + Cached by rendered prompt, the same key + :meth:`AgentNodeExecutor._create_react_agent_with_given_input_values` uses. + Calling the private converter entry point is deliberate, mirroring the react + path: the public ``convert`` caches by component id, which would collapse the + differently-rendered copies (all sharing the original's id) into one graph. + """ + from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter + + converter = AgentSpecToLangGraphConverter() + component = self._manager_workers + entry_agent = component.group_manager + if not isinstance(entry_agent, AgentSpecAgent): + # Nothing to render or cache; the converter owns the error for this case. + return converter._manager_workers_convert_to_langgraph( + component, **self._conversion_kwargs() + ) + + system_prompt = render_template(entry_agent.system_prompt, inputs) + if system_prompt not in self._agents_cache: + rendered = component.model_copy( + update={ + "group_manager": entry_agent.model_copy( + update={"system_prompt": system_prompt, "inputs": []} + ), + "inputs": [], + } + ) + self._agents_cache[system_prompt] = converter._manager_workers_convert_to_langgraph( + rendered, **self._conversion_kwargs() + ) + return self._agents_cache[system_prompt] + + def _prepare_agent_and_inputs( + self, inputs: Dict[str, Any], messages: Messages + ) -> Tuple[CompiledStateGraph[Any, Any], Dict[str, Any]]: + # Inputs were baked into the group-manager's prompt, so this graph runs on + # messages alone rather than the react-agent's remaining_steps state. + graph = self._create_manager_workers_with_given_input_values(inputs) + return graph, {"messages": self._with_driving_message(messages)} + + def _format_agent_result(self, result: Dict[str, Any]) -> ExecuteOutput: + node_outputs = self.node.outputs + if not node_outputs: + return super()._format_agent_result(result) + # __init__ already rejected any shape but a single string output. + return {node_outputs[0].title: result["messages"][-1].content}, NodeExecutionDetails() diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index 915abc1c..a1398df9 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -49,7 +49,6 @@ 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 @@ -493,17 +492,6 @@ def __init__( super().__init__(node) if not isinstance(self.node, AgentSpecAgentNode): raise TypeError("AgentNodeExecutor can only be initialized with AgentNode") - if isinstance(self.node.agent, AgentSpecManagerWorkers): - # The hierarchical graph runs over MessagesState, which cannot carry a - # structured_response outward: the manager's final message is the only - # result, so anything but a single string output cannot be honored. - # Raising here fails at conversion time rather than mid-run. - outputs = self.node.outputs or [] - if outputs and (len(outputs) != 1 or outputs[0].type != "string"): - raise NotImplementedError( - "A ManagerWorkers flow step supports a single string output; " - f"node `{self.node.name}` declares {[o.title for o in outputs]}." - ) self.tool_registry = tool_registry self.checkpointer = checkpointer self.converted_components = converted_components @@ -547,62 +535,21 @@ def _create_react_agent_with_given_input_values( ) return self._agents_cache[system_prompt] - def _create_manager_workers_with_given_input_values( - self, component: AgentSpecManagerWorkers, inputs: Dict[str, Any] - ) -> CompiledStateGraph[Any, Any]: - """Compile a ``ManagerWorkers`` that this node runs as a flow step. - - The graph runs over ``MessagesState``, which can't carry structured inputs - inward to the group manager, so the node inputs are rendered into its - ``system_prompt`` and the satisfied ports dropped. Cached by rendered prompt, - the same key :meth:`_create_react_agent_with_given_input_values` uses. - """ - from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter - - converter = AgentSpecToLangGraphConverter() - entry_agent = component.group_manager - if not isinstance(entry_agent, AgentSpecAgent): - # Nothing to render or cache; the converter owns the error for this case. - return converter._manager_workers_convert_to_langgraph( - component, **self._conversion_kwargs() - ) - - system_prompt = render_template(entry_agent.system_prompt, inputs) - if system_prompt not in self._agents_cache: - rendered = component.model_copy( - update={ - "group_manager": entry_agent.model_copy( - update={"system_prompt": system_prompt, "inputs": []} - ), - "inputs": [], - } - ) - self._agents_cache[system_prompt] = converter._manager_workers_convert_to_langgraph( - rendered, **self._conversion_kwargs() - ) - return self._agents_cache[system_prompt] + @staticmethod + def _with_driving_message(messages: Messages) -> Messages: + # 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. + return messages if messages else cast(Messages, [{"role": "user", "content": ""}]) def _prepare_agent_and_inputs( self, inputs: Dict[str, Any], messages: Messages ) -> Tuple[CompiledStateGraph[Any, Any], Dict[str, Any]]: - # 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": ""}]) - agentspec_component = self.node.agent - if isinstance(agentspec_component, AgentSpecManagerWorkers): - # Inputs were baked into the group-manager's prompt, so this graph runs on - # messages alone rather than the agent's remaining_steps state. - graph = self._create_manager_workers_with_given_input_values( - agentspec_component, 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, + "messages": self._with_driving_message(messages), "structured_response": {}, } return agent, inputs @@ -615,13 +562,6 @@ def _format_agent_result(self, result: Dict[str, Any]) -> ExecuteOutput: ] return {}, NodeExecutionDetails(generated_messages=generated_messages) - if isinstance(self.node.agent, AgentSpecManagerWorkers): - # The hierarchical graph runs over MessagesState, which cannot carry a - # structured_response outward: the manager's final message is the result. - # __init__ already rejected any shape but a single string output. - node_outputs = self.node.outputs or [] - return {node_outputs[0].title: result["messages"][-1].content}, NodeExecutionDetails() - outputs = extract_outputs_from_invoke_result(result, self.node.outputs or []) return outputs, NodeExecutionDetails() From 700081af5c86cb30090f712a10742e922ec93d2a Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Mon, 24 Aug 2026 12:04:51 +0200 Subject: [PATCH 06/14] Validation updates. Language updates. TS adapter support. --- .../agentspec/language_spec_nightly.rst | 3 ++ docs/pyagentspec/source/changelog.rst | 19 +++++++ .../adapters/langgraph/_execution_span.py | 1 - pyagentspec/src/pyagentspec/managerworkers.py | 42 +++++++++------- .../serialization/test_managerworkers.py | 24 +++++++++ .../test_agentic_patterns_validation.py | 15 +++++- tsagentspec/src/agents/manager-workers.ts | 49 ++++++++++++++++++- .../tests/agents/manager-workers.test.ts | 40 +++++++++++++++ 8 files changed, 172 insertions(+), 21 deletions(-) diff --git a/docs/pyagentspec/source/agentspec/language_spec_nightly.rst b/docs/pyagentspec/source/agentspec/language_spec_nightly.rst index 99de2c3a..fc3d9dad 100644 --- a/docs/pyagentspec/source/agentspec/language_spec_nightly.rst +++ b/docs/pyagentspec/source/agentspec/language_spec_nightly.rst @@ -2270,6 +2270,9 @@ The ManagerWorkers has two main parameters: - Workers cannot interact with the end user directly. - When invoked, each worker can leverage its equipped tools to complete the assigned task and report the result back to the group manager. +The ``ManagerWorkers`` input and output schemas must match those of its ``group_manager``. +In particular, the two components must declare the same input property names and the same output property names, +and each corresponding property must have the same type. Datastores ~~~~~~~~~~ diff --git a/docs/pyagentspec/source/changelog.rst b/docs/pyagentspec/source/changelog.rst index 60795a13..c9ceb2b5 100644 --- a/docs/pyagentspec/source/changelog.rst +++ b/docs/pyagentspec/source/changelog.rst @@ -71,6 +71,25 @@ New features Added ``DbmsVectorChainLlmConfig`` for configuring LLM requests executed through Oracle Database ``DBMS_VECTOR_CHAIN``. +* **ManagerWorkers I/O update** + + The ManagerWorkers language specification now requires its input and output + schemas to use the same property names and types as its group manager. + This allows exposing inputs required by the manager agent (e.g., the placeholders + in its system prompt). + + We thank @spichen for the contribution! + +* **ManagerWorkers support in the LangGraph adapter** + + The LangGraph adapter now converts ``ManagerWorkers`` into hierarchical graphs: + the group manager delegates tasks to workers and receives their results before + producing a final response. Nested ``ManagerWorkers`` can be used as workers. + The adapter also supports ``ManagerWorkers`` in Flow ``AgentNode`` steps with + one string output only. + + We thank @spichen for the contribution! + * **MCP tool retry policies** Added ``retry_policy`` support to ``MCPTool`` and ``MCPToolBox`` so runtimes can diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py index 51e39e7f..033484c8 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py @@ -47,7 +47,6 @@ async def _async_or_sync( def patch_with_execution_span( compiled_graph: CompiledStateGraph[Any, Any, Any], - *, make_span: Callable[[], Any], make_start_event: Callable[[Dict[str, Any]], Any], make_end_event: Callable[[Dict[str, Any]], Any], diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index f38526a7..497f8b5c 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -13,7 +13,7 @@ from typing_extensions import Self from pyagentspec.agenticcomponent import AgenticComponent -from pyagentspec.property import Property +from pyagentspec.property import Property, properties_have_same_type from pyagentspec.validation_helpers import model_validator_with_error_accumulation from pyagentspec.versioning import AgentSpecVersionEnum @@ -68,15 +68,19 @@ class ManagerWorkers(AgenticComponent): def _get_inferred_inputs(self) -> List[Property]: # Per the language spec, the inputs of a ManagerWorkers are the inputs of its - # group manager (same name and type): the manager drives the conversation and - # is the component whose prompt the runtime renders. The hasattr guard matches - # Flow._get_inferred_inputs: validators can run this against a - # partially-constructed model with no group_manager yet. - return (self.group_manager.inputs or []) if hasattr(self, "group_manager") else [] + # group manager (same name and type): the manager drives the conversation. + return self.group_manager.inputs or [] def _get_inferred_outputs(self) -> List[Property]: # Symmetric with the inferred inputs: the group manager's outputs. - return (self.group_manager.outputs or []) if hasattr(self, "group_manager") else [] + return self.group_manager.outputs or [] + + def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: + min_version = super()._infer_min_agentspec_version_from_configuration() + # Inheritance of manager's inputs and outputs was introduced in 26.2.0 + if self.group_manager.inputs or self.group_manager.outputs: + min_version = max(min_version, AgentSpecVersionEnum.v26_2_0) + return min_version @model_validator_with_error_accumulation def _validate_one_or_more_workers(self) -> Self: @@ -97,21 +101,23 @@ def _validate_group_manager_is_not_included_as_a_worker(self) -> Self: def _validate_ios_match_group_manager_ios(self) -> Self: # Per the language spec, the I/Os of a ManagerWorkers must be the I/Os of its # group manager, same name and type. The base ComponentWithIO validators - # already enforce matching titles; enforce matching types here. - if not hasattr(self, "group_manager"): - return self - for kind, own_properties, manager_properties in ( - ("input", self.inputs or [], self.group_manager.inputs or []), - ("output", self.outputs or [], self.group_manager.outputs or []), + # already enforce matching titles; use the shared property helper here so + # nested JSON Schema types are compared correctly as well. + for kind, own_properties, manager_properties, explicitly_provided in ( + ("input", self.inputs or [], self.group_manager.inputs or [], "inputs"), + ("output", self.outputs or [], self.group_manager.outputs or [], "outputs"), ): - manager_type_by_title = {p.title: p.type for p in manager_properties} + if explicitly_provided not in self.model_fields_set: + continue + manager_property_by_title = {p.title: p for p in manager_properties} for own_property in own_properties: - manager_type = manager_type_by_title.get(own_property.title) - if manager_type is not None and own_property.type != manager_type: + manager_property = manager_property_by_title.get(own_property.title) + if manager_property is not None and not properties_have_same_type( + own_property, manager_property + ): raise ValueError( f"The {kind}s of a `ManagerWorkers` must match the {kind}s of its " f"group manager (same name and type), but {kind} " - f"`{own_property.title}` has type `{own_property.type}` while the " - f"group manager declares `{manager_type}`." + f"`{own_property.title}` has a different type from the group manager." ) return self diff --git a/pyagentspec/tests/serialization/test_managerworkers.py b/pyagentspec/tests/serialization/test_managerworkers.py index fefb4271..693b3915 100644 --- a/pyagentspec/tests/serialization/test_managerworkers.py +++ b/pyagentspec/tests/serialization/test_managerworkers.py @@ -9,6 +9,7 @@ from pyagentspec.agent import Agent from pyagentspec.llms import VllmConfig from pyagentspec.managerworkers import ManagerWorkers +from pyagentspec.property import StringProperty from pyagentspec.serialization import AgentSpecDeserializer, AgentSpecSerializer from pyagentspec.versioning import AgentSpecVersionEnum @@ -109,3 +110,26 @@ def test_deserializing_managerworkers_with_unsupported_version_raises_error( with pytest.raises(ValueError, match="Invalid agentspec_version"): AgentSpecDeserializer().from_yaml(serialized_managerworkers) + + +def test_managerworkers_infers_manager_ios_in_current_version() -> None: + llm_config = VllmConfig(name="model", model_id="model_id", url="https://example.com") + manager = Agent( + name="manager", + llm_config=llm_config, + system_prompt="Manage the team.", + outputs=[StringProperty(title="answer")], + ) + worker = Agent(name="worker", llm_config=llm_config, system_prompt="Help the manager.") + manager_workers = ManagerWorkers( + name="team", + group_manager=manager, + workers=[worker], + ) + + assert manager_workers.outputs == manager.outputs + assert manager_workers.min_agentspec_version == AgentSpecVersionEnum.current_version + with pytest.raises(ValueError, match="Invalid agentspec_version"): + AgentSpecSerializer().to_dict( + manager_workers, agentspec_version=AgentSpecVersionEnum.v25_4_2 + ) diff --git a/pyagentspec/tests/validation/test_agentic_patterns_validation.py b/pyagentspec/tests/validation/test_agentic_patterns_validation.py index f951ce3a..2eeab694 100644 --- a/pyagentspec/tests/validation/test_agentic_patterns_validation.py +++ b/pyagentspec/tests/validation/test_agentic_patterns_validation.py @@ -14,7 +14,7 @@ from pyagentspec.flows.nodes.startnode import StartNode from pyagentspec.llms import OpenAiConfig from pyagentspec.managerworkers import ManagerWorkers -from pyagentspec.property import FloatProperty, StringProperty +from pyagentspec.property import FloatProperty, ListProperty, StringProperty from pyagentspec.swarm import Swarm @@ -135,6 +135,19 @@ def test_managerworkers_with_ios_not_matching_the_group_manager_raises_errors() outputs=[FloatProperty(title="answer")], ) + # The comparison must also inspect nested schema types, rather than only the + # top-level ``array`` type. + list_output_manager = manager_agent.model_copy( + update={"outputs": [ListProperty(title="answer", item_type=StringProperty())]} + ) + with pytest.raises(ValueError, match="must match the outputs of its group manager"): + ManagerWorkers( + name="managerworkers", + group_manager=list_output_manager, + workers=[worker_agent], + outputs=[ListProperty(title="answer", item_type=FloatProperty())], + ) + # A title the group manager does not declare is rejected by the base # ComponentWithIO validation. with pytest.raises(ValueError, match="expected only properties with the titles"): diff --git a/tsagentspec/src/agents/manager-workers.ts b/tsagentspec/src/agents/manager-workers.ts index 5e32e4e1..03aaea42 100644 --- a/tsagentspec/src/agents/manager-workers.ts +++ b/tsagentspec/src/agents/manager-workers.ts @@ -3,7 +3,7 @@ */ import { z } from "zod"; import { ComponentWithIOSchema } from "../component.js"; -import type { Property } from "../property.js"; +import { propertiesHaveSameType, type Property } from "../property.js"; // z.record(z.unknown()) is used instead of AgenticComponentUnion to break a circular // dependency (ManagerWorkers -> AgenticComponentUnion -> ManagerWorkers). Validation of @@ -18,6 +18,43 @@ export const ManagerWorkersSchema = ComponentWithIOSchema.extend({ export type ManagerWorkers = z.infer; +function getComponentProperties( + component: Record, + field: "inputs" | "outputs", +): Property[] { + const properties = component[field]; + return Array.isArray(properties) ? (properties as Property[]) : []; +} + +function validatePropertiesMatchManager( + properties: Property[], + managerProperties: Property[], + kind: "inputs" | "outputs", +): void { + const managerPropertiesByTitle = new Map( + managerProperties.map((property) => [property.title, property]), + ); + const propertiesByTitle = new Map( + properties.map((property) => [property.title, property]), + ); + if ( + propertiesByTitle.size !== properties.length || + propertiesByTitle.size !== managerPropertiesByTitle.size + ) { + throw new Error( + `The ${kind} of a ManagerWorkers must match the ${kind} of its group manager.`, + ); + } + for (const property of properties) { + const managerProperty = managerPropertiesByTitle.get(property.title); + if (!managerProperty || !propertiesHaveSameType(property, managerProperty)) { + throw new Error( + `The ${kind} of a ManagerWorkers must match the ${kind} of its group manager.`, + ); + } + } +} + export function createManagerWorkers(opts: { name: string; groupManager: Record; @@ -31,9 +68,19 @@ export function createManagerWorkers(opts: { if (opts.workers.some(w => w === opts.groupManager)) { throw new Error("Group manager cannot be a worker."); } + const managerInputs = getComponentProperties(opts.groupManager, "inputs"); + const managerOutputs = getComponentProperties(opts.groupManager, "outputs"); + if (opts.inputs !== undefined) { + validatePropertiesMatchManager(opts.inputs, managerInputs, "inputs"); + } + if (opts.outputs !== undefined) { + validatePropertiesMatchManager(opts.outputs, managerOutputs, "outputs"); + } return Object.freeze( ManagerWorkersSchema.parse({ ...opts, + inputs: opts.inputs ?? managerInputs, + outputs: opts.outputs ?? managerOutputs, componentType: "ManagerWorkers" as const, }), ); diff --git a/tsagentspec/tests/agents/manager-workers.test.ts b/tsagentspec/tests/agents/manager-workers.test.ts index 3f58b895..7daac563 100644 --- a/tsagentspec/tests/agents/manager-workers.test.ts +++ b/tsagentspec/tests/agents/manager-workers.test.ts @@ -3,6 +3,8 @@ import { createManagerWorkers, createAgent, createOpenAiCompatibleConfig, + numberProperty, + stringProperty, } from "../../src/index.js"; function makeLlmConfig() { @@ -118,4 +120,42 @@ describe("ManagerWorkers", () => { }), ).toThrow("Group manager cannot be a worker."); }); + + it("should infer the group manager's inputs and outputs", () => { + const topic = stringProperty({ title: "topic" }); + const answer = stringProperty({ title: "answer" }); + const manager = createAgent({ + name: "manager", + llmConfig: makeLlmConfig(), + systemPrompt: "Manage the team.", + inputs: [topic], + outputs: [answer], + }); + const mw = createManagerWorkers({ + name: "test-mw", + groupManager: manager, + workers: [makeAgent("worker")], + }); + + expect(mw.inputs).toEqual([topic]); + expect(mw.outputs).toEqual([answer]); + }); + + it("should reject explicit I/O that differs from the group manager", () => { + const manager = createAgent({ + name: "manager", + llmConfig: makeLlmConfig(), + systemPrompt: "Manage the team.", + outputs: [stringProperty({ title: "answer" })], + }); + + expect(() => + createManagerWorkers({ + name: "test-mw", + groupManager: manager, + workers: [makeAgent("worker")], + outputs: [numberProperty({ title: "answer" })], + }), + ).toThrow("outputs of a ManagerWorkers must match"); + }); }); From 3160835129ce983f578c18350d92d05833b8ff25 Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Mon, 24 Aug 2026 15:19:57 +0200 Subject: [PATCH 07/14] Fix validation --- .../howto_managerworkers.json | 13 +++++-- .../howto_managerworkers.yaml | 8 +++-- pyagentspec/src/pyagentspec/managerworkers.py | 23 ++++++++++--- .../serialization/test_managerworkers.py | 34 +++++++++++++++++++ 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json index 1159e7f9..492f715d 100644 --- a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json +++ b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json @@ -4,7 +4,16 @@ "name": "managerworkers", "description": null, "metadata": {}, - "inputs": [], + "inputs": [ + { + "title": "customer_id", + "type": "string" + }, + { + "title": "company_policy_info", + "type": "string" + } + ], "outputs": [], "group_manager": { "component_type": "Agent", @@ -208,5 +217,5 @@ "model_id": "llama-4-maverick" } }, - "agentspec_version": "26.1.0" + "agentspec_version": "26.2.0" } diff --git a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml index fb74f087..b3d31b4f 100644 --- a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml +++ b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml @@ -9,7 +9,11 @@ id: 248045cb-ca6f-4d6f-9e22-d28b452a25da name: managerworkers description: null metadata: {} -inputs: [] +inputs: +- title: company_policy_info + type: string +- title: customer_id + type: string outputs: [] group_manager: component_type: Agent @@ -225,4 +229,4 @@ $referenced_components: default_generation_parameters: null url: http://url.to.my.vllm.server/llama4mav model_id: llama-4-maverick -agentspec_version: 26.1.0 +agentspec_version: 26.2.0 diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index 497f8b5c..50f69b70 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -69,19 +69,34 @@ class ManagerWorkers(AgenticComponent): def _get_inferred_inputs(self) -> List[Property]: # Per the language spec, the inputs of a ManagerWorkers are the inputs of its # group manager (same name and type): the manager drives the conversation. - return self.group_manager.inputs or [] + return ( + self.group_manager.inputs or [] + if self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 + else [] + ) def _get_inferred_outputs(self) -> List[Property]: # Symmetric with the inferred inputs: the group manager's outputs. - return self.group_manager.outputs or [] + return ( + self.group_manager.outputs or [] + if self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 + else [] + ) def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: min_version = super()._infer_min_agentspec_version_from_configuration() - # Inheritance of manager's inputs and outputs was introduced in 26.2.0 - if self.group_manager.inputs or self.group_manager.outputs: + # ManagerWorkers I/O matching was introduced in 26.2.0. + if self.inputs or self.outputs: min_version = max(min_version, AgentSpecVersionEnum.v26_2_0) return min_version + def _infer_max_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: + max_version = super()._infer_max_agentspec_version_from_configuration() + # Before 26.2.0 a ManagerWorkers did not inherit its manager's I/O. + if (self.group_manager.inputs or self.group_manager.outputs) and not (self.inputs or self.outputs): + max_version = min(max_version, AgentSpecVersionEnum.v26_1_2) + return max_version + @model_validator_with_error_accumulation def _validate_one_or_more_workers(self) -> Self: if len(self.workers) == 0: diff --git a/pyagentspec/tests/serialization/test_managerworkers.py b/pyagentspec/tests/serialization/test_managerworkers.py index 693b3915..757d4f73 100644 --- a/pyagentspec/tests/serialization/test_managerworkers.py +++ b/pyagentspec/tests/serialization/test_managerworkers.py @@ -125,6 +125,7 @@ def test_managerworkers_infers_manager_ios_in_current_version() -> None: name="team", group_manager=manager, workers=[worker], + outputs=[StringProperty(title="answer")], ) assert manager_workers.outputs == manager.outputs @@ -133,3 +134,36 @@ def test_managerworkers_infers_manager_ios_in_current_version() -> None: AgentSpecSerializer().to_dict( manager_workers, agentspec_version=AgentSpecVersionEnum.v25_4_2 ) + + +def test_managerworkers_without_ios_with_manager_ios_is_legacy_compatible() -> None: + llm_config = VllmConfig(name="model", model_id="model_id", url="https://example.com") + manager = Agent( + name="manager", + llm_config=llm_config, + system_prompt="Manage {{question}}.", + inputs=[StringProperty(title="question")], + outputs=[StringProperty(title="answer")], + ) + worker = Agent(name="worker", llm_config=llm_config, system_prompt="Help the manager.") + manager_workers = ManagerWorkers( + name="team", + group_manager=manager, + workers=[worker], + inputs=[], + outputs=[], + ) + + assert manager_workers.inputs == [] + assert manager_workers.outputs == [] + assert manager_workers.min_agentspec_version == AgentSpecVersionEnum.v25_4_2 + assert manager_workers.max_agentspec_version == AgentSpecVersionEnum.v26_1_2 + + serialized = AgentSpecSerializer().to_dict( + manager_workers, agentspec_version=AgentSpecVersionEnum.v26_1_2 + ) + deserialized = AgentSpecDeserializer().from_dict(serialized) + + assert isinstance(deserialized, ManagerWorkers) + assert deserialized.inputs == [] + assert deserialized.outputs == [] From fee4bf83a2a91b242e99668e6ea838fd5965b3e7 Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Mon, 24 Aug 2026 15:35:18 +0200 Subject: [PATCH 08/14] Fix format --- pyagentspec/src/pyagentspec/managerworkers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index 50f69b70..8a6b0610 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -93,7 +93,9 @@ def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnu def _infer_max_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: max_version = super()._infer_max_agentspec_version_from_configuration() # Before 26.2.0 a ManagerWorkers did not inherit its manager's I/O. - if (self.group_manager.inputs or self.group_manager.outputs) and not (self.inputs or self.outputs): + if (self.group_manager.inputs or self.group_manager.outputs) and not ( + self.inputs or self.outputs + ): max_version = min(max_version, AgentSpecVersionEnum.v26_1_2) return max_version From f81b5ea79286924fbcdb57b1da080412eb17d20e Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Mon, 24 Aug 2026 16:02:02 +0200 Subject: [PATCH 09/14] Fix format --- pyagentspec/src/pyagentspec/managerworkers.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index 8a6b0610..d5db68a8 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -71,7 +71,8 @@ def _get_inferred_inputs(self) -> List[Property]: # group manager (same name and type): the manager drives the conversation. return ( self.group_manager.inputs or [] - if self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 + if getattr(self, "group_manager", None) + and self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 else [] ) @@ -79,22 +80,25 @@ def _get_inferred_outputs(self) -> List[Property]: # Symmetric with the inferred inputs: the group manager's outputs. return ( self.group_manager.outputs or [] - if self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 + if getattr(self, "group_manager", None) + and self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 else [] ) def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: min_version = super()._infer_min_agentspec_version_from_configuration() # ManagerWorkers I/O matching was introduced in 26.2.0. - if self.inputs or self.outputs: + if getattr(self, "inputs", []) or getattr(self, "outputs", []): min_version = max(min_version, AgentSpecVersionEnum.v26_2_0) return min_version def _infer_max_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: max_version = super()._infer_max_agentspec_version_from_configuration() # Before 26.2.0 a ManagerWorkers did not inherit its manager's I/O. - if (self.group_manager.inputs or self.group_manager.outputs) and not ( - self.inputs or self.outputs + if ( + getattr(self, "group_manager", None) + and (self.group_manager.inputs or self.group_manager.outputs) + and not (self.inputs or self.outputs) ): max_version = min(max_version, AgentSpecVersionEnum.v26_1_2) return max_version From bf30c66fcbc2c456b651361200fdf8ca2bbfcf7b Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Mon, 24 Aug 2026 17:01:22 +0200 Subject: [PATCH 10/14] Fix mypy issues --- .../src/pyagentspec/adapters/langgraph/_execution_span.py | 4 ++-- .../pyagentspec/adapters/langgraph/_langgraphconverter.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py index 033484c8..1fe7e0bb 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py @@ -83,5 +83,5 @@ async def patched_astream(*args: Any, **kwargs: Any) -> AsyncGenerator[Any, Any] finally: await _async_or_sync(span.end_async, span.end) - compiled_graph.stream = patched_stream # type: ignore[assignment] - compiled_graph.astream = patched_astream # type: ignore[assignment] + compiled_graph.stream = patched_stream # type: ignore[method-assign] + compiled_graph.astream = patched_astream # type: ignore[method-assign] diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 8929b1be..0ca55cb0 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -15,6 +15,7 @@ Awaitable, Callable, Dict, + Hashable, List, Optional, Tuple, @@ -1165,13 +1166,16 @@ def _manager_workers_convert_to_langgraph( builder.add_edge(node_name, _MANAGER_NODE_KEY) builder.add_edge(langgraph_graph.START, _MANAGER_NODE_KEY) + path_map: Dict[Hashable, str] = {} + for node_name in worker_node_names: + path_map[node_name] = node_name + path_map[langgraph_graph.END] = langgraph_graph.END builder.add_conditional_edges( _MANAGER_NODE_KEY, _make_manager_router(worker_node_names), # The path map covers every worker plus END, so langgraph can validate # the routing statically. - {node_name: node_name for node_name in worker_node_names} - | {langgraph_graph.END: langgraph_graph.END}, + path_map, ) compiled_graph = builder.compile(checkpointer=checkpointer, name=mw.name) From 4e77dcf0dcb4af8f224b6db0c50efd34e9db641f Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Tue, 25 Aug 2026 15:54:34 +0200 Subject: [PATCH 11/14] Post-release updates --- .../howto_managerworkers.json | 2 +- .../howto_managerworkers.yaml | 2 +- pyagentspec/src/pyagentspec/managerworkers.py | 12 ++++++------ .../tests/serialization/test_managerworkers.py | 6 +++--- tsagentspec/src/versioning.ts | 5 ++++- tsagentspec/tests/versioning.test.ts | 7 +++++-- 6 files changed, 20 insertions(+), 14 deletions(-) diff --git a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json index 492f715d..221088a6 100644 --- a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json +++ b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json @@ -217,5 +217,5 @@ "model_id": "llama-4-maverick" } }, - "agentspec_version": "26.2.0" + "agentspec_version": "26.4.0" } diff --git a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml index b3d31b4f..01bb633f 100644 --- a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml +++ b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml @@ -229,4 +229,4 @@ $referenced_components: default_generation_parameters: null url: http://url.to.my.vllm.server/llama4mav model_id: llama-4-maverick -agentspec_version: 26.2.0 +agentspec_version: 26.4.0 diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index d5db68a8..c2e25090 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -72,7 +72,7 @@ def _get_inferred_inputs(self) -> List[Property]: return ( self.group_manager.inputs or [] if getattr(self, "group_manager", None) - and self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 + and self.min_agentspec_version >= AgentSpecVersionEnum.v26_4_0 else [] ) @@ -81,26 +81,26 @@ def _get_inferred_outputs(self) -> List[Property]: return ( self.group_manager.outputs or [] if getattr(self, "group_manager", None) - and self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 + and self.min_agentspec_version >= AgentSpecVersionEnum.v26_4_0 else [] ) def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: min_version = super()._infer_min_agentspec_version_from_configuration() - # ManagerWorkers I/O matching was introduced in 26.2.0. + # ManagerWorkers I/O matching was introduced in 26.4.0. if getattr(self, "inputs", []) or getattr(self, "outputs", []): - min_version = max(min_version, AgentSpecVersionEnum.v26_2_0) + min_version = max(min_version, AgentSpecVersionEnum.v26_4_0) return min_version def _infer_max_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: max_version = super()._infer_max_agentspec_version_from_configuration() - # Before 26.2.0 a ManagerWorkers did not inherit its manager's I/O. + # Before 26.4.0 a ManagerWorkers did not inherit its manager's I/O. if ( getattr(self, "group_manager", None) and (self.group_manager.inputs or self.group_manager.outputs) and not (self.inputs or self.outputs) ): - max_version = min(max_version, AgentSpecVersionEnum.v26_1_2) + max_version = min(max_version, AgentSpecVersionEnum.v26_3_0) return max_version @model_validator_with_error_accumulation diff --git a/pyagentspec/tests/serialization/test_managerworkers.py b/pyagentspec/tests/serialization/test_managerworkers.py index 757d4f73..dba17837 100644 --- a/pyagentspec/tests/serialization/test_managerworkers.py +++ b/pyagentspec/tests/serialization/test_managerworkers.py @@ -132,7 +132,7 @@ def test_managerworkers_infers_manager_ios_in_current_version() -> None: assert manager_workers.min_agentspec_version == AgentSpecVersionEnum.current_version with pytest.raises(ValueError, match="Invalid agentspec_version"): AgentSpecSerializer().to_dict( - manager_workers, agentspec_version=AgentSpecVersionEnum.v25_4_2 + manager_workers, agentspec_version=AgentSpecVersionEnum.v26_3_0 ) @@ -157,10 +157,10 @@ def test_managerworkers_without_ios_with_manager_ios_is_legacy_compatible() -> N assert manager_workers.inputs == [] assert manager_workers.outputs == [] assert manager_workers.min_agentspec_version == AgentSpecVersionEnum.v25_4_2 - assert manager_workers.max_agentspec_version == AgentSpecVersionEnum.v26_1_2 + assert manager_workers.max_agentspec_version == AgentSpecVersionEnum.v26_3_0 serialized = AgentSpecSerializer().to_dict( - manager_workers, agentspec_version=AgentSpecVersionEnum.v26_1_2 + manager_workers, agentspec_version=AgentSpecVersionEnum.v26_3_0 ) deserialized = AgentSpecDeserializer().from_dict(serialized) diff --git a/tsagentspec/src/versioning.ts b/tsagentspec/src/versioning.ts index 94df5c04..fa566920 100644 --- a/tsagentspec/src/versioning.ts +++ b/tsagentspec/src/versioning.ts @@ -10,14 +10,17 @@ export const AgentSpecVersion = { V25_4_1: "25.4.1", V25_4_2: "25.4.2", V26_1_0: "26.1.0", + V26_1_2: "26.1.2", V26_2_0: "26.2.0", + V26_3_0: "26.3.0", + V26_4_0: "26.4.0", } as const; export type AgentSpecVersion = (typeof AgentSpecVersion)[keyof typeof AgentSpecVersion]; /** The current (latest) agent spec version */ -export const CURRENT_VERSION: AgentSpecVersion = AgentSpecVersion.V26_2_0; +export const CURRENT_VERSION: AgentSpecVersion = AgentSpecVersion.V26_4_0; /** Field name for the agentspec version in serialized JSON/YAML */ export const AGENTSPEC_VERSION_FIELD_NAME = "agentspec_version"; diff --git a/tsagentspec/tests/versioning.test.ts b/tsagentspec/tests/versioning.test.ts index d8b63f9c..06d13be4 100644 --- a/tsagentspec/tests/versioning.test.ts +++ b/tsagentspec/tests/versioning.test.ts @@ -17,12 +17,15 @@ describe("AgentSpecVersion", () => { expect(AgentSpecVersion.V25_4_1).toBe("25.4.1"); expect(AgentSpecVersion.V25_4_2).toBe("25.4.2"); expect(AgentSpecVersion.V26_1_0).toBe("26.1.0"); + expect(AgentSpecVersion.V26_1_2).toBe("26.1.2"); expect(AgentSpecVersion.V26_2_0).toBe("26.2.0"); + expect(AgentSpecVersion.V26_3_0).toBe("26.3.0"); + expect(AgentSpecVersion.V26_4_0).toBe("26.4.0"); }); it("should set CURRENT_VERSION to the latest version", () => { - expect(CURRENT_VERSION).toBe("26.2.0"); - expect(CURRENT_VERSION).toBe(AgentSpecVersion.V26_2_0); + expect(CURRENT_VERSION).toBe("26.4.0"); + expect(CURRENT_VERSION).toBe(AgentSpecVersion.V26_4_0); }); it("should define the version field name", () => { From 6c3b58352000e70c607cad7a6a6bf90150d0bcea Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Fri, 28 Aug 2026 19:20:25 +0200 Subject: [PATCH 12/14] Fix min/max version inference --- .../langgraph/_managerworkers_node.py | 5 ++-- pyagentspec/src/pyagentspec/managerworkers.py | 27 ++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py index f04c7815..74a0e700 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py @@ -7,9 +7,8 @@ """Runs a ``ManagerWorkers`` as a flow step. ``AgentSpecToLangGraphConverter._agent_node_convert_to_langgraph`` selects -:class:`ManagerWorkersNodeExecutor` when the node's agent is a ``ManagerWorkers``, -so :class:`~pyagentspec.adapters.langgraph._node_execution.AgentNodeExecutor` keeps -the plain-Agent behavior only. +`ManagerWorkersNodeExecutor` when the node's agent is a ``ManagerWorkers``, +so `AgentNodeExecutor` keeps the plain-Agent behavior only. """ from typing import Any, Dict, List, Optional, Tuple diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index c2e25090..8d9c7319 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -87,18 +87,33 @@ def _get_inferred_outputs(self) -> List[Property]: def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: min_version = super()._infer_min_agentspec_version_from_configuration() - # ManagerWorkers I/O matching was introduced in 26.4.0. - if getattr(self, "inputs", []) or getattr(self, "outputs", []): + # ManagerWorkers I/O matching was introduced in 26.4.0. Omitted I/O + # inherits from the group manager; explicitly empty I/O is legacy-only. + manager = getattr(self, "group_manager", None) + inherits_manager_io = bool( + manager + and ( + ("inputs" not in self.model_fields_set and manager.inputs) + or ("outputs" not in self.model_fields_set and manager.outputs) + ) + ) + if inherits_manager_io or getattr(self, "inputs", []) or getattr(self, "outputs", []): min_version = max(min_version, AgentSpecVersionEnum.v26_4_0) return min_version def _infer_max_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: max_version = super()._infer_max_agentspec_version_from_configuration() # Before 26.4.0 a ManagerWorkers did not inherit its manager's I/O. - if ( - getattr(self, "group_manager", None) - and (self.group_manager.inputs or self.group_manager.outputs) - and not (self.inputs or self.outputs) + manager = getattr(self, "group_manager", None) + inherits_manager_io = bool( + manager + and ( + ("inputs" not in self.model_fields_set and manager.inputs) + or ("outputs" not in self.model_fields_set and manager.outputs) + ) + ) + if manager and (manager.inputs or manager.outputs) and not inherits_manager_io and not ( + self.inputs or self.outputs ): max_version = min(max_version, AgentSpecVersionEnum.v26_3_0) return max_version From 3db690b52e7ee6badbfec4843fed2535fcfcff9b Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Mon, 31 Aug 2026 10:08:48 +0200 Subject: [PATCH 13/14] Format --- pyagentspec/src/pyagentspec/managerworkers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index 8d9c7319..11c9088c 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -112,8 +112,11 @@ def _infer_max_agentspec_version_from_configuration(self) -> AgentSpecVersionEnu or ("outputs" not in self.model_fields_set and manager.outputs) ) ) - if manager and (manager.inputs or manager.outputs) and not inherits_manager_io and not ( - self.inputs or self.outputs + if ( + manager + and (manager.inputs or manager.outputs) + and not inherits_manager_io + and not (self.inputs or self.outputs) ): max_version = min(max_version, AgentSpecVersionEnum.v26_3_0) return max_version From 264d328f9170df32d1f1e5c20ac292d2996860e8 Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Mon, 31 Aug 2026 14:50:19 +0200 Subject: [PATCH 14/14] Fixes --- .../adapters/langgraph/_managerworkers.py | 19 ++++++++++++------- .../adapters/langgraph/test_managerworkers.py | 5 +++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py index ea931b2a..9d2298fb 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py @@ -16,7 +16,11 @@ from typing import Annotated, Any, Dict, Iterable, List, Tuple from pyagentspec.adapters.langgraph._execution_span import patch_with_execution_span -from pyagentspec.adapters.langgraph._types import CompiledStateGraph, langgraph_graph +from pyagentspec.adapters.langgraph._types import ( + CompiledStateGraph, + RunnableConfig, + langgraph_graph, +) from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers from pyagentspec.tracing.events import ( ManagerWorkersExecutionEnd as AgentSpecManagerWorkersExecutionEnd, @@ -194,16 +198,17 @@ def _wrap_worker_for_subgraph( Hierarchical rather than shared-state like a Swarm: each run is handed only the manager's chosen task, and the worker's answer comes back as a ToolMessage so the manager's react loop sees a well-formed tool response on its next turn. The worker - is invoked with no explicit config and inherits this node's ambient run config, - which streams its token events under the worker node's checkpoint namespace. + receives this node's ambient run config explicitly, which streams its token events + under the worker node's checkpoint namespace. Explicit propagation is necessary on + Python 3.10, where LangChain cannot preserve the callback context across tasks. """ from pyagentspec.adapters.langgraph._types import RunnableLambda - def run(state: Dict[str, Any]) -> Dict[str, Any]: - return _worker_reply(state, worker_graph.invoke(_worker_input(state))) + def run(state: Dict[str, Any], config: RunnableConfig) -> Dict[str, Any]: + return _worker_reply(state, worker_graph.invoke(_worker_input(state), config=config)) - async def arun(state: Dict[str, Any]) -> Dict[str, Any]: - return _worker_reply(state, await worker_graph.ainvoke(_worker_input(state))) + async def arun(state: Dict[str, Any], config: RunnableConfig) -> Dict[str, Any]: + return _worker_reply(state, await worker_graph.ainvoke(_worker_input(state), config=config)) return RunnableLambda(func=run, afunc=arun, name=f"worker:{worker_node_name}") diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index 96cdc656..01a14389 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -390,6 +390,7 @@ def test_worker_events_stream_natively_namespaced_under_worker_node() -> None: from langchain_core.language_models.fake_chat_models import GenericFakeChatModel from langchain_core.messages import AIMessage, HumanMessage + from langchain_core.runnables import RunnableConfig from langgraph.graph import END, START, MessagesState, StateGraph from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph @@ -398,8 +399,8 @@ def test_worker_events_stream_natively_namespaced_under_worker_node() -> None: 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"])]} + async def _wagent(state: Any, config: RunnableConfig) -> Any: + return {"messages": [await wmodel.ainvoke(state["messages"], config=config)]} wb.add_node("agent", _wagent) wb.add_edge(START, "agent")