diff --git a/pyagentspec/src/pyagentspec/adapters/_tools_common.py b/pyagentspec/src/pyagentspec/adapters/_tools_common.py index 6592be46..01925e70 100644 --- a/pyagentspec/src/pyagentspec/adapters/_tools_common.py +++ b/pyagentspec/src/pyagentspec/adapters/_tools_common.py @@ -16,7 +16,11 @@ maybe_warn_about_unrestricted_templated_url, validate_url_against_allow_list, ) -from pyagentspec.adapters._utils import render_nested_object_template, render_template +from pyagentspec.adapters._utils import ( + render_nested_json_template, + render_nested_object_template, + render_template, +) from pyagentspec.retrypolicy import RetryPolicy from pyagentspec.tools.remotetool import RemoteTool as AgentSpecRemoteTool @@ -38,7 +42,10 @@ def _create_remote_tool_func(remote_tool: AgentSpecRemoteTool) -> Callable[..., ) def _remote_tool(**kwargs: Any) -> Any: - remote_tool_data = render_nested_object_template(remote_tool.data, kwargs) + # The body is JSON: preserve the type of whole-placeholder values so + # structured tool arguments (arrays/objects/numbers/None) survive instead + # of being stringified to a Python repr. URL/headers/query stay strings. + remote_tool_data = render_nested_json_template(remote_tool.data, kwargs) remote_tool_headers = { render_template(k, kwargs): render_nested_object_template(v, kwargs) for k, v in remote_tool.headers.items() @@ -87,7 +94,20 @@ def _remote_tool(**kwargs: Any) -> Any: response = _request_with_retry(remote_tool.retry_policy, request_kwargs) if remote_tool.retry_policy is not None and not response.is_success: response.raise_for_status() - return response.json() + try: + return response.json() + except ValueError as exc: + # A body that isn't JSON is almost always an error page the status + # would have explained — but a tool with no retry policy doesn't + # check the status (a non-2xx JSON error body is handed to the agent + # to read), so the decode failure was the only thing surfaced: + # "Expecting value: line 1 column 1 (char 0)", with no clue that the + # backend answered 401 or served HTML. + raise ValueError( + f"{remote_tool.name} returned {response.status_code} " + f"{response.headers.get('content-type', 'no content-type')}, " + f"which is not JSON: {response.text[:200]!r}" + ) from exc return _remote_tool diff --git a/pyagentspec/src/pyagentspec/adapters/_utils.py b/pyagentspec/src/pyagentspec/adapters/_utils.py index e221d613..6c92d38a 100644 --- a/pyagentspec/src/pyagentspec/adapters/_utils.py +++ b/pyagentspec/src/pyagentspec/adapters/_utils.py @@ -74,6 +74,57 @@ def render_template(template: Any, inputs: Dict[str, Any]) -> str: return _render_template_placeholders(template, inputs) +def _to_jsonable(value: Any) -> Any: + """Normalize a value into JSON-compatible primitives/containers. + + Pydantic models (e.g. the per-input models built from a tool's input schema) + are dumped to plain dicts so they serialize correctly into a JSON request + body; nested structures are converted recursively. + """ + if isinstance(value, BaseModel): + return value.model_dump(mode="json") + if isinstance(value, dict): + return {k: _to_jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple, set)): + return [_to_jsonable(item) for item in value] + return value + + +def render_nested_json_template(object: Any, inputs: Dict[str, Any]) -> Any: + """Render a template destined for a JSON request body. + + Behaves like :func:`render_nested_object_template`, except a string *value* + that is exactly one placeholder (e.g. ``"{{members}}"``) is replaced by the + raw input value with its type preserved (list/dict/number/bool/None) — so + structured tool arguments survive as JSON instead of being stringified into a + Python ``repr`` (``"[membersItem(...)]"``) or ``"None"``. Strings with + surrounding text keep ordinary interpolation, and dict keys are always + rendered as strings. + """ + if isinstance(object, str): + return _render_json_leaf(object, inputs) + elif isinstance(object, bytes): + return render_nested_json_template(object.decode("utf-8", errors="replace"), inputs) + elif isinstance(object, dict): + return { + render_template(k, inputs): render_nested_json_template(v, inputs) + for k, v in object.items() + } + elif isinstance(object, list) or isinstance(object, set) or isinstance(object, tuple): + return object.__class__([render_nested_json_template(item, inputs) for item in object]) + else: + return object + + +def _render_json_leaf(template: str, inputs: Dict[str, Any]) -> Any: + """Whole-placeholder string -> raw (JSON-able) value; otherwise interpolate.""" + stripped = template.strip() + matches = list(re.finditer(TEMPLATE_PLACEHOLDER_REGEXP, stripped)) + if len(matches) == 1 and matches[0].group(0) == stripped and matches[0].group(1) in inputs: + return _to_jsonable(inputs[matches[0].group(1)]) + return _render_template_placeholders(template, inputs) + + def _render_template_placeholders(template: str, inputs: Dict[str, Any]) -> str: """Render placeholders found in the original template using the list of inputs.""" rendered_parts: List[str] = [] @@ -137,6 +188,14 @@ def _build_type_from_schema( return List[item_type] # type: ignore # objects if t == "object" or ("properties" in schema or "required" in schema): + props = schema.get("properties", {}) or {} + # An object schema with no declared properties accepts any object + # (JSON Schema semantics). Building an empty create_model() here would + # silently strip every key on validation (pydantic defaults to + # extra="ignore"), so the tool receives {} instead of the LLM's + # arguments. Map it to a passthrough dict instead. + if not props and schema.get("additionalProperties") is not False: + return Dict[str, Any] # Create or reuse a Pydantic model for this object schema model_name = schema.get("title") or name unique_name = model_name @@ -145,7 +204,6 @@ def _build_type_from_schema( suffix += 1 unique_name = f"{model_name}_{suffix}" - props = schema.get("properties", {}) or {} required = set(schema.get("required", [])) fields: Dict[str, Tuple[Any, Any]] = {} diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index fb54281f..4524e094 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -5,8 +5,10 @@ # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. +import asyncio import inspect import logging +import re import sys from typing import ( TYPE_CHECKING, @@ -39,6 +41,7 @@ from pyagentspec.adapters.langgraph._node_execution import ( NodeExecutor, extract_outputs_from_invoke_result, + is_single_string_output, ) from pyagentspec.adapters.langgraph._types import ( AgentState, @@ -96,7 +99,9 @@ from pyagentspec.llms.openaicompatibleconfig import OpenAIAPIType, OpenAiCompatibleConfig from pyagentspec.llms.openaiconfig import OpenAiConfig from pyagentspec.llms.vllmconfig import VllmConfig +from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers from pyagentspec.mcp.clienttransport import ClientTransport as AgentSpecClientTransport +from pyagentspec.mcp.clienttransport import RemoteTransport as AgentSpecRemoteTransport from pyagentspec.mcp.clienttransport import SSEmTLSTransport as AgentSpecSSEmTLSTransport from pyagentspec.mcp.clienttransport import SSETransport as AgentSpecSSETransport from pyagentspec.mcp.clienttransport import StdioTransport as AgentSpecStdioTransport @@ -126,8 +131,17 @@ from pyagentspec.tracing.events import AgentExecutionStart as AgentSpecAgentExecutionStart from pyagentspec.tracing.events import FlowExecutionEnd as AgentSpecFlowExecutionEnd from pyagentspec.tracing.events import FlowExecutionStart as AgentSpecFlowExecutionStart +from pyagentspec.tracing.events import ( + ManagerWorkersExecutionEnd as AgentSpecManagerWorkersExecutionEnd, +) +from pyagentspec.tracing.events import ( + ManagerWorkersExecutionStart as AgentSpecManagerWorkersExecutionStart, +) from pyagentspec.tracing.spans import AgentExecutionSpan as AgentSpecAgentExecutionSpan from pyagentspec.tracing.spans import FlowExecutionSpan as AgentSpecFlowExecutionSpan +from pyagentspec.tracing.spans import ( + ManagerWorkersExecutionSpan as AgentSpecManagerWorkersExecutionSpan, +) if TYPE_CHECKING: from langchain_mcp_adapters.sessions import ( @@ -189,6 +203,21 @@ def _exec_body(ns: Dict[str, Any]) -> None: ) +def _remote_transport_headers( + transport: AgentSpecRemoteTransport, +) -> Optional[Dict[str, str]]: + """Return the headers to send on the wire for a remote MCP transport. + + A ``RemoteTransport`` carries both ``headers`` and ``sensitive_headers``, + validated to be disjoint. ``sensitive_headers`` is only redacted from + *exported* configs (so credentials never leak into a saved spec) -- it must + still travel on live requests. Merge both so a header configured as + sensitive (e.g. an ``Authorization`` token) actually reaches the server. + """ + merged = {**(transport.headers or {}), **(transport.sensitive_headers or {})} + return merged or None + + class AgentSpecToLangGraphConverter: def convert( self, @@ -267,6 +296,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): @@ -785,13 +823,41 @@ def _tool_node_convert_to_langgraph( ) -> "NodeExecutor": from pyagentspec.adapters.langgraph._node_execution import ToolNodeExecutor - tool = self.convert( - tool_node.tool, - tool_registry=tool_registry, - converted_components=converted_components, - checkpointer=checkpointer, - config=config, - ) + agentspec_tool = tool_node.tool + tool_outputs = agentspec_tool.outputs or [] + + # When a confirmation tool declares multiple outputs, a denial returns a single + # string that cannot be mapped to those outputs. Bypass self.convert() for all + # affected tool types so we can pass raise_on_denial=True: on rejection the + # tool raises RuntimeError with a clear message instead of an opaque crash. + if agentspec_tool.requires_confirmation and len(tool_outputs) > 1: + _ensure_checkpointer_and_valid_tool_config(agentspec_tool, checkpointer) + if isinstance(agentspec_tool, AgentSpecServerTool): + tool = self._server_tool_convert_to_langgraph( + agentspec_tool, tool_registry, config=config, raise_on_denial=True + ) + elif isinstance(agentspec_tool, AgentSpecRemoteTool): + tool = self._remote_tool_convert_to_langgraph( + agentspec_tool, config=config, raise_on_denial=True + ) + elif isinstance(agentspec_tool, AgentSpecClientTool): + tool = self._client_tool_convert_to_langgraph(agentspec_tool, raise_on_denial=True) + else: + raise ValueError( + f"Tool '{agentspec_tool.name}' of type " + f"'{type(agentspec_tool).__name__}' declares multiple outputs and " + f"requires_confirmation=True inside Flow ToolNode '{tool_node.name}'. " + f"Multi-output confirmation is supported for ServerTool, RemoteTool, " + f"and ClientTool. Use a single output or a supported tool type." + ) + else: + tool = self.convert( + tool_node.tool, + tool_registry=tool_registry, + converted_components=converted_components, + checkpointer=checkpointer, + config=config, + ) return ToolNodeExecutor(tool_node, tool) @@ -809,6 +875,7 @@ def _remote_tool_convert_to_langgraph( self, remote_tool: AgentSpecRemoteTool, config: RunnableConfig, + raise_on_denial: bool = False, ) -> StructuredTool: tool_name = remote_tool.name tool_description = remote_tool.description or "" @@ -816,6 +883,7 @@ def _remote_tool_convert_to_langgraph( func=_create_remote_tool_func(remote_tool), tool_name=tool_name, requires_confirmation=remote_tool.requires_confirmation, + raise_on_denial=raise_on_denial, ) # Use a Pydantic model for args_schema @@ -841,6 +909,7 @@ def _server_tool_convert_to_langgraph( agentspec_server_tool: AgentSpecServerTool, tool_registry: Dict[str, LangGraphTool], config: RunnableConfig, + raise_on_denial: bool = False, ) -> StructuredTool: def _is_structured_tool(x: Any) -> TypeGuard[StructuredTool]: return isinstance(x, StructuredTool) @@ -869,6 +938,7 @@ def _is_structured_tool(x: Any) -> TypeGuard[StructuredTool]: tool_obj, tool_name=tool_name, requires_confirmation=requires_confirmation, + raise_on_denial=raise_on_denial, ) if _is_structured_tool(tool_obj): if not tool_callable_kwargs: @@ -918,7 +988,7 @@ def _is_structured_tool(x: Any) -> TypeGuard[StructuredTool]: ) def _client_tool_convert_to_langgraph( - self, agentspec_client_tool: AgentSpecClientTool + self, agentspec_client_tool: AgentSpecClientTool, raise_on_denial: bool = False ) -> StructuredTool: # Warn at load time for Python < 3.11 since client tools use interrupt under the hood. if sys.version_info < (3, 11): @@ -938,6 +1008,11 @@ def client_tool(*args: Any, **kwargs: Any) -> Any: confirmed, reason = _confirm_tool_use(tool_name, **kwargs) if not confirmed: + if raise_on_denial: + raise RuntimeError( + f"Tool '{tool_name}' was denied by the user (reason: {reason}). " + f"Denial cannot be mapped to multiple declared outputs." + ) return f"Tool '{tool_name}' was denied execution by the user. Reason: {reason}" tool_request = { @@ -1048,61 +1123,288 @@ def _swarm_convert_to_langgraph( raise ValueError( "Handoff mode NEVER is not supported for conversion in LangGraph adapter" ) - agents: dict[str, AgentSpecAgent] = { - # LangGraph distinguishes agents by name, so we use names here. - # We also assume to get only agents in relationships. - agent.name: cast(AgentSpecAgent, agent) - # Relationships are tuples of (from_agent, to_agent) - for agent in (e for r in agentspec_component.relationships for e in r) + members: Dict[str, AgentSpecComponent] = { + # LangGraph distinguishes members by name, so we key by name. + member.name: member + # Relationships are tuples of (from_member, to_member) + for member in (e for r in agentspec_component.relationships for e in r) } - for agent in agents.values(): - # Since handoff is performed with tools, we can only support agents in relationships for now - # Note that the fact that we called `cast` before does not change the actual type of the agent - if not isinstance(agent, AgentSpecAgent): - raise ValueError( - f"Only Agents are supported as part of a Swarm in the LangGraph adapter, received {type(agent)} instead." + for member in members.values(): + # Swarm handoff is driven by an LLM emitting a transfer tool-call, + # so a member must run a chat loop the handoff tools can attach to: + # a plain Agent, or a ManagerWorkers (whose group-manager Agent + # makes the decision). A Flow / nested Swarm has no single + # "decide-the-handoff" LLM, so it cannot participate yet. + if not isinstance(member, (AgentSpecAgent, AgentSpecManagerWorkers)): + raise NotImplementedError( + "Only Agent and ManagerWorkers members are supported as part " + f"of a Swarm in the LangGraph adapter, received " + f"{type(member).__name__} instead." ) - # We convert the agents event though we do not use them in langgraph, since we have to append - # the handoff tools, but at least this way the agents will be created and stored in the registry - # of converted components in case they are used in other places + # We convert each member even though we do not use this copy in the + # swarm (the handoff-enabled copy is built below), so it is created + # and stored in the converted-components registry in case it is + # referenced elsewhere in the spec. self.convert( - agent, + member, tool_registry=tool_registry, converted_components=converted_components, checkpointer=checkpointer, config=config, middleware=middleware, ) - handoffs: dict[str, list[str]] = {agent_name: [] for agent_name in agents} - for from_agent, to_agent in agentspec_component.relationships: - handoffs[from_agent.name].append(to_agent.name) - # We re-create the agents with the additional handoff tools - langgraph_agents: list[CompiledStateGraph[Any, Any, Any]] = [ - self._create_react_agent_with_given_info( - agent=agent, - name=agent.name, - system_prompt=agent.system_prompt, - llm_config=agent.llm_config, - tools=agent.tools, - toolboxes=agent.toolboxes, - inputs=agent.inputs or [], - outputs=agent.outputs or [], + handoffs: Dict[str, List[str]] = {name: [] for name in members} + for from_member, to_member in agentspec_component.relationships: + handoffs[from_member.name].append(to_member.name) + # We re-create each member equipped with the handoff tools that let it + # transfer the conversation to its related members. + langgraph_members: List[CompiledStateGraph[Any, Any, Any]] = [] + for member in members.values(): + destinations = handoffs.get(member.name, []) + if isinstance(member, AgentSpecManagerWorkers): + # A ManagerWorkers member gets its handoff tools wired onto its + # group-manager and re-emitted to the swarm — see + # `_manager_workers_convert_to_langgraph`. + langgraph_members.append( + self._manager_workers_convert_to_langgraph( + member, + tool_registry=tool_registry, + converted_components=converted_components, + checkpointer=checkpointer, + config=config, + middleware=middleware, + swarm_handoff_destinations=destinations, + ) + ) + continue + agent = cast(AgentSpecAgent, member) + langgraph_members.append( + self._create_react_agent_with_given_info( + agent=agent, + name=agent.name, + system_prompt=agent.system_prompt, + llm_config=agent.llm_config, + tools=agent.tools, + toolboxes=agent.toolboxes, + inputs=agent.inputs or [], + outputs=agent.outputs or [], + tool_registry=tool_registry, + converted_components=converted_components, + checkpointer=checkpointer, + config=config, + middleware=middleware, + additional_langgraph_tools=[ + langgraph_swarm.create_handoff_tool(agent_name=to_name) + for to_name in destinations + ], + ) + ) + + # Same reason as the ManagerWorkers parent graph: a member built with + # `response_format` writes `structured_response` in its own subgraph state, and + # the swarm's state schema has to declare the channel for that update to survive. + # Only members with declared outputs get a `response_format`, so only they write it. + class _SwarmState(langgraph_swarm.SwarmState, total=False): # type: ignore[misc] + structured_response: Dict[str, Any] + + return langgraph_swarm.create_swarm( + agents=langgraph_members, # type: ignore + default_active_agent=agentspec_component.first_agent.name, + state_schema=_SwarmState, + ).compile(name=agentspec_component.name, checkpointer=checkpointer) + + def _manager_workers_convert_to_langgraph( + self, + mw: AgentSpecManagerWorkers, + tool_registry: Dict[str, "LangGraphTool"], + converted_components: Dict[str, Any], + checkpointer: Optional[Checkpointer], + config: RunnableConfig, + middleware: List[Any], + swarm_handoff_destinations: Optional[List[str]] = None, + ) -> CompiledStateGraph[Any, Any, Any]: + """Compile a ``ManagerWorkers`` into a hierarchical LangGraph. + + 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(...)``. + + ``swarm_handoff_destinations`` is set only when this ManagerWorkers is + a member of a ``Swarm``: it lists the sibling member names this MW may + hand the conversation off to. Each becomes a synthetic + ``transfer_to_`` tool on the manager, plus a ``__handoff__`` + parent node that re-emits the handoff up to the Swarm (the manager's + own ``Command(graph=PARENT)`` would stop one level short, at this + ManagerWorkers graph). When ``None``/empty (the standalone, Flow-step, + or nested-worker case) no handoff machinery is added and behaviour is + unchanged. + """ + if not isinstance(mw.group_manager, AgentSpecAgent): + # Pyagentspec allows any AgenticComponent as group_manager, + # 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, - additional_langgraph_tools=[ - langgraph_swarm.create_handoff_tool(agent_name=to_agent_name) - for to_agent_name in handoffs.get(agent.name, []) - ], ) - for agent in agents.values() + + # 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 ] - return langgraph_swarm.create_swarm( - agents=langgraph_agents, # type: ignore - default_active_agent=agentspec_component.first_agent.name, - ).compile(name=agentspec_component.name, checkpointer=checkpointer) + + # 3b. When this ManagerWorkers is a Swarm member, synthesize one + # transfer_to_ tool per allowed handoff destination. Like + # the delegation tools these are placeholders: the manager's LLM + # emits the call, the parent graph intercepts it (routing edge + # below) and the `__handoff__` node re-emits the handoff to the + # Swarm. We map the (normalized) tool name back to the raw sibling + # name, which is the Swarm node name used as the handoff `goto`. + handoff_dest_by_tool_name: Dict[str, str] = {} + handoff_tools: List[Any] = [] + for dest in swarm_handoff_destinations or []: + handoff_tool = _make_swarm_handoff_tool(dest) + handoff_tools.append(handoff_tool) + handoff_dest_by_tool_name[handoff_tool.name] = dest + + # 4. Compile the manager as a react-agent with the delegation tools + # (and, for a Swarm member, the transfer tools). + manager_graph = self._create_react_agent_with_given_info( + name=manager_agent.name, + system_prompt=rendered_prompt, + 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 + handoff_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 + + # The group manager is a react agent built with `response_format`, so it writes + # its structured answer to the `structured_response` channel of its own subgraph + # state. LangGraph drops a subgraph's updates to channels the parent does not + # declare, so the parent declares it too — otherwise the answer dies here and an + # AgentNode wrapping this ManagerWorkers sees its declared outputs unresolved. + # Only the manager writes it: `_wrap_worker_for_subgraph` returns `messages` alone. + class _ManagerWorkersState(MessagesState, total=False): + structured_response: Dict[str, Any] + + manager_node_key = _MANAGER_NODE_KEY + if manager_node_key in worker_graphs: + raise ValueError( + f"Worker name '{manager_node_key}' is reserved for the " + f"manager node in ManagerWorkers; rename the worker." + ) + + builder = StateGraph(_ManagerWorkersState) + 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; the Swarm-handoff branch is + # added only when this MW is a swarm member with handoff destinations. + routing_path_map: Dict[str, str] = {node_name: node_name for node_name in worker_node_names} + routing_path_map[langgraph_graph.END] = langgraph_graph.END + if handoff_dest_by_tool_name: + if _HANDOFF_NODE_KEY in worker_graphs: + raise ValueError( + f"Worker name '{_HANDOFF_NODE_KEY}' is reserved for the " + f"Swarm-handoff node in ManagerWorkers; rename the worker." + ) + builder.add_node( + _HANDOFF_NODE_KEY, + _make_handoff_forward_node(handoff_dest_by_tool_name), + ) + routing_path_map[_HANDOFF_NODE_KEY] = _HANDOFF_NODE_KEY + + builder.add_edge(langgraph_graph.START, manager_node_key) + builder.add_conditional_edges( + manager_node_key, + _route_manager_to_worker_handoff_or_end, + routing_path_map, + ) + for node_name in worker_node_names: + builder.add_edge(node_name, manager_node_key) + # The `__handoff__` node has no outgoing edge on purpose: it returns a + # Command(goto=, graph=PARENT) that exits this graph into the + # Swarm, so looping it back to the manager would be wrong. + + compiled_graph = builder.compile(checkpointer=checkpointer, name=mw.name) + + # 6. Tracing — wrap stream/astream so ManagerWorkersExecutionSpan + # surrounds each run. Mirrors the patches applied to Agent and + # Flow graphs above. + _patch_with_manager_workers_execution_span(compiled_graph, mw) + return compiled_graph def _create_react_agent_with_given_info( self, @@ -1156,8 +1458,12 @@ def _create_react_agent_with_given_info( output_model: Optional[type[BaseModel]] = None state_schema: Optional[Any] = None - # Build response (output) model (used for response_format) - if outputs: + # Build response (output) model (used for response_format). A single + # string output is taken from the agent's final message (see + # extract_outputs_from_invoke_result), so it needs no structured + # generation — mirrors LlmNodeExecutor and lets a string output work on + # models without structured-output support. + if outputs and not is_single_string_output(outputs): output_model = create_pydantic_model_from_properties("AgentOutputModel", outputs) if inputs: @@ -1458,7 +1764,7 @@ def _client_transport_convert_to_langgraph( return SSEConnection( transport="sse", url=agentspec_component.url, - headers=agentspec_component.headers, + headers=_remote_transport_headers(agentspec_component), httpx_client_factory=_HttpxClientFactory( key_file=agentspec_component.key_file, cert_file=agentspec_component.cert_file, @@ -1469,14 +1775,14 @@ def _client_transport_convert_to_langgraph( return SSEConnection( transport="sse", url=agentspec_component.url, - headers=agentspec_component.headers, + headers=_remote_transport_headers(agentspec_component), httpx_client_factory=_HttpxClientFactory(verify=True), ) if isinstance(agentspec_component, AgentSpecStreamableHTTPmTLSTransport): return StreamableHttpConnection( transport="streamable_http", url=agentspec_component.url, - headers=agentspec_component.headers, + headers=_remote_transport_headers(agentspec_component), httpx_client_factory=_HttpxClientFactory( key_file=agentspec_component.key_file, cert_file=agentspec_component.cert_file, @@ -1487,7 +1793,7 @@ def _client_transport_convert_to_langgraph( return StreamableHttpConnection( transport="streamable_http", url=agentspec_component.url, - headers=agentspec_component.headers, + headers=_remote_transport_headers(agentspec_component), httpx_client_factory=_HttpxClientFactory(verify=True), ) raise ValueError( @@ -1531,7 +1837,9 @@ async def load_all_mcp_tools() -> List[BaseTool]: description=tool.description, client_transport=client_transport, inputs=[ - AgentSpecProperty(title=arg_name, json_schema=arg_json_schema) + AgentSpecProperty( + title=arg_name, json_schema=_strip_schema_titles(arg_json_schema) + ) for arg_name, arg_json_schema in tool.args.items() ], outputs=[AgentSpecStringProperty(title="tool_output")], @@ -1763,6 +2071,40 @@ def _normalize_title(d: Dict[str, Any]) -> Dict[str, Any]: return out +def _strip_schema_titles(json_schema: Dict[str, Any]) -> Dict[str, Any]: + """Return a copy of ``json_schema`` with ``title`` annotations removed. + + MCP servers commonly derive tool schemas from OpenAPI documents whose + nested schemas carry human-readable titles (e.g. ``"Rich Text"``). + ``Property`` validates every title it can reach as an identifier and + rejects the whole schema, killing the run. The on-the-fly ``MCPTool`` + built from these schemas only backs the tracing callback (the LLM-facing + ``args_schema`` is untouched), and its port name is passed explicitly as + ``title=``, so the schema's own titles are safe to drop. + + Only the positions ``Property``'s validator traverses are visited — + ``items``, ``anyOf``, ``additionalProperties`` and ``properties`` values. + A generic recursive strip would corrupt non-schema payloads such as + ``default``/``examples`` values containing a ``title`` key. + """ + out = {key: value for key, value in json_schema.items() if key != "title"} + if isinstance(out.get("items"), dict): + out["items"] = _strip_schema_titles(out["items"]) + if isinstance(out.get("anyOf"), list): + out["anyOf"] = [ + _strip_schema_titles(inner) if isinstance(inner, dict) else inner + for inner in out["anyOf"] + ] + if isinstance(out.get("additionalProperties"), dict): + out["additionalProperties"] = _strip_schema_titles(out["additionalProperties"]) + if isinstance(out.get("properties"), dict): + out["properties"] = { + name: _strip_schema_titles(inner) if isinstance(inner, dict) else inner + for name, inner in out["properties"].items() + } + return out + + def _confirm_tool_use(tool_name: str, **tool_arguments: Any) -> Tuple[bool, str]: # aligned with https://docs.langchain.com/oss/python/langchain/human-in-the-loop#responding-to-interrupts ALLOWED_DECISIONS = ["approve", "reject"] @@ -1812,6 +2154,7 @@ def _confirm_then( func: Callable[..., Awaitable[Any]], tool_name: str, requires_confirmation: bool, + raise_on_denial: bool = ..., ) -> Callable[..., Awaitable[Any]]: ... @@ -1820,6 +2163,7 @@ def _confirm_then( func: Callable[..., Any], tool_name: str, requires_confirmation: bool, + raise_on_denial: bool = ..., ) -> Callable[..., Any]: ... @@ -1827,8 +2171,14 @@ def _confirm_then( func: Callable[..., Any], tool_name: str, requires_confirmation: bool, + raise_on_denial: bool = False, ) -> Callable[..., Any]: - """Wrap a callable so that it first interrupts for confirmation (if required).""" + """Wrap a callable so that it first interrupts for confirmation (if required). + + When raise_on_denial is True, denial raises RuntimeError instead of returning a + string. Use this inside a Flow ToolNode with multiple outputs where a denial + string cannot be mapped to the declared output structure. + """ if not requires_confirmation: return func @@ -1839,6 +2189,11 @@ async def _wrapped_async(*args: Any, **kwargs: Any) -> Any: confirmed, reason = _confirm_tool_use(tool_name, **confirmation_arguments) if not confirmed: + if raise_on_denial: + raise RuntimeError( + f"Tool '{tool_name}' was denied by the user (reason: {reason}). " + f"Denial cannot be mapped to multiple declared outputs." + ) return f"Tool '{tool_name}' was denied execution by the user. Reason: {reason}" return await func(*args, **kwargs) @@ -1850,6 +2205,11 @@ def _wrapped_sync(*args: Any, **kwargs: Any) -> Any: confirmed, reason = _confirm_tool_use(tool_name, **confirmation_arguments) if not confirmed: + if raise_on_denial: + raise RuntimeError( + f"Tool '{tool_name}' was denied by the user (reason: {reason}). " + f"Denial cannot be mapped to multiple declared outputs." + ) return f"Tool '{tool_name}' was denied execution by the user. Reason: {reason}" return func(*args, **kwargs) @@ -1870,7 +2230,7 @@ def _as_structured_tool_coroutine( return func async def _wrapped_async(*args: Any, **kwargs: Any) -> Any: - return func(*args, **kwargs) + return await asyncio.to_thread(func, *args, **kwargs) return _wrapped_async @@ -1884,6 +2244,7 @@ def _get_structured_tool_callable_kwargs( tool_obj: Union[StructuredTool, BaseTool, Callable[..., Any]], tool_name: str, requires_confirmation: bool = False, + raise_on_denial: bool = False, ) -> StructuredToolCallableKwargs: """Return the callables to pass to StructuredTool. @@ -1903,6 +2264,7 @@ def _get_structured_tool_callable_kwargs( func=tool_func, tool_name=tool_name, requires_confirmation=requires_confirmation, + raise_on_denial=raise_on_denial, ) tool_coroutine = getattr(tool_obj, "coroutine", None) @@ -1911,12 +2273,14 @@ def _get_structured_tool_callable_kwargs( func=tool_coroutine, tool_name=tool_name, requires_confirmation=requires_confirmation, + raise_on_denial=raise_on_denial, ) elif callable(tool_obj): wrapped_tool = _confirm_then( func=tool_obj, tool_name=tool_name, requires_confirmation=requires_confirmation, + raise_on_denial=raise_on_denial, ) if _is_async_callable(wrapped_tool): structured_tool_callable_kwargs["coroutine"] = wrapped_tool @@ -1941,13 +2305,592 @@ def _ensure_checkpointer_and_valid_tool_config( elif isinstance(agentspec_tool, AgentSpecClientTool) and checkpointer is None: raise ValueError(f"A Checkpointer is required when using ClientTool '{tool_name}'.") - tool_output = agentspec_tool.outputs or [] - if agentspec_tool.requires_confirmation and ( - len(tool_output) != 1 or "type" in tool_output[0].json_schema - ): - # TODO: refine to only raise output property does not support string - raise ValueError( - f"Invalid output schema for tool '{tool_name}' requiring tool confirmation: " - f"json schema should be left unspecified when using tool confirmation, was {tool_output}. " - f'Please use outputs=[Property(title="{tool_name}", json_schema={{}})]' + +# ─── ManagerWorkers helpers ────────────────────────────────────────────────── + +# 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_" + +# Prefix the manager's LLM uses to hand the conversation off to a sibling +# Swarm member (only present when this ManagerWorkers is a Swarm member). The +# suffix is the normalized sibling name; matches langgraph_swarm's convention. +_HANDOFF_TOOL_PREFIX = "transfer_to_" + +# Node key for the Swarm-handoff node in the ManagerWorkers parent StateGraph. +# Like ``_MANAGER_NODE_KEY`` it cannot collide with a normalized worker node +# name (which never contains leading/trailing underscores). +_HANDOFF_NODE_KEY = "__handoff__" + +# Keys carried on the per-delegation ``Send`` payload from the manager's +# routing edge to a worker node, so a worker run knows which task it was +# given and which ``tool_call_id`` its reply ToolMessage must answer. This +# 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__" + +# The manager's delegated task is forwarded to the worker as a HumanMessage so +# the model has a user turn to answer (a system-only turn yields an empty +# completion — see ``_worker_input``). Because the worker inherits the node's +# astream_events callbacks, that HumanMessage streams out to consumers, where it +# would otherwise render as a spurious end-user turn. We stamp this marker in the +# message's ``additional_kwargs`` so consumers can identify it as internal +# delegation plumbing (not an end-user turn) and drop/relabel it — without +# stripping anything from the stream (which breaks tool-call/result pairing and +# attribution). ``additional_kwargs`` is metadata: it survives serialization +# into the streamed ``on_chain_end`` message state and is not sent to the model +# provider for user-role messages. +_DELEGATION_TASK_MARKER_KEY = "pyagentspec_kind" +_DELEGATION_TASK_MARKER_VALUE = "delegation_task" + +# Collapses any run of whitespace to a single space so multi-line worker +# descriptions stay on one roster line. +_WHITESPACE_RE = re.compile(r"\s+") + + +def _safe_node_name(name: str, fallback_id: str) -> str: + """Normalize a worker name into a LangGraph node identifier. + + LangGraph node names must be hashable strings; in practice we want + ASCII-friendly identifiers that also work as Python attribute-ish + names (the LLM is going to see ``delegate_to_`` as a tool + name and needs to be able to emit it reliably). We lowercase, collapse + non-alphanumerics to underscores, strip surrounding underscores, and + fall back to the (component) id — normalized the same way — if the + name yields an empty string. Falling through both transforms keeps + node names internally consistent regardless of which input wins. + """ + + def _norm(s: str) -> str: + return re.sub(r"[^a-z0-9]+", "_", (s or "").lower()).strip("_") + + return _norm(name) or _norm(fallback_id) or "worker" + + +def _tc_get(tool_call: Any, key: str) -> Any: + """Read ``key`` off a tool call that may be a dict or a pydantic-style + object (langchain emits either depending on the message source).""" + if isinstance(tool_call, dict): + return tool_call.get(key) + return getattr(tool_call, key, None) + + +def _append_workers_roster( + system_prompt: str, + entries: List[Tuple[str, str]], +) -> 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 work to a worker subgraph. + + The tool body returns a ``Command(graph=Command.PARENT)`` which the + langchain ``ToolNode`` propagates up to the parent graph, breaking out + of the manager's react-agent inner loop. It deliberately carries **no + ``goto``**: routing is the parent graph's conditional edge's job + (:func:`_route_manager_to_worker_handoff_or_end`), which inspects the manager's + AIMessage and fans out one ``Send`` per ``delegate_to_`` call. + Routing via ``goto`` here would be wrong when the manager emits several + delegations in a single turn — ``ToolNode`` collapses the multiple + parent commands down to one, so only the first worker would run and the + other delegations' ``tool_call_id``s would be left unanswered. Each + worker run replies with a ToolMessage matched to its ``tool_call_id``; + on the next manager turn the LLM sees every ``AIMessage(tool_call)`` + + ``ToolMessage(worker_reply)`` pair and can produce its final answer — a + well-formed tool-call / tool-result sequence in the OpenAI contract. + + Modelled on ``langgraph_swarm.create_handoff_tool``, which uses the + same Command-propagation pattern for swarm handoffs. + """ + from typing import Annotated + + from langchain_core.tools import InjectedToolCallId, tool + from langgraph.prebuilt import InjectedState + from langgraph.types import Command + + tool_name = f"{_DELEGATE_TOOL_PREFIX}{worker_node_name}" + + @tool(tool_name) + def _delegate( + task: str, + state: Annotated[Any, InjectedState], + tool_call_id: Annotated[str, InjectedToolCallId], + ) -> Command: + """Delegate a task to the named worker and wait for its reply. + + ``task`` is the natural-language instruction the worker should + execute. The worker runs in its own isolated message context; + only this ``task`` is forwarded as the worker's first message. + """ + # Mirror langgraph_swarm's create_handoff_tool: project the + # subgraph's messages — including the AIMessage carrying this + # tool_call — onto the PARENT state via the update. The + # ``add_messages`` reducer dedupes by id so existing messages + # aren't duplicated. The parent's routing edge then reads the + # AIMessage off the parent state and fans out a worker run per + # delegation, each answering its own ``tool_call_id``. + subgraph_messages: List[Any] = [] + if isinstance(state, dict): + subgraph_messages = list(state.get("messages") or []) + else: + subgraph_messages = list(getattr(state, "messages", []) or []) + del task, tool_call_id # task + id are recovered by the routing edge + return Command( + graph=Command.PARENT, + update={"messages": subgraph_messages}, ) + + _delegate.description = ( + f"Delegate a task to the {worker_node_name} worker and receive " + f"its response. Use this when the task fits the worker's " + f"described capability." + ) + return _delegate + + +def _handoff_tool_name(destination_name: str) -> str: + """``transfer_to_`` — the tool name the manager's LLM + emits to hand off to a Swarm sibling. Normalization mirrors + :func:`_safe_node_name` so the name is a clean tool identifier; the raw + destination (the Swarm node name used as the handoff ``goto``) is recovered + via the tool-name→destination map held by the handoff node.""" + normalized = re.sub(r"[^a-z0-9]+", "_", (destination_name or "").lower()).strip("_") + return f"{_HANDOFF_TOOL_PREFIX}{normalized or 'agent'}" + + +def _make_swarm_handoff_tool(destination_name: str) -> Any: + """Build the ``transfer_to_`` tool a Swarm-member ManagerWorkers' + manager LLM emits to hand the conversation off to a sibling member. + + Mirrors :func:`_make_worker_delegation_tool`: the body returns a + ``Command(graph=Command.PARENT)`` carrying **no** ``goto`` — it only breaks + the manager's react loop and surfaces the AIMessage on the parent graph. + The real handoff is performed by the parent's handoff node + (:func:`_make_handoff_forward_node`), which re-emits a + ``Command(goto=, graph=Command.PARENT)`` so the destination is + resolved against the *Swarm* graph. A plain + ``langgraph_swarm.create_handoff_tool`` cannot be used directly on the + manager: its single ``Command(graph=PARENT)`` would land on *this* + ManagerWorkers graph (one level short of the Swarm) and be silently + dropped, so the handoff would never happen. + """ + from typing import Annotated + + from langchain_core.tools import InjectedToolCallId, tool + from langgraph.prebuilt import InjectedState + from langgraph.types import Command + + tool_name = _handoff_tool_name(destination_name) + + @tool(tool_name) + def _handoff( + state: Annotated[Any, InjectedState], + tool_call_id: Annotated[str, InjectedToolCallId], + ) -> Command: + """Hand the whole conversation off to the named agent.""" + if isinstance(state, dict): + subgraph_messages = list(state.get("messages") or []) + else: + subgraph_messages = list(getattr(state, "messages", []) or []) + del tool_call_id # id is recovered from the AIMessage by the handoff node + return Command( + graph=Command.PARENT, + update={"messages": subgraph_messages}, + ) + + _handoff.description = ( + f"Transfer the full conversation to the '{destination_name}' agent so " + f"it takes over the dialogue with the user. Use this when " + f"'{destination_name}' is better suited to continue; you will not " + f"regain control afterwards." + ) + return _handoff + + +def _make_handoff_forward_node(handoff_dest_by_tool_name: Dict[str, str]) -> Any: + """Build the parent-graph node that performs a Swarm handoff. + + Reached (via the routing edge) when the manager's last AIMessage carries a + ``transfer_to_`` tool call. It returns a + ``Command(goto=, graph=Command.PARENT, update={..., active_agent})`` + that re-emits the handoff up to the Swarm graph (mirroring what + ``langgraph_swarm.create_handoff_tool`` does for a plain Agent member). + + Transcript validity: the ManagerWorkers graph exits via this PARENT + command rather than running to its own END, so its internal messages do + **not** merge into the shared Swarm conversation — only this command's + ``update`` does. We therefore forward the manager's transfer AIMessage + itself, followed by a ``ToolMessage`` answering every (still-unanswered) + tool call on it, so the Swarm sees a well-formed + ``AIMessage(tool_calls)`` → ``ToolMessage`` sequence — an orphan + ToolMessage would 400 the next member's LLM. Answering *every* call (not + just the transfer) keeps the sequence valid even when the manager emitted + delegations in the same turn; the manager's internal delegation mechanics + otherwise stay hidden inside the ManagerWorkers, as in a standalone run. + + Returns a ``RunnableLambda`` exposing both sync (``func``) and async + (``afunc``) entrypoints so LangGraph can call it on either path; the body + is pure so the async wrapper just delegates. + """ + from langchain_core.messages import ToolMessage + from langgraph.types import Command + + from pyagentspec.adapters.langgraph._types import RunnableLambda + + def _forward(state: Dict[str, Any]) -> Any: + messages = state.get("messages") or [] + last = messages[-1] if messages else None + tool_calls = getattr(last, "tool_calls", None) or [] + transfer_call = next( + (tc for tc in tool_calls if _tc_get(tc, "name") in handoff_dest_by_tool_name), + None, + ) + if transfer_call is None: + # Defensive: the routing edge only sends us here on a transfer call. + return {"messages": []} + destination = handoff_dest_by_tool_name[_tc_get(transfer_call, "name")] + transfer_id = _tc_get(transfer_call, "id") or "" + already_answered = { + getattr(m, "tool_call_id", None) for m in messages if getattr(m, "type", None) == "tool" + } + tool_messages: List[Any] = [] + for tc in tool_calls: + call_id = _tc_get(tc, "id") or "" + if call_id in already_answered: + continue + if call_id == transfer_id: + content = f"Successfully transferred to {destination}" + else: + content = f"Not executed: the conversation was handed off to " f"{destination}." + tool_messages.append( + ToolMessage( + content=content, + name=_tc_get(tc, "name"), + tool_call_id=call_id, + ) + ) + return Command( + goto=destination, + graph=Command.PARENT, + # Forward the manager's transfer AIMessage ahead of the answering + # ToolMessages so the Swarm transcript is a valid tool-call/result + # sequence (the MW's internal messages don't otherwise merge on a + # PARENT-jump exit). add_messages dedupes by id, so re-sending an + # already-present message is a no-op. + update={"messages": [last, *tool_messages], "active_agent": destination}, + ) + + async def _forward_async(state: Dict[str, Any]) -> Any: + return _forward(state) + + return RunnableLambda( + func=_forward, + afunc=_forward_async, + name="swarm_handoff", + ) + + +def _is_handoff_name(name: Any) -> bool: + """True if ``name`` is one of the synthetic ``transfer_to_`` tool + names the manager emits to hand the conversation off to a Swarm sibling.""" + return isinstance(name, str) and name.startswith(_HANDOFF_TOOL_PREFIX) + + +def _route_manager_to_worker_handoff_or_end(state: Dict[str, Any]) -> Any: + """Inspect the manager's last AIMessage and route the parent graph. + + Routing precedence: + + * a ``transfer_to_`` tool call → the Swarm-handoff node + (:func:`_make_handoff_forward_node`), which re-emits the handoff up to + the Swarm. Handoff transfers the *whole* conversation, so it wins over + delegation and routes to a single destination (only present when this + ManagerWorkers is a Swarm member). + * one or more ``delegate_to_`` tool calls → one ``Send`` per + delegation; otherwise → ``END``. + + A single manager turn may delegate to several workers at once — the LLM + emits multiple ``delegate_to_`` tool calls in one AIMessage + (e.g. "spin up 5 sub-agents"). Every one of those tool calls must be + answered by its own ``ToolMessage`` matched to the originating + ``tool_call_id``; leaving any unanswered produces a tool-call / + tool-result mismatch that violates the OpenAI contract and makes the + manager hallucinate the missing replies. We therefore emit one ``Send`` + per delegation, each carrying the delegated ``task`` and its + ``tool_call_id`` so the target worker node can reply to exactly that + call. Multiple ``Send``s to the same worker node run as independent + tasks. Plain (non-delegation) tool calls were already executed inside + the manager's react-agent loop before routing reaches here. + """ + from langgraph.types import Send + + messages = state.get("messages") or [] + if not messages: + return langgraph_graph.END + last = messages[-1] + tool_calls = getattr(last, "tool_calls", None) or [] + # Swarm handoff wins: it hands the whole conversation to a sibling, so any + # delegations in the same turn are answered-and-dropped by the handoff node. + if any(_is_handoff_name(_tc_get(tc, "name")) for tc in tool_calls): + return _HANDOFF_NODE_KEY + sends = [] + for tc in tool_calls: + name = _tc_get(tc, "name") + if isinstance(name, str) and name.startswith(_DELEGATE_TOOL_PREFIX): + worker_node_name = name[len(_DELEGATE_TOOL_PREFIX) :] + args = _tc_get(tc, "args") or {} + sends.append( + Send( + worker_node_name, + { + _DELEGATE_TASK_KEY: args.get("task") or "", + _DELEGATE_CALL_ID_KEY: _tc_get(tc, "id") or "", + }, + ) + ) + return sends or langgraph_graph.END + + +def _wrap_worker_for_subgraph( + 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]: + # Content isolation: the worker is fed ONLY the delegated task, never + # the manager's history. We deliberately pass NO explicit config so + # the worker run *inherits* the ambient run config of this node — + # which carries (a) the astream_events callbacks and (b) this node's + # ``checkpoint_ns`` (``:``). Inheriting the + # namespace is what makes the worker's token events stream natively + # under the worker node (so consumers can attribute them to the + # worker) instead of escaping to a detached ``agent:`` run. + # + # State stays isolated *across* delegations without a fresh thread_id: + # LangGraph gives each ```` invocation a distinct + # per-superstep ``checkpoint_ns``, so a worker called twice in a row + # starts each run fresh rather than replaying its previous answer. + # + # The task is forwarded as a HumanMessage: a chat model generates the + # next assistant turn in response to a user (or tool) turn, so the + # worker needs a user-role message to answer. A SystemMessage-only + # conversation gives the model nothing to respond to — strict + # OpenAI-compatible providers return an empty completion and + # langchain-core then raises "No generations found in stream", failing + # the whole delegation. + # + # This message *does* stream out to consumers (the worker inherits this + # node's astream_events callbacks — see above) and would otherwise + # surface in the chat UI as a spurious end-user turn. Rather than starve + # the model of the user turn it needs, we stamp a marker in + # ``additional_kwargs`` so the consumer can recognise it as internal + # delegation plumbing and drop/relabel it. The marker is non-destructive + # (nothing is stripped from the stream) and metadata-only (not sent to + # the provider for user-role messages). + return { + "messages": [ + HumanMessage( + content=task, + additional_kwargs={_DELEGATION_TASK_MARKER_KEY: _DELEGATION_TASK_MARKER_VALUE}, + ) + ] + } + + def _last_message_content(result: Any) -> str: + messages = result.get("messages") if isinstance(result, dict) else None + if not messages: + return "" + return getattr(messages[-1], "content", "") or "" + + def _error_reply(call_id: str, exc: Exception) -> Dict[str, Any]: + # A worker crash must not abort the whole parent run and leave the + # manager's ``delegate_to_`` tool-call unanswered: an orphan + # tool-call breaks the OpenAI/Anthropic contract and 400s the manager's + # next turn (in particular on a checkpoint resume). Answer the pending + # delegation with an error ToolMessage instead, so the manager sees a + # well-formed "worker failed" tool response and can decide how to react + # — mirroring how a tool raising inside the react-agent ToolNode is + # surfaced as an error ToolMessage rather than crashing the graph. + logging.getLogger("pyagentspec.adapters.langgraph").exception( + "Worker '%s' failed; answering delegation %s with an error ToolMessage", + worker_node_name, + call_id, + ) + return { + "messages": [ + ToolMessage( + content=f"Worker '{worker_node_name}' failed: {exc}", + tool_call_id=call_id, + status="error", + ) + ] + } + + def _run_sync(state: Dict[str, Any]) -> Dict[str, Any]: + # ``_extract_pending`` runs outside the try: if it raises there is no + # pending delegation to answer, so the error must surface unchanged. + task, call_id = _extract_pending(state) + try: + result = worker_graph.invoke(_worker_input(task)) + except Exception as exc: # noqa: BLE001 — degrade any worker failure + return _error_reply(call_id, exc) + return _tool_message_from(_last_message_content(result), call_id) + + async def _run_async(state: Dict[str, Any]) -> Dict[str, Any]: + task, call_id = _extract_pending(state) + try: + result = await worker_graph.ainvoke(_worker_input(task)) + except Exception as exc: # noqa: BLE001 — degrade any worker failure + return _error_reply(call_id, exc) + return _tool_message_from(_last_message_content(result), call_id) + + return RunnableLambda( + func=_run_sync, + afunc=_run_async, + name=f"worker:{worker_node_name}", + ) + + +def _patch_with_manager_workers_execution_span( + compiled_graph: CompiledStateGraph[Any, Any, Any], + mw: AgentSpecManagerWorkers, +) -> None: + """Wrap ``stream`` / ``astream`` so each ManagerWorkers run emits a + ``ManagerWorkersExecutionSpan`` with Start/End events. Mirrors the + patches applied to Agent and Flow compiled graphs elsewhere in this + converter. + """ + original_stream = compiled_graph.stream + original_astream = compiled_graph.astream + + def _coerce_inputs(kwargs: Dict[str, Any]) -> Dict[str, Any]: + inputs = kwargs.get("input", {}) + return inputs if isinstance(inputs, dict) else {} + + def patched_stream(*args: Any, **kwargs: Any) -> Generator[Any, Any, None]: + span_name = f"ManagerWorkersExecution[{mw.name}]" + inputs = _coerce_inputs(kwargs) + with AgentSpecManagerWorkersExecutionSpan(name=span_name, managerworkers=mw) as span: + span.add_event(AgentSpecManagerWorkersExecutionStart(managerworkers=mw, inputs=inputs)) + last_chunk: Dict[str, Any] = {} + for chunk in original_stream(*args, **kwargs): + yield chunk + if isinstance(chunk, tuple) and isinstance(chunk[1], dict): + last_chunk = chunk[1] + span.add_event( + AgentSpecManagerWorkersExecutionEnd( + managerworkers=mw, + outputs={"messages": last_chunk.get("messages", [])}, + ) + ) + + async def patched_astream(*args: Any, **kwargs: Any) -> AsyncGenerator[Any, Any]: + span_name = f"ManagerWorkersExecution[{mw.name}]" + inputs = _coerce_inputs(kwargs) + span = AgentSpecManagerWorkersExecutionSpan(name=span_name, managerworkers=mw) + try: + await span.start_async() + except NotImplementedError: + span.start() + try: + start_event = AgentSpecManagerWorkersExecutionStart(managerworkers=mw, inputs=inputs) + try: + await span.add_event_async(start_event) + except NotImplementedError: + span.add_event(start_event) + last_chunk: Dict[str, Any] = {} + async for chunk in original_astream(*args, **kwargs): + yield chunk + if isinstance(chunk, tuple) and isinstance(chunk[1], dict): + last_chunk = chunk[1] + end_event = AgentSpecManagerWorkersExecutionEnd( + managerworkers=mw, + outputs={"messages": last_chunk.get("messages", [])}, + ) + try: + await span.add_event_async(end_event) + except NotImplementedError: + span.add_event(end_event) + finally: + try: + await span.end_async() + except NotImplementedError: + span.end() + + compiled_graph.stream = patched_stream # type: ignore[assignment] + compiled_graph.astream = patched_astream # type: ignore[assignment] diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index f1999aef..30308a2b 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -49,8 +49,10 @@ 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.swarm import Swarm as AgentSpecSwarm from pyagentspec.tracing.events import NodeExecutionEnd as AgentSpecNodeExecutionEnd from pyagentspec.tracing.events import NodeExecutionStart as AgentSpecNodeExecutionStart from pyagentspec.tracing.events.exception import ExceptionRaised @@ -529,16 +531,138 @@ def _create_react_agent_with_given_input_values( ) return self._agents_cache[system_prompt] + def _create_manager_graph_with_given_input_values( + self, inputs: Dict[str, Any] + ) -> CompiledStateGraph[Any, Any]: + """Compile the node's ``ManagerWorkers`` into a runnable graph for these inputs. + + Mirrors :meth:`_create_react_agent_with_given_input_values`: the node inputs are + rendered into the group manager's ``system_prompt`` (so flow ``{{placeholder}}`` + inputs substitute), and the compiled manager graph is cached by the rendered + prompt. A non-Agent group manager is left untouched so the converter raises its + own clear ``NotImplementedError``. + """ + from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter + + manager_workers = self.node.agent + if not isinstance(manager_workers, AgentSpecManagerWorkers): + raise TypeError( + "_create_manager_graph_with_given_input_values requires a ManagerWorkers" + ) + manager_agent = manager_workers.group_manager + if isinstance(manager_agent, AgentSpecAgent): + cache_key = render_template(manager_agent.system_prompt, inputs) + # The manager graph runs over MessagesState and can't carry structured + # inputs to its inner agent, so we bake them into the prompt instead. The + # rendered prompt then has no `{{placeholders}}`, so the group manager (and + # the manager) accept no further inputs — drop the now-satisfied input ports + # so declared == inferred and the downstream span re-validation passes. + rendered_manager = manager_workers.model_copy( + update={ + "group_manager": manager_agent.model_copy( + update={"system_prompt": cache_key, "inputs": []} + ), + "inputs": [], + } + ) + else: + # The converter rejects a non-Agent group manager; pass through unchanged. + cache_key = manager_workers.id + rendered_manager = manager_workers + if cache_key not in self._agents_cache: + self._agents_cache[ + cache_key + ] = AgentSpecToLangGraphConverter()._manager_workers_convert_to_langgraph( + rendered_manager, + tool_registry=self.tool_registry, + converted_components=self.converted_components, + checkpointer=self.checkpointer, + config=self.config, + middleware=self._middleware, + ) + return self._agents_cache[cache_key] + + def _create_swarm_graph_with_given_input_values( + self, inputs: Dict[str, Any] + ) -> CompiledStateGraph[Any, Any]: + """Compile the node's ``Swarm`` into a runnable graph for these inputs. + + Mirrors :meth:`_create_manager_graph_with_given_input_values`: a Swarm flow step + runs as a multi-agent graph over ``MessagesState`` and can't carry structured + inputs to its inner agents, so the node inputs are rendered into the entry + (``first_agent``) ``system_prompt`` (so flow ``{{placeholder}}`` inputs substitute) + and the compiled swarm graph is cached by the rendered prompt. The entry agent + appears both as ``first_agent`` and inside the relationship tuples the converter + builds the swarm from, so it is swapped in both (matched by id) — the converted + swarm then uses the rendered prompt and ``default_active_agent`` still resolves by + the unchanged name. The now-satisfied input ports are dropped so declared == + inferred and the downstream span re-validation passes. A non-Agent entry agent is + left untouched so the converter raises its own clear error. + """ + from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter + + swarm = self.node.agent + if not isinstance(swarm, AgentSpecSwarm): + raise TypeError("_create_swarm_graph_with_given_input_values requires a Swarm") + entry_agent = swarm.first_agent + if isinstance(entry_agent, AgentSpecAgent): + cache_key = render_template(entry_agent.system_prompt, inputs) + rendered_entry = entry_agent.model_copy( + update={"system_prompt": cache_key, "inputs": []} + ) + + def _swap(agent: Any) -> Any: + return rendered_entry if agent.id == entry_agent.id else agent + + rendered_swarm = swarm.model_copy( + update={ + "first_agent": rendered_entry, + "relationships": [ + (_swap(caller), _swap(recipient)) + for caller, recipient in swarm.relationships + ], + "inputs": [], + } + ) + else: + # The converter rejects a non-Agent swarm member; pass through unchanged. + cache_key = swarm.id + rendered_swarm = swarm + if cache_key not in self._agents_cache: + self._agents_cache[ + cache_key + ] = AgentSpecToLangGraphConverter()._swarm_convert_to_langgraph( + rendered_swarm, + tool_registry=self.tool_registry, + converted_components=self.converted_components, + checkpointer=self.checkpointer, + config=self.config, + middleware=self._middleware, + ) + return self._agents_cache[cache_key] + def _prepare_agent_and_inputs( self, inputs: Dict[str, Any], messages: Messages ) -> Tuple[CompiledStateGraph[Any, Any], Dict[str, Any]]: - agent = self._create_react_agent_with_given_input_values(inputs) # LangGraph's agent expects at least one user message to drive execution. # When an AgentNode is used with a templated system prompt and no messages are provided # by the flow, the agent can crash. To avoid this, we artificially insert an empty # user message when the message list is empty. if not messages: messages = cast(Messages, [{"role": "user", "content": ""}]) + if isinstance(self.node.agent, AgentSpecManagerWorkers): + # A ManagerWorkers flow step runs as a hierarchical graph over MessagesState. + # The node inputs were already rendered into the group-manager prompt, so the + # graph is driven by messages alone (not the agent's remaining_steps state). + graph = self._create_manager_graph_with_given_input_values(inputs) + return graph, {"messages": messages} + if isinstance(self.node.agent, AgentSpecSwarm): + # A Swarm flow step runs as a multi-agent graph over MessagesState, same as a + # ManagerWorkers: the node inputs were rendered into the entry agent's prompt, + # so the graph is driven by messages alone. + graph = self._create_swarm_graph_with_given_input_values(inputs) + return graph, {"messages": messages} + agent = self._create_react_agent_with_given_input_values(inputs) inputs |= { "remaining_steps": 20, # Get the right number of steps left "messages": messages, @@ -546,7 +670,9 @@ def _prepare_agent_and_inputs( } return agent, inputs - def _format_agent_result(self, result: Dict[str, Any]) -> ExecuteOutput: + def _format_agent_result( + self, result: Dict[str, Any], node_inputs: Dict[str, Any] + ) -> ExecuteOutput: if not self.node.outputs: generated_message = result["messages"][-1] generated_messages: List[MessageLike] = [ @@ -554,18 +680,55 @@ def _format_agent_result(self, result: Dict[str, Any]) -> ExecuteOutput: ] return {}, NodeExecutionDetails(generated_messages=generated_messages) + # Node inputs are seeded into the invoke state (_prepare_agent_and_inputs), + # so `result` still carries each input under its port title. When an input + # port shares its title with an output port (e.g. an `{{output}}` prompt + # placeholder wired from an upstream agent's default `output` port), that + # echoed input would shadow the agent's generated reply in + # extract_outputs_from_invoke_result. Drop result entries that still hold + # the exact seeded value so extraction falls through to the structured + # response or the final message; a value the graph rewrote is kept. + result = { + key: value + for key, value in result.items() + if not (key in node_inputs and value == node_inputs[key]) + } outputs = extract_outputs_from_invoke_result(result, self.node.outputs or []) return outputs, NodeExecutionDetails() + def _invoke_config(self) -> RunnableConfig: + """Config to run the inner agent with: the current pregel task's config. + + The conversion-time ``self.config`` carries none of the flow task's + ``__pregel_*`` context, so invoking the inner agent with it runs the + agent as a standalone root graph: an ``interrupt()`` raised inside it + (a ClientTool, a requires_confirmation tool) is absorbed into that + root run's own state, ``invoke`` returns with ``__interrupt__``, and + the flow carries on as if the agent had answered. With the live task + config the agent runs as a true subgraph of the flow — the interrupt + propagates to the flow's run, and a ``Command(resume=...)`` replays + back into the agent. + """ + from langgraph.config import get_config + + try: + return get_config() + except RuntimeError: + # Not inside a pregel task (e.g. an executor driven directly in + # tests): keep the conversion-time config. + return self.config + def _execute(self, inputs: Dict[str, Any], messages: Messages) -> ExecuteOutput: + node_inputs = dict(inputs) agent, prepared_inputs = self._prepare_agent_and_inputs(inputs, messages) - result = agent.invoke(prepared_inputs, self.config) - return self._format_agent_result(result) + result = agent.invoke(prepared_inputs, self._invoke_config()) + return self._format_agent_result(result, node_inputs) async def _aexecute(self, inputs: Dict[str, Any], messages: Messages) -> ExecuteOutput: + node_inputs = dict(inputs) agent, prepared_inputs = self._prepare_agent_and_inputs(inputs, messages) - result = await agent.ainvoke(prepared_inputs, self.config) - return self._format_agent_result(result) + result = await agent.ainvoke(prepared_inputs, self._invoke_config()) + return self._format_agent_result(result, node_inputs) class InputMessageNodeExecutor(NodeExecutor): @@ -919,13 +1082,25 @@ def _accumulate_outputs( outputs[collected_output_name].append(output_value) +def is_single_string_output(expected_outputs: List[AgentSpecProperty]) -> bool: + """Whether the declared outputs are a single string property. + + Such an output is the model's free text, not a structured field, so the + adapter takes it directly from the agent's final message rather than forcing + structured generation. Mirrors ``LlmNodeExecutor``'s single-string handling + and lets a string output work on models without structured-output support. + """ + outputs = expected_outputs or [] + return len(outputs) == 1 and outputs[0].type == "string" + + def extract_outputs_from_invoke_result( result: Dict[str, Any], expected_outputs: List[AgentSpecProperty] ) -> Dict[str, Any]: # Extracts the outputs from the return value of an invoke call made on an agent # The outputs are typically exposed as part of the `structured_response`, or as entries in the result directly. # We give priority to the latter. - return { + outputs = { # Defaults if available **{ output.title: output.default @@ -941,3 +1116,17 @@ def extract_outputs_from_invoke_result( if output.title in result }, } + # A single string output is the agent's free-text answer, not a structured + # field. When structured generation didn't populate it — because the model + # lacks structured-output support, or because none was requested (see + # ``_create_react_agent_with_given_info``) — fall back to the final message + # content so the output still carries the agent's response. + if is_single_string_output(expected_outputs): + title = expected_outputs[0].title + if title not in outputs: + messages = result.get("messages") + if messages: + content = getattr(messages[-1], "content", None) + if content is not None: + outputs[title] = content + return outputs diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py b/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py index a8090df7..a80ede02 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/mcp_utils.py @@ -4,6 +4,7 @@ # (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. +import contextvars import ssl import warnings from concurrent.futures import ThreadPoolExecutor @@ -12,7 +13,11 @@ import anyio from anyio import from_thread -from sniffio import AsyncLibraryNotFoundError, current_async_library +from sniffio import ( + AsyncLibraryNotFoundError, + current_async_library, + current_async_library_cvar, +) from pyagentspec._lazy_loader import LazyLoader @@ -38,9 +43,13 @@ def __init__( ): self.verify: bool | ssl.SSLContext if verify: - ssl_ctx = ssl.create_default_context() + # When a custom CA is provided, use it as the sole trust anchor (replacing the + # system CA bundle) so the trust boundary stays exactly as configured. + # When no custom CA is given, fall back to the system CA bundle. if ssl_ca_cert: - ssl_ctx.load_verify_locations(cafile=ssl_ca_cert) + ssl_ctx = ssl.create_default_context(cafile=ssl_ca_cert) + else: + ssl_ctx = ssl.create_default_context() if key_file or cert_file: # Client authentication requires both pieces of certificate material. @@ -159,10 +168,24 @@ def run_async_in_sync( # workaround: anyio does not have any API run asynchronous code in a # synchronous method that was not started with anyio.to_thread # instead, we spawn a thread to execute it in a completely new event loop + # + # A fresh thread starts with an EMPTY contextvars context, so any + # request-scoped state the caller set — tenant/user identity, the + # OTEL trace context — would be lost inside async_function. Notably an + # MCP client loaded here would then read empty ContextVars and drop + # the per-request headers derived from them. Copy the caller's context + # and run the thread body inside it so that state propagates. + ctx = contextvars.copy_context() + def thread_target() -> T: + # The copied context may carry the caller's async-library marker + # (sniffio); this new thread has no running loop, so clear it or + # anyio.run would refuse with "Already running in this + # thread". anyio.run sets its own marker for the loop it starts. + current_async_library_cvar.set(None) return anyio.run(async_function, *args) - future = ThreadPoolExecutor(max_workers=1).submit(thread_target) + future = ThreadPoolExecutor(max_workers=1).submit(ctx.run, thread_target) return future.result() case unsupported_context: raise NotImplementedError(f"Unsupported async context: {unsupported_context}") diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/tracing.py b/pyagentspec/src/pyagentspec/adapters/langgraph/tracing.py index fb8bff97..d4132908 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/tracing.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/tracing.py @@ -222,7 +222,7 @@ async def _start_and_copy_ctx_async(self, run_id_str: str, span: AgentSpecSpan) def _in_async_trace(self) -> bool: try: - anyio.get_running_tasks() + anyio.get_current_task() return True except RuntimeError: return False diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index 26c2bdeb..a95de155 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -13,6 +13,7 @@ from typing_extensions import Self from pyagentspec.agenticcomponent import AgenticComponent +from pyagentspec.property import Property from pyagentspec.validation_helpers import model_validator_with_error_accumulation from pyagentspec.versioning import AgentSpecVersionEnum @@ -65,6 +66,29 @@ class ManagerWorkers(AgenticComponent): default=AgentSpecVersionEnum.v25_4_2, init=False, exclude=True ) + def _get_inferred_inputs(self) -> List[Property]: + """A ``ManagerWorkers`` exposes the inputs of its group manager. + + The group manager is the component that drives the conversation and whose prompt + the run-time renders, so the manager-workers component accepts exactly the inputs + the group manager accepts (e.g. the ``{{placeholder}}`` inputs of an ``Agent`` + group manager). Without this, the base default infers no inputs, so a + ``ManagerWorkers`` used as a flow ``AgentNode`` would expose no input ports and a + data-flow edge into it could not resolve. + """ + group_manager = getattr(self, "group_manager", None) + return list(getattr(group_manager, "inputs", None) or []) + + def _get_inferred_outputs(self) -> List[Property]: + """A ``ManagerWorkers`` exposes the outputs of its group manager. + + Symmetric with :meth:`_get_inferred_inputs`: the group manager produces the + component's result, so a ``ManagerWorkers`` used as a flow ``AgentNode`` can have + its output wired downstream (or surfaced as a leaf) just like an ``Agent`` step. + """ + group_manager = getattr(self, "group_manager", None) + return list(getattr(group_manager, "outputs", None) or []) + @model_validator_with_error_accumulation def _validate_one_or_more_workers(self) -> Self: if len(self.workers) == 0: diff --git a/pyagentspec/src/pyagentspec/swarm.py b/pyagentspec/src/pyagentspec/swarm.py index 3b9c7f72..50ef9bb4 100644 --- a/pyagentspec/src/pyagentspec/swarm.py +++ b/pyagentspec/src/pyagentspec/swarm.py @@ -15,6 +15,7 @@ from pyagentspec.agenticcomponent import AgenticComponent from pyagentspec.component import SerializeAsEnum +from pyagentspec.property import Property from pyagentspec.validation_helpers import model_validator_with_error_accumulation from pyagentspec.versioning import AgentSpecVersionEnum @@ -127,6 +128,28 @@ class Swarm(AgenticComponent): default=AgentSpecVersionEnum.v25_4_2, init=False, exclude=True ) + def _get_inferred_inputs(self) -> List[Property]: + """A ``Swarm`` exposes the inputs of its entry agent (``first_agent``). + + Symmetric with :meth:`ManagerWorkers._get_inferred_inputs`. The ``first_agent`` + is the swarm's entry point — it interacts with the user before any handoff — so + the swarm component accepts exactly the inputs that agent accepts (e.g. the + ``{{placeholder}}`` inputs of an ``Agent`` entry's prompt). Without this, the base + default infers no inputs, so a flow ``AgentNode`` wrapping a swarm declares no + input ports and a ``DataFlowEdge`` into it fails to resolve at load. + """ + first_agent = getattr(self, "first_agent", None) + return list(getattr(first_agent, "inputs", None) or []) + + def _get_inferred_outputs(self) -> List[Property]: + """A ``Swarm`` exposes the outputs of its entry agent (``first_agent``). + + Symmetric with :meth:`_get_inferred_inputs`: the ``first_agent`` produces the + swarm's surfaced result, so the swarm exposes its outputs. + """ + first_agent = getattr(self, "first_agent", None) + return list(getattr(first_agent, "outputs", None) or []) + @model_validator(mode="before") def _raise_warning_if_handoff_is_bool(cls: Self, values: Any) -> Any: import warnings diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py b/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py index e5b4ba12..d74489bb 100644 --- a/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py +++ b/pyagentspec/tests/adapters/langgraph/flows/test_agentnode.py @@ -96,6 +96,348 @@ def test_agentnode_can_be_imported_and_executed(agent_flow: Flow) -> None: assert "car" in outputs +def test_is_single_string_output() -> None: + """A lone string output is treated as free text, not a structured field.""" + from pyagentspec.adapters.langgraph._node_execution import is_single_string_output + from pyagentspec.property import IntegerProperty, StringProperty + + assert is_single_string_output([StringProperty(title="x")]) is True + assert is_single_string_output([]) is False + assert is_single_string_output([IntegerProperty(title="n")]) is False + assert is_single_string_output([StringProperty(title="a"), StringProperty(title="b")]) is False + + +def test_single_string_output_taken_from_final_message_without_structured_generation() -> None: + """An AgentNode whose agent declares a single string output should resolve + that output from the agent's final message — no structured generation, so + it works on models without structured-output support. + + The model is stubbed with a ``FakeMessagesListChatModel`` (no structured + output); if the converter still attached a ``response_format`` the output + would come back empty. Asserting it equals the message content proves the + single-string path takes the final message instead. + """ + from unittest.mock import patch + + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + fake_llm = _FakeModel(responses=[AIMessage(content="42")]) + + answer = StringProperty(title="answer") + agent = Agent( + name="agent", + llm_config=OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null"), + system_prompt="Answer the question.", + outputs=[answer], + ) + agent_node = AgentNode(name="agent_node", agent=agent) + start_node = StartNode(name="start") + end_node = EndNode(name="end", outputs=[answer]) + flow = Flow( + name="flow", + start_node=start_node, + nodes=[start_node, agent_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=agent_node), + ControlFlowEdge(name="node_to_end", from_node=agent_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="answer_edge", + source_node=agent_node, + source_output=answer.title, + destination_node=end_node, + destination_input=answer.title, + ), + ], + outputs=[answer], + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ): + compiled = loader.load_component(flow) + result = compiled.invoke( + {"inputs": {}, "messages": [{"role": "user", "content": "What is 6*7?"}]}, + {"configurable": {"thread_id": "agentnode-single-string"}}, + ) + + assert result["outputs"]["answer"] == "42" + + +def test_output_not_shadowed_by_same_named_input() -> None: + """An input port named like the single string output port must not leak + through as the node's output — the agent's generated reply wins. + + Regression: node inputs are seeded into the invoke state + (``_prepare_agent_and_inputs``), so the result still carries each input + under its port title. With an input wired as ``{{output}}`` — the name a + data edge from an upstream agent's default ``output`` port requires — + ``extract_outputs_from_invoke_result`` preferred that echoed input over the + final message, and the flow returned the upstream agent's text verbatim + while this agent's actual reply was discarded. + """ + from unittest.mock import patch + + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + english_joke = "Why don't programmers like nature? It has too many bugs." + french_joke = "Pourquoi les programmeurs n'aiment pas la nature ? Trop de bugs." + fake_llm = _FakeModel(responses=[AIMessage(content=french_joke)]) + + output_in = StringProperty(title="output") + output_out = StringProperty(title="output") + agent = Agent( + name="translator", + llm_config=OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null"), + system_prompt="Translate the following joke into French:\n\n{{output}}", + inputs=[output_in], + outputs=[output_out], + ) + agent_node = AgentNode(name="agent_node", agent=agent) + start_node = StartNode(name="start", inputs=[output_in]) + end_node = EndNode(name="end", outputs=[output_out]) + flow = Flow( + name="flow", + start_node=start_node, + nodes=[start_node, agent_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=agent_node), + ControlFlowEdge(name="node_to_end", from_node=agent_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="input_edge", + source_node=start_node, + source_output="output", + destination_node=agent_node, + destination_input="output", + ), + DataFlowEdge( + name="output_edge", + source_node=agent_node, + source_output="output", + destination_node=end_node, + destination_input="output", + ), + ], + outputs=[output_out], + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ): + compiled = loader.load_component(flow) + result = compiled.invoke( + { + "inputs": {"output": english_joke}, + "messages": [{"role": "user", "content": "tell me a joke"}], + }, + {"configurable": {"thread_id": "agentnode-shadowed-output"}}, + ) + + assert result["outputs"]["output"] == french_joke + + +def _build_client_tool_flow() -> Flow: + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + from pyagentspec.tools import ClientTool + + client_tool = ClientTool( + name="ask_user", + description="Ask the user a question", + inputs=[StringProperty(title="question")], + ) + agent = Agent( + name="agent", + llm_config=OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null"), + system_prompt="You are helpful.", + tools=[client_tool], + ) + agent_node = AgentNode(name="agent_node", agent=agent) + start_node = StartNode(name="start") + end_node = EndNode(name="end") + return Flow( + name="flow", + start_node=start_node, + nodes=[start_node, agent_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=agent_node), + ControlFlowEdge(name="node_to_end", from_node=agent_node, to_node=end_node), + ], + data_flow_connections=[], + ) + + +def _client_tool_fake_model(): + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs): + # ChatOpenAI's _agenerate would win the MRO and call the real API; + # route the async path through the fake sync implementation. + return self._generate(messages, stop=stop, **kwargs) + + return _FakeModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "name": "ask_user", + "args": {"question": "capital of India?"}, + "id": "call_1", + "type": "tool_call", + } + ], + ), + AIMessage(content="You answered: New Delhi"), + ] + ) + + +def _client_tool_flow_patches(fake_llm): + from unittest.mock import patch + + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + + return ( + patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), + patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ), + ) + + +def test_agentnode_client_tool_interrupt_propagates_and_resumes() -> None: + """A ClientTool interrupt raised inside an AgentNode's agent must pause the + flow, and a resume must replay into the agent. + + Regression: the executor invoked the inner agent with the conversion-time + ``self.config`` (no ``__pregel_*`` task context), so the agent ran as a + standalone root graph — the interrupt was absorbed into that run's own + state, the node formatted the tool-call message as its output, and the + flow carried on as if the agent had answered. + """ + from langchain_core.runnables import RunnableConfig + from langgraph.checkpoint.memory import MemorySaver + from langgraph.types import Command + + from pyagentspec.adapters.langgraph import AgentSpecLoader + + fake_llm = _client_tool_fake_model() + llm_patch, bind_patch = _client_tool_flow_patches(fake_llm) + with llm_patch, bind_patch: + compiled = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()).load_component( + _build_client_tool_flow() + ) + config = RunnableConfig({"configurable": {"thread_id": "agentnode-client-tool"}}) + result = compiled.invoke( + {"inputs": {}, "messages": [{"role": "user", "content": "ask me a question"}]}, + config=config, + ) + assert "__interrupt__" in result + interrupt_value = result["__interrupt__"][0].value + assert interrupt_value["type"] == "client_tool_request" + assert interrupt_value["name"] == "ask_user" + assert interrupt_value["inputs"]["kwargs"] == {"question": "capital of India?"} + + resumed = compiled.invoke(Command(resume="New Delhi"), config=config) + + assert "__interrupt__" not in resumed + assert resumed["messages"][-1].content == "You answered: New Delhi" + + +@pytest.mark.anyio +async def test_agentnode_client_tool_interrupt_propagates_and_resumes_async() -> None: + """Async variant of the ClientTool interrupt round-trip through an AgentNode.""" + from langchain_core.runnables import RunnableConfig + from langgraph.checkpoint.memory import MemorySaver + from langgraph.types import Command + + from pyagentspec.adapters.langgraph import AgentSpecLoader + + fake_llm = _client_tool_fake_model() + llm_patch, bind_patch = _client_tool_flow_patches(fake_llm) + with llm_patch, bind_patch: + compiled = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()).load_component( + _build_client_tool_flow() + ) + config = RunnableConfig({"configurable": {"thread_id": "agentnode-client-tool-async"}}) + result = await compiled.ainvoke( + {"inputs": {}, "messages": [{"role": "user", "content": "ask me a question"}]}, + config=config, + ) + assert "__interrupt__" in result + assert result["__interrupt__"][0].value["type"] == "client_tool_request" + + resumed = await compiled.ainvoke(Command(resume="New Delhi"), config=config) + + assert "__interrupt__" not in resumed + assert resumed["messages"][-1].content == "You answered: New Delhi" + + @pytest.mark.anyio @retry_test(max_attempts=3, wait_between_tries=2) async def test_agentnode_can_be_executed_async(agent_flow: Flow) -> None: 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..28f5bb92 --- /dev/null +++ b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py @@ -0,0 +1,258 @@ +# Copyright © 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +"""A ManagerWorkers used as a flow step (AgentNode). + +Regression coverage for two coupled behaviours: + * ``ManagerWorkers._get_inferred_inputs`` exposes the group manager's inputs, so a + flow ``AgentNode`` wrapping a manager declares input ports and a ``DataFlowEdge`` + into it resolves at load (previously: "node does not have any input property..."). + * ``AgentNodeExecutor`` runs a ManagerWorkers node (previously: TypeError "can only + be used with AgentSpecAgent agents"), rendering the node inputs into the group + manager's prompt and returning its result. +""" + +from pyagentspec.agent import Agent +from pyagentspec.managerworkers import ManagerWorkers +from pyagentspec.property import StringProperty + + +def test_managerworkers_infers_inputs_from_group_manager_prompt() -> None: + """A ManagerWorkers exposes the group manager's prompt placeholders as inputs.""" + llm = {"name": "m", "model_id": "fake", "url": "null"} + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + cfg = OpenAiCompatibleConfig(**llm) + manager = Agent( + name="manager", + llm_config=cfg, + system_prompt="Translate the following to Arabic:\n\n{{joke}}\n\nMake {{count}} variants.", + ) + worker = Agent(name="worker", llm_config=cfg, system_prompt="You translate.") + mw = ManagerWorkers(name="mw", group_manager=manager, workers=[worker]) + + assert sorted(p.title for p in (mw.inputs or [])) == ["count", "joke"] + + +def test_managerworkers_runs_as_a_flow_step_with_data_edge_inputs() -> None: + """A ManagerWorkers flow step loads (data edge resolves) and executes offline. + + The model is stubbed (no real LLM, no delegation), so the manager produces a final + message and the manager graph routes straight to END. Asserts the flow both loads — + proving the manager node exposes the ``joke`` input the data edge targets — and runs, + surfacing the manager's answer as the node's single string output. + """ + from unittest.mock import patch + + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge + from pyagentspec.flows.flow import Flow + from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + # Final message has no tool_calls → the manager routes to END without delegating. + fake_llm = _FakeModel(responses=[AIMessage(content="لماذا...")]) + + cfg = OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null") + joke = StringProperty(title="joke") + translated = StringProperty(title="translated") + + manager = Agent( + name="manager", + llm_config=cfg, + system_prompt="Translate the following to Arabic:\n\n{{joke}}", + outputs=[translated], + ) + worker = Agent(name="worker", llm_config=cfg, system_prompt="You translate.") + mw = ManagerWorkers(name="translator", group_manager=manager, workers=[worker]) + # The manager node exposes the group manager's `joke` input, and the single + # `translated` output (inherited from the group manager) for the leaf edge. + assert [p.title for p in (mw.inputs or [])] == ["joke"] + + manager_node = AgentNode(name="manager_node", agent=mw) + start_node = StartNode(name="start", inputs=[joke]) + end_node = EndNode(name="end", outputs=[translated]) + flow = Flow( + name="flow", + start_node=start_node, + nodes=[start_node, manager_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=manager_node), + ControlFlowEdge(name="node_to_end", from_node=manager_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="joke_edge", + source_node=start_node, + source_output=joke.title, + destination_node=manager_node, + destination_input=joke.title, + ), + DataFlowEdge( + name="translated_edge", + source_node=manager_node, + source_output=translated.title, + destination_node=end_node, + destination_input=translated.title, + ), + ], + outputs=[translated], + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ): + compiled = loader.load_component(flow) + result = compiled.invoke( + { + "inputs": {"joke": "Why did the car..."}, + "messages": [{"role": "user", "content": ""}], + }, + {"configurable": {"thread_id": "managerworkers-node"}}, + ) + + assert "outputs" in result + assert result["outputs"]["translated"] == "لماذا..." + + +def test_managerworkers_node_resolves_multiple_structured_outputs() -> None: + """A ManagerWorkers flow step resolves several structured outputs. + + A manager graph runs over ``MessagesState``, which has no ``structured_response`` + channel, so the structured answer the group manager generated cannot come back + through the graph's return value. With a single string output the free-text + fallback hides that; with two or more declared outputs nothing filled them and + ``_cast_values_and_add_defaults`` raised ``ValueError: Expected node ... to have + a value for property ...`` at the producing node. The values are still present in + the message history as the arguments of the structured-output tool call, so they + are recovered from there. + """ + from unittest.mock import patch + + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge + from pyagentspec.flows.flow import Flow + from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + fake_llm = _FakeModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "name": "AgentOutputModel", + "args": {"route": "art", "brief": "a pixel dragon"}, + "id": "call_structured_output", + } + ], + ) + ] + ) + + cfg = OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null") + request = StringProperty(title="request") + route = StringProperty(title="route") + brief = StringProperty(title="brief") + + manager = Agent( + name="manager", + llm_config=cfg, + system_prompt="Triage this request:\n\n{{request}}", + outputs=[route, brief], + ) + worker = Agent(name="worker", llm_config=cfg, system_prompt="You help.") + mw = ManagerWorkers(name="triage", group_manager=manager, workers=[worker]) + + manager_node = AgentNode(name="manager_node", agent=mw) + start_node = StartNode(name="start", inputs=[request]) + end_node = EndNode(name="end", outputs=[route, brief]) + flow = Flow( + name="flow", + start_node=start_node, + nodes=[start_node, manager_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=manager_node), + ControlFlowEdge(name="node_to_end", from_node=manager_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="request_edge", + source_node=start_node, + source_output=request.title, + destination_node=manager_node, + destination_input=request.title, + ), + DataFlowEdge( + name="route_edge", + source_node=manager_node, + source_output=route.title, + destination_node=end_node, + destination_input=route.title, + ), + DataFlowEdge( + name="brief_edge", + source_node=manager_node, + source_output=brief.title, + destination_node=end_node, + destination_input=brief.title, + ), + ], + outputs=[route, brief], + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ): + compiled = loader.load_component(flow) + result = compiled.invoke( + { + "inputs": {"request": "make me a dragon"}, + "messages": [{"role": "user", "content": ""}], + }, + {"configurable": {"thread_id": "managerworkers-node-structured"}}, + ) + + assert result["outputs"]["route"] == "art" + assert result["outputs"]["brief"] == "a pixel dragon" diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_swarm_node.py b/pyagentspec/tests/adapters/langgraph/flows/test_swarm_node.py new file mode 100644 index 00000000..1462721c --- /dev/null +++ b/pyagentspec/tests/adapters/langgraph/flows/test_swarm_node.py @@ -0,0 +1,254 @@ +# Copyright © 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +"""A Swarm used as a flow step (AgentNode). + +Regression coverage for two coupled behaviours, symmetric with the ManagerWorkers +flow-step coverage in ``test_managerworkers_node.py``: + * ``Swarm._get_inferred_inputs`` exposes the entry agent's (``first_agent``) inputs, so a + flow ``AgentNode`` wrapping a swarm declares input ports and a ``DataFlowEdge`` into it + resolves at load (previously: "node does not have any input property..."). + * ``AgentNodeExecutor`` runs a Swarm node (previously: TypeError "can only be used with + AgentSpecAgent agents"), rendering the node inputs into the entry agent's prompt and + returning its result. +""" + +from pyagentspec.agent import Agent +from pyagentspec.property import StringProperty +from pyagentspec.swarm import Swarm + + +def test_swarm_infers_inputs_from_first_agent_prompt() -> None: + """A Swarm exposes the entry agent's prompt placeholders as inputs.""" + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + cfg = OpenAiCompatibleConfig(name="m", model_id="fake", url="null") + first = Agent( + name="first", + llm_config=cfg, + system_prompt="Translate the following to Arabic:\n\n{{joke}}\n\nMake {{count}} variants.", + ) + second = Agent(name="second", llm_config=cfg, system_prompt="You translate.") + swarm = Swarm(name="swarm", first_agent=first, relationships=[(first, second)]) + + assert sorted(p.title for p in (swarm.inputs or [])) == ["count", "joke"] + + +def test_swarm_runs_as_a_flow_step_with_data_edge_inputs() -> None: + """A Swarm flow step loads (data edge resolves) and executes offline. + + The model is stubbed (no real LLM, no handoff), so the entry agent produces a final + message and the swarm routes straight to END. Asserts the flow both loads — proving the + swarm node exposes the ``joke`` input the data edge targets — and runs, surfacing the + entry agent's answer as the node's single string output. + """ + from unittest.mock import patch + + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge + from pyagentspec.flows.flow import Flow + from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + # Final message has no tool_calls → the entry agent answers without handing off. + fake_llm = _FakeModel(responses=[AIMessage(content="لماذا...")]) + + cfg = OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null") + joke = StringProperty(title="joke") + translated = StringProperty(title="translated") + + first = Agent( + name="first", + llm_config=cfg, + system_prompt="Translate the following to Arabic:\n\n{{joke}}", + outputs=[translated], + ) + second = Agent(name="second", llm_config=cfg, system_prompt="You translate.") + swarm = Swarm(name="translator", first_agent=first, relationships=[(first, second)]) + # The swarm node exposes the entry agent's `joke` input, and its single `translated` + # output (inherited from the entry agent) for the leaf edge. + assert [p.title for p in (swarm.inputs or [])] == ["joke"] + + swarm_node = AgentNode(name="swarm_node", agent=swarm) + start_node = StartNode(name="start", inputs=[joke]) + end_node = EndNode(name="end", outputs=[translated]) + flow = Flow( + name="flow", + start_node=start_node, + nodes=[start_node, swarm_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=swarm_node), + ControlFlowEdge(name="node_to_end", from_node=swarm_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="joke_edge", + source_node=start_node, + source_output=joke.title, + destination_node=swarm_node, + destination_input=joke.title, + ), + DataFlowEdge( + name="translated_edge", + source_node=swarm_node, + source_output=translated.title, + destination_node=end_node, + destination_input=translated.title, + ), + ], + outputs=[translated], + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ): + compiled = loader.load_component(flow) + result = compiled.invoke( + { + "inputs": {"joke": "Why did the car..."}, + "messages": [{"role": "user", "content": ""}], + }, + {"configurable": {"thread_id": "swarm-node"}}, + ) + + assert "outputs" in result + assert result["outputs"]["translated"] == "لماذا..." + + +def test_swarm_node_resolves_multiple_structured_outputs() -> None: + """A Swarm flow step resolves several structured outputs. + + Symmetric with ``test_managerworkers_node_resolves_multiple_structured_outputs``: a + swarm also runs over ``MessagesState`` and returns no ``structured_response``, so two + or more declared outputs used to raise ``ValueError`` at the producing node. The + generated values are recovered from the structured-output tool call in the history. + """ + from unittest.mock import patch + + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge + from pyagentspec.flows.flow import Flow + from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + fake_llm = _FakeModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "name": "AgentOutputModel", + "args": {"route": "art", "brief": "a pixel dragon"}, + "id": "call_structured_output", + } + ], + ) + ] + ) + + cfg = OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null") + request = StringProperty(title="request") + route = StringProperty(title="route") + brief = StringProperty(title="brief") + + first = Agent( + name="first", + llm_config=cfg, + system_prompt="Triage this request:\n\n{{request}}", + outputs=[route, brief], + ) + second = Agent(name="second", llm_config=cfg, system_prompt="You help.") + swarm = Swarm(name="triage", first_agent=first, relationships=[(first, second)]) + + swarm_node = AgentNode(name="swarm_node", agent=swarm) + start_node = StartNode(name="start", inputs=[request]) + end_node = EndNode(name="end", outputs=[route, brief]) + flow = Flow( + name="flow", + start_node=start_node, + nodes=[start_node, swarm_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=swarm_node), + ControlFlowEdge(name="node_to_end", from_node=swarm_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="request_edge", + source_node=start_node, + source_output=request.title, + destination_node=swarm_node, + destination_input=request.title, + ), + DataFlowEdge( + name="route_edge", + source_node=swarm_node, + source_output=route.title, + destination_node=end_node, + destination_input=route.title, + ), + DataFlowEdge( + name="brief_edge", + source_node=swarm_node, + source_output=brief.title, + destination_node=end_node, + destination_input=brief.title, + ), + ], + outputs=[route, brief], + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ): + compiled = loader.load_component(flow) + result = compiled.invoke( + { + "inputs": {"request": "make me a dragon"}, + "messages": [{"role": "user", "content": ""}], + }, + {"configurable": {"thread_id": "swarm-node-structured"}}, + ) + + assert result["outputs"]["route"] == "art" + assert result["outputs"]["brief"] == "a pixel dragon" diff --git a/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py b/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py index 2aa37560..65ee12b7 100644 --- a/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py +++ b/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py @@ -149,6 +149,59 @@ def test_non_mtls_remote_connections_enable_tls_verification(client_transport, e assert connection["httpx_client_factory"].verify.check_hostname is True +def test_remote_connections_merge_sensitive_headers_into_request( + client_key_path, client_cert_path, ca_cert_path +): + """`sensitive_headers` must travel on the wire alongside `headers`. + + Sensitive headers are redacted from *exported* configs but still have to be + sent on live requests -- otherwise an Authorization token configured as a + sensitive header never reaches the MCP server. Covers all four remote + transports, since each builds its own connection. + """ + headers = {"X-Plain": "plain"} + sensitive_headers = {"Authorization": "Bearer secret"} + expected = {"X-Plain": "plain", "Authorization": "Bearer secret"} + + transports = [ + SSETransport( + name="sse", + url="https://example.com/sse", + headers=headers, + sensitive_headers=sensitive_headers, + ), + StreamableHTTPTransport( + name="streamable-http", + url="https://example.com/mcp", + headers=headers, + sensitive_headers=sensitive_headers, + ), + SSEmTLSTransport( + name="sse-mtls", + url="https://example.com/sse", + headers=headers, + sensitive_headers=sensitive_headers, + key_file=client_key_path, + cert_file=client_cert_path, + ca_file=ca_cert_path, + ), + StreamableHTTPmTLSTransport( + name="streamable-http-mtls", + url="https://example.com/mcp", + headers=headers, + sensitive_headers=sensitive_headers, + key_file=client_key_path, + cert_file=client_cert_path, + ca_file=ca_cert_path, + ), + ] + + converter = AgentSpecToLangGraphConverter() + for transport in transports: + connection = converter._client_transport_convert_to_langgraph(transport) + assert connection["headers"] == expected, transport.__class__.__name__ + + @pytest.fixture(scope="function") def agentspec_agent_with_mcp_toolbox(sse_client_transport, big_llama): return Agent( @@ -370,3 +423,116 @@ async def test_flow_with_mcp_tool_with_interrupt(sse_client_transport): # Server fooza: a*2 + b*3 - 1 => 2*2 + 5*3 - 1 = 18 assert result["outputs"]["my_result"] == 18 + + +def test_strip_schema_titles_removes_only_schema_title_annotations(): + from pyagentspec.adapters.langgraph._langgraphconverter import _strip_schema_titles + + schema = { + "type": "array", + "title": "Children", + "default": [{"title": "kept payload"}], + "items": { + "anyOf": [ + { + "type": "object", + "title": "Paragraph Block", + "properties": { + "rich_text": { + "type": "array", + "title": "Rich Text", + "items": {"type": "object", "title": "Rich Text Item"}, + }, + "title": {"type": "string", "title": "Page Title"}, + }, + "additionalProperties": {"type": "string", "title": "Extra Value"}, + } + ] + }, + } + + stripped = _strip_schema_titles(schema) + + block = stripped["items"]["anyOf"][0] + assert "title" not in stripped + assert "title" not in block + assert "title" not in block["properties"]["rich_text"] + assert "title" not in block["properties"]["rich_text"]["items"] + assert "title" not in block["additionalProperties"] + # a *property named* "title" is data, not an annotation - only its schema is sanitized + assert block["properties"]["title"] == {"type": "string"} + # non-schema payloads such as default values are untouched + assert stripped["default"] == [{"title": "kept payload"}] + # the input schema is not mutated + assert schema["items"]["anyOf"][0]["title"] == "Paragraph Block" + + +def test_mcp_toolbox_tolerates_openapi_style_nested_schema_titles(monkeypatch): + """Nested titles with spaces (e.g. Notion's "Rich Text") must not kill the run. + + MCP servers commonly derive tool schemas from OpenAPI documents whose nested + schemas carry human-readable titles. The on-the-fly MCPTool built for the + tracing callback used to feed those raw schemas into Property, whose title + validation rejected them and failed the whole agent run. + """ + import langchain_mcp_adapters.tools as mcp_adapter_tools + from langchain_core.tools import StructuredTool + + from pyagentspec.adapters.langgraph.tracing import AgentSpecToolCallbackHandler + + args_schema = { + "type": "object", + "properties": { + "block_id": {"type": "string", "description": "Parent block id."}, + "children": { + "type": "array", + "description": "Array of block objects to append.", + "items": { + "anyOf": [ + { + "type": "object", + "title": "Paragraph Block", + "properties": { + "rich_text": {"type": "array", "title": "Rich Text"}, + }, + } + ] + }, + }, + }, + "required": ["block_id", "children"], + } + + async def run_tool(**kwargs: object) -> str: # pragma: no cover - never invoked + return "ok" + + notion_tool = StructuredTool( + name="API-patch-block-children", + description="Append new children blocks to a block.", + args_schema=args_schema, + coroutine=run_tool, + ) + + async def fake_load_mcp_tools(session, connection): + return [notion_tool] + + monkeypatch.setattr(mcp_adapter_tools, "load_mcp_tools", fake_load_mcp_tools) + + toolbox = MCPToolBox( + name="notion", + client_transport=SSETransport(name="notion server", url="https://example.com/sse"), + ) + + tools = AgentSpecToLangGraphConverter().convert(toolbox, tool_registry={}) + + assert tools == [notion_tool] + # The LLM-facing schema keeps the server's titles untouched... + assert notion_tool.args["children"]["items"]["anyOf"][0]["title"] == "Paragraph Block" + # ...while the tracing MCPTool is built from sanitized schemas + handler = next( + callback + for callback in notion_tool.callbacks + if isinstance(callback, AgentSpecToolCallbackHandler) + ) + children_input = next(inp for inp in handler.tool.inputs if inp.title == "children") + assert "Rich Text" not in str(children_input.json_schema) diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py new file mode 100644 index 00000000..b199526e --- /dev/null +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -0,0 +1,1178 @@ +# Copyright © 2025 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +"""Offline tests for the LangGraph ``ManagerWorkers`` converter. + +These cover the hierarchical topology, roster prompt rendering, the +worker-isolation invariant (each worker sees only its delegated task), +and the recursive nesting case. The LLM is stubbed with +``FakeMessagesListChatModel`` so the tests run without network or model +endpoints. +""" + +from typing import Any +from unittest.mock import patch + +import pytest + +# ─── Shared helpers ────────────────────────────────────────────────────────── + + +def _llm_cfg(name: str) -> Any: + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + return OpenAiCompatibleConfig(name=name, model_id="fake", url="null") + + +def _fake_manager(*ai_responses: Any) -> Any: + """A FakeMessagesListChatModel subclassed under ChatOpenAI so the + manager's react-agent treats it as an OpenAI-style chat model.""" + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_openai import ChatOpenAI + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + return _FakeModel(responses=list(ai_responses)) + + +# ─── Pure-helper unit tests (no LLM) ──────────────────────────────────────── + + +def test_safe_node_name_lowercases_and_collapses_punctuation() -> None: + from pyagentspec.adapters.langgraph._langgraphconverter import _safe_node_name + + assert _safe_node_name("Research Helper", "id-1") == "research_helper" + assert _safe_node_name("My-Worker!! v2", "id-1") == "my_worker_v2" + + +def test_safe_node_name_falls_back_to_normalized_id() -> None: + from pyagentspec.adapters.langgraph._langgraphconverter import _safe_node_name + + # Name slugifies to empty → id used (and also normalized). + assert _safe_node_name("!!!", "sub-1") == "sub_1" + # Both empty → constant fallback. + assert _safe_node_name("", "") == "worker" + + +def test_append_workers_roster_appends_block_after_existing_prompt() -> None: + from pyagentspec.adapters.langgraph._langgraphconverter import _append_workers_roster + + out = _append_workers_roster( + "Coordinate the team.", + [("research_helper", "Handles research"), ("drafter", "Drafts text")], + ) + assert out == ( + "Coordinate the team.\n\n" + "Available workers:\n" + "- research_helper: Handles research\n" + "- drafter: Drafts text" + ) + + +def test_append_workers_roster_flattens_multiline_descriptions() -> None: + from pyagentspec.adapters.langgraph._langgraphconverter import _append_workers_roster + + out = _append_workers_roster( + "", + [("helper", "First line\nsecond line\n third line ")], + ) + # Whitespace flattened so the one-line-per-worker shape survives. + assert out == "Available workers:\n- helper: First line second line third line" + + +def test_route_manager_to_worker_or_end_sends_to_pending_delegation() -> None: + from langchain_core.messages import AIMessage + from langgraph.types import Send + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _DELEGATE_CALL_ID_KEY, + _DELEGATE_TASK_KEY, + _route_manager_to_worker_handoff_or_end, + ) + + delegating = AIMessage( + content="", + tool_calls=[{"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "c1"}], + ) + sends = _route_manager_to_worker_handoff_or_end({"messages": [delegating]}) + # One delegation → a single Send to the worker node carrying the task + # 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._langgraphconverter import ( + _route_manager_to_worker_handoff_or_end, + ) + + not_delegating = AIMessage(content="Done.", tool_calls=[]) + assert _route_manager_to_worker_handoff_or_end({"messages": [not_delegating]}) == END + assert _route_manager_to_worker_handoff_or_end({"messages": []}) == END + + +def test_route_manager_to_worker_or_end_fans_out_every_delegation() -> None: + from langchain_core.messages import AIMessage + from langgraph.types import Send + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _DELEGATE_CALL_ID_KEY, + _DELEGATE_TASK_KEY, + _route_manager_to_worker_handoff_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_handoff_or_end({"messages": [msg]}) + # Every delegation gets its own Send so each tool_call_id is answered. + # The non-delegation tool call was already executed inside the manager's + # react loop and is ignored by routing. + 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 END, START + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _MANAGER_NODE_KEY, + AgentSpecToLangGraphConverter, + ) + 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 ( + _MANAGER_NODE_KEY, + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + manager_agent = Agent( + name="Coordinator", + description="Coordinates", + system_prompt="Coordinate the team.", + llm_config=_llm_cfg("manager_llm"), + ) + worker = Agent( + name="Research Helper", + description="Handles research tasks", + system_prompt="Research.", + llm_config=_llm_cfg("worker_llm"), + ) + mw = ManagerWorkers( + name="Team", + group_manager=manager_agent, + workers=[worker], + ) + + fake_llm = _fake_manager(AIMessage(content="Done.")) + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, "_llm_convert_to_langgraph", return_value=fake_llm + ): + compiled = loader.load_component(mw) + + # The manager react-agent is itself a subgraph; its create_agent + # middleware stack carries the rendered system prompt as the + # first message of every turn. Walk the manager subgraph's pre-model + # hook chain to find it. + manager_subgraph = compiled.builder.nodes[_MANAGER_NODE_KEY].runnable + # `create_agent` builds a graph whose system message generation + # wraps the prompt — easier to assert by re-rendering it through the + # same helper used by the converter and checking the *intent*. + from pyagentspec.adapters.langgraph._langgraphconverter import _append_workers_roster + + expected = _append_workers_roster( + "Coordinate the team.", + [("research_helper", "Handles research tasks")], + ) + assert "Available workers:" in expected + assert "- research_helper: Handles research tasks" in expected + # And the compiled manager carries the delegation tool the prompt + # advertises, proving the LLM has the matching contract. + tools_node = manager_subgraph.builder.nodes["tools"].runnable + assert "delegate_to_research_helper" in tools_node.tools_by_name + + +# ─── End-to-end execution test (offline, fake LLM emitting delegation) ────── + + +def test_manager_workers_delegates_and_routes_back_with_tool_message() -> None: + """End-to-end: manager LLM emits a delegate_to_ tool call, + the parent graph routes to the worker subgraph (which runs with an + isolated message context), the worker's final AIMessage content is + surfaced back to the manager as a ToolMessage matched to the + pending tool_call_id, and the manager's next turn (no tool call) + terminates the graph. This is the load-bearing path that proves the + subgraph composition actually works.""" + from langchain_core.messages import AIMessage, HumanMessage + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + manager_agent = Agent( + name="Coordinator", + description="Coordinates", + system_prompt="You coordinate.", + llm_config=_llm_cfg("manager_llm"), + ) + worker = Agent( + name="Research Helper", + description="Handles research", + system_prompt="You research.", + llm_config=_llm_cfg("worker_llm"), + ) + mw = ManagerWorkers( + name="Team", + group_manager=manager_agent, + workers=[worker], + ) + + # Manager turn 1: delegate to research_helper. + # Manager turn 2: produce final answer (no tool call → END). + manager_responses = [ + AIMessage( + content="", + tool_calls=[ + { + "name": "delegate_to_research_helper", + "args": {"task": "Look up Saturn"}, + "id": "call_1", + } + ], + ), + AIMessage(content="The worker reports: Saturn has rings."), + ] + # Worker turn 1: produce its own final answer. + worker_responses = [AIMessage(content="Saturn has rings.")] + + fake_manager = _fake_manager(*manager_responses) + fake_worker = _fake_manager(*worker_responses) + + def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: + if llm_config.name == "manager_llm": + return fake_manager + if llm_config.name == "worker_llm": + return fake_worker + raise AssertionError(f"unexpected llm_config: {llm_config.name}") + + # ``create_agent`` calls ``model.bind_tools(...)``. ``FakeMessagesListChatModel`` + # inherits ``bind_tools`` from real ``ChatOpenAI``, which calls out to + # OpenAI. Patch the class method so binding is a no-op that returns the + # same fake (preserving its response queue). + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=_dispatch, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **kw: self_obj, + ): + compiled = loader.load_component(mw) + + # Use the sync invocation path: ``FakeMessagesListChatModel`` provides + # a sync ``_generate`` (returns queued responses) but no async + # override, so MRO resolves ``_agenerate`` to the real + # ``ChatOpenAI._agenerate`` which calls the OpenAI API. The worker + # wrapper exposes both sync and async via RunnableLambda; LangGraph + # picks the sync path here. + result = compiled.invoke( + {"messages": [HumanMessage(content="Tell me about Saturn.")]}, + {"configurable": {"thread_id": "mw-1"}}, + ) + messages = result["messages"] + + # The end state should contain: user input, manager's delegation + # AIMessage, the synthesized ToolMessage (worker's reply), and the + # manager's final AIMessage. + msg_types = [type(m).__name__ for m in messages] + assert "HumanMessage" in msg_types + assert "ToolMessage" in msg_types + # Final message is the manager's terminating AIMessage. + assert isinstance(messages[-1], AIMessage) + assert "Saturn has rings" in messages[-1].content + + # And the ToolMessage carries the worker's reply matched to the + # pending delegation tool_call_id — proves the isolation wrapper + # threaded the call id through. + tool_msgs = [m for m in messages if type(m).__name__ == "ToolMessage"] + assert tool_msgs and tool_msgs[0].tool_call_id == "call_1" + assert "Saturn has rings" in tool_msgs[0].content + + +def test_worker_receives_its_task_as_a_human_message() -> None: + """The manager's delegated task reaches the worker as a HumanMessage. + + A chat model generates the next assistant turn in response to a user (or + tool) turn, so the worker needs a user-role message to answer. Delivering + the task as a SystemMessage leaves the worker with a system-only + conversation and nothing to respond to — strict OpenAI-compatible + providers return an empty completion and langchain-core then raises + "No generations found in stream", failing the delegation. This guards the + role choice in ``_wrap_worker_for_subgraph._worker_input``. + + (The worker's input message streaming out and rendering as a spurious + end-user turn is a consumer-side rendering concern, handled downstream by + dropping user-role messages from non-root subgraph namespaces — not by + starving the model of the user turn it needs.) + """ + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import AIMessage, HumanMessage, SystemMessage + from langchain_openai import ChatOpenAI + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _DELEGATION_TASK_MARKER_KEY, + _DELEGATION_TASK_MARKER_VALUE, + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + worker_inputs: list = [] + + class _CapturingModel(FakeMessagesListChatModel, ChatOpenAI): + """Records the messages passed to each worker model call.""" + + def _generate(self, messages: Any, *args: Any, **kwargs: Any) -> Any: + worker_inputs.append(list(messages)) + return super()._generate(messages, *args, **kwargs) + + manager_agent = Agent( + name="Coordinator", + description="Coordinates", + system_prompt="You coordinate.", + llm_config=_llm_cfg("manager_llm"), + ) + worker = Agent( + name="Research Helper", + description="Handles research", + system_prompt="You research.", + llm_config=_llm_cfg("worker_llm"), + ) + mw = ManagerWorkers(name="Team", group_manager=manager_agent, workers=[worker]) + + fake_manager = _fake_manager( + AIMessage( + content="", + tool_calls=[ + { + "name": "delegate_to_research_helper", + "args": {"task": "Look up Saturn"}, + "id": "call_1", + } + ], + ), + AIMessage(content="Done."), + ) + fake_worker = _CapturingModel(responses=[AIMessage(content="Saturn has rings.")]) + + def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: + if llm_config.name == "manager_llm": + return fake_manager + if llm_config.name == "worker_llm": + return fake_worker + raise AssertionError(f"unexpected llm_config: {llm_config.name}") + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=_dispatch, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **kw: self_obj, + ): + compiled = loader.load_component(mw) + + compiled.invoke( + {"messages": [HumanMessage(content="Tell me about Saturn.")]}, + {"configurable": {"thread_id": "mw-humanmsg-1"}}, + ) + + assert worker_inputs, "worker model was never invoked" + first_call = worker_inputs[0] + # The delegated task reached the worker as a HumanMessage (a user turn the + # model can answer)... + task_msg = next( + ( + m + for m in first_call + if isinstance(m, HumanMessage) and "Look up Saturn" in (m.content or "") + ), + None, + ) + assert task_msg is not None, [type(m).__name__ for m in first_call] + # ...carrying the delegation marker so consumers can tell it apart from a + # real end-user turn (and drop/relabel it) instead of rendering it as one. + assert ( + task_msg.additional_kwargs.get(_DELEGATION_TASK_MARKER_KEY) == _DELEGATION_TASK_MARKER_VALUE + ) + # ...and the task was NOT smuggled in as a SystemMessage (which would leave + # the model with no user turn to respond to). The worker's own system + # prompt is still a SystemMessage, so assert on the task content, not the + # mere presence of a SystemMessage. + assert not any( + isinstance(m, SystemMessage) and "Look up Saturn" in (m.content or "") for m in first_call + ) + + +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) + + +def test_worker_events_stream_natively_namespaced_under_worker_node() -> None: + """Regression: a worker's token events must stream under the worker + node's checkpoint namespace so a consumer can attribute them to the + sub-agent. The wrapper must inherit the ambient run config (no fresh + thread_id); a fresh thread_id detaches the worker into a top-level + ``agent:`` run with no worker prefix, which is unattributable.""" + import asyncio + + from langchain_core.language_models.fake_chat_models import ( + GenericFakeChatModel, + ) + from langchain_core.messages import AIMessage, HumanMessage + from langgraph.graph import END, START, MessagesState, StateGraph + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _wrap_worker_for_subgraph, + ) + + # A minimal worker compiled graph that streams some content. + wmodel = GenericFakeChatModel(messages=iter([AIMessage(content="Saturn has rings")] * 9)) + wb = StateGraph(MessagesState) + + async def _wagent(state: Any) -> Any: + return {"messages": [await wmodel.ainvoke(state["messages"])]} + + wb.add_node("agent", _wagent) + wb.add_edge(START, "agent") + wb.add_edge("agent", END) + worker_graph = wb.compile() + + # Parent: a plain manager node emits the delegate tool call, then routes + # to the wrapped worker node named "research_helper". + pb = StateGraph(MessagesState) + + def _manager(state: Any) -> Any: + return { + "messages": [ + AIMessage( + content="", + tool_calls=[ + { + "name": "delegate_to_research_helper", + "args": {"task": "Saturn"}, + "id": "c1", + } + ], + ) + ] + } + + pb.add_node("__manager__", _manager) + pb.add_node("research_helper", _wrap_worker_for_subgraph(worker_graph, "research_helper")) + pb.add_edge(START, "__manager__") + pb.add_edge("__manager__", "research_helper") + pb.add_edge("research_helper", END) + parent = pb.compile() + + async def _collect() -> Any: + namespaces = [] + async for ev in parent.astream_events( + {"messages": [HumanMessage(content="hi")]}, + {"configurable": {"thread_id": "t"}}, + version="v2", + ): + if ev["event"] == "on_chat_model_stream": + ns = (ev.get("metadata") or {}).get("langgraph_checkpoint_ns", "") + namespaces.append(ns) + return namespaces + + namespaces = asyncio.run(_collect()) + assert namespaces, "expected the worker to emit token-stream events" + # Every worker token event is namespaced under the worker node, so a + # consumer can attribute the stream to the sub-agent. + assert all(ns.startswith("research_helper:") for ns in namespaces), namespaces + + +# ─── ManagerWorkers: a worker (subgraph) error must not orphan the delegation ─ + + +def _raising_worker_graph() -> Any: + """A compiled-graph stand-in whose invoke/ainvoke always raise, standing + in for a worker subgraph that crashes mid-run.""" + + class _Graph: + def invoke(self, _input: Any) -> Any: + raise RuntimeError("worker boom") + + async def ainvoke(self, _input: Any) -> Any: + raise RuntimeError("worker boom") + + return _Graph() + + +def test_worker_error_answers_pending_delegation_with_error_tool_message() -> None: + """A worker that raises answers the manager's pending + ``delegate_to_`` tool-call with an error ToolMessage (matched to + the tool_call_id carried on the Send fan-out payload) rather than letting + the exception propagate. Covers both the sync and async node entrypoints.""" + import asyncio + + from langchain_core.messages import ToolMessage + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _DELEGATE_CALL_ID_KEY, + _DELEGATE_TASK_KEY, + _wrap_worker_for_subgraph, + ) + + node = _wrap_worker_for_subgraph(_raising_worker_graph(), "researcher") + state = {_DELEGATE_TASK_KEY: "find X", _DELEGATE_CALL_ID_KEY: "c1"} + + result = asyncio.run(node.ainvoke(state)) + [msg] = result["messages"] + assert isinstance(msg, ToolMessage) + assert msg.tool_call_id == "c1" + assert msg.status == "error" + assert "researcher" in msg.content + assert "worker boom" in msg.content + + result_sync = node.invoke(state) + [msg_sync] = result_sync["messages"] + assert isinstance(msg_sync, ToolMessage) + assert msg_sync.tool_call_id == "c1" + assert msg_sync.status == "error" + + +def test_worker_error_recovers_call_id_from_manager_ai_message() -> None: + """Direct-edge path (no Send payload): the failing worker recovers the + pending tool_call_id from the manager's last AIMessage.""" + import asyncio + + from langchain_core.messages import AIMessage, ToolMessage + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _wrap_worker_for_subgraph, + ) + + ai = AIMessage( + content="", + tool_calls=[{"name": "delegate_to_researcher", "args": {"task": "find X"}, "id": "c9"}], + ) + node = _wrap_worker_for_subgraph(_raising_worker_graph(), "researcher") + + result = asyncio.run(node.ainvoke({"messages": [ai]})) + [msg] = result["messages"] + assert isinstance(msg, ToolMessage) + assert msg.tool_call_id == "c9" + assert msg.status == "error" + + +def test_worker_error_reraises_when_no_pending_delegation() -> None: + """With no delegation to answer (empty manager state), the original error + must surface rather than being silently swallowed — there is no tool-call + to keep well-formed.""" + import asyncio + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _wrap_worker_for_subgraph, + ) + + node = _wrap_worker_for_subgraph(_raising_worker_graph(), "researcher") + + with pytest.raises(RuntimeError, match="empty manager state"): + asyncio.run(node.ainvoke({"messages": []})) + + +def test_worker_error_lets_parent_run_complete_with_matched_tool_message() -> None: + """End-to-end: a worker subgraph that raises must NOT abort the parent + run. The worker node answers the manager's delegation with an error + ToolMessage, keeping the transcript well-formed (every tool_call answered), + so the manager can react instead of the exception killing the run.""" + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + from langgraph.graph import END, START, MessagesState, StateGraph + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _wrap_worker_for_subgraph, + ) + + wb = StateGraph(MessagesState) + + def _boom(state: Any) -> Any: + raise RuntimeError("worker boom") + + wb.add_node("agent", _boom) + wb.add_edge(START, "agent") + wb.add_edge("agent", END) + worker_graph = wb.compile() + + pb = StateGraph(MessagesState) + + def _manager(state: Any) -> Any: + return { + "messages": [ + AIMessage( + content="", + tool_calls=[ + { + "name": "delegate_to_research_helper", + "args": {"task": "Saturn"}, + "id": "c1", + } + ], + ) + ] + } + + pb.add_node("__manager__", _manager) + pb.add_node("research_helper", _wrap_worker_for_subgraph(worker_graph, "research_helper")) + pb.add_edge(START, "__manager__") + pb.add_edge("__manager__", "research_helper") + pb.add_edge("research_helper", END) + parent = pb.compile() + + # The run completes without raising ... + result = parent.invoke( + {"messages": [HumanMessage(content="hi")]}, + {"configurable": {"thread_id": "t"}}, + ) + messages = result["messages"] + + # ... the delegation is answered by exactly one error ToolMessage ... + tool_msgs = [m for m in messages if isinstance(m, ToolMessage)] + assert len(tool_msgs) == 1 + assert tool_msgs[0].tool_call_id == "c1" + assert tool_msgs[0].status == "error" + assert "worker boom" in tool_msgs[0].content + + # ... and no tool_call is left orphaned (an orphan would 400 a real LLM). + answered = {m.tool_call_id for m in tool_msgs} + for m in messages: + for tc in getattr(m, "tool_calls", None) or []: + assert tc["id"] in answered, f"orphan tool_call {tc['id']}" + + +# ─── ManagerWorkers as a Swarm member: handoff to a sibling ───────────────── + + +def test_handoff_tool_name_normalizes_like_node_names() -> None: + from pyagentspec.adapters.langgraph._langgraphconverter import _handoff_tool_name + + assert _handoff_tool_name("Specialist") == "transfer_to_specialist" + assert _handoff_tool_name("Math Helper v2") == "transfer_to_math_helper_v2" + # Empty / punctuation-only falls back to a stable identifier. + assert _handoff_tool_name("!!!") == "transfer_to_agent" + + +def test_route_manager_handoff_takes_precedence_over_delegation() -> None: + """A ``transfer_to_`` call routes to the handoff node, and wins + over any ``delegate_to_`` calls emitted in the same turn.""" + from langchain_core.messages import AIMessage + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _HANDOFF_NODE_KEY, + _route_manager_to_worker_handoff_or_end, + ) + + mixed = AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_research_helper", "args": {"task": "x"}, "id": "c1"}, + {"name": "transfer_to_specialist", "args": {}, "id": "c2"}, + ], + ) + assert _route_manager_to_worker_handoff_or_end({"messages": [mixed]}) == _HANDOFF_NODE_KEY + + +def test_make_handoff_forward_node_reemits_parent_command() -> None: + """The handoff node returns a ``Command(goto=, graph=PARENT)`` + that sets ``active_agent`` and forwards the transfer AIMessage ahead of a + ToolMessage answering *every* unanswered tool call on it.""" + from langchain_core.messages import AIMessage, ToolMessage + from langgraph.types import Command + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + _make_handoff_forward_node, + ) + + node = _make_handoff_forward_node({"transfer_to_specialist": "Specialist"}) + transfer_ai = AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_helper", "args": {"task": "y"}, "id": "d1"}, + {"name": "transfer_to_specialist", "args": {}, "id": "h1"}, + ], + ) + out = node.invoke({"messages": [transfer_ai]}) + + assert isinstance(out, Command) + assert out.goto == "Specialist" + assert out.graph == Command.PARENT + assert out.update["active_agent"] == "Specialist" + + forwarded = out.update["messages"] + # Transfer AIMessage first, then a ToolMessage per unanswered call. + assert forwarded[0] is transfer_ai + tool_msgs = [m for m in forwarded if isinstance(m, ToolMessage)] + answered = {m.tool_call_id for m in tool_msgs} + assert answered == {"d1", "h1"} + transferred = next(m for m in tool_msgs if m.tool_call_id == "h1") + assert transferred.content == "Successfully transferred to Specialist" + + +def test_manager_workers_as_swarm_member_hands_off_to_sibling() -> None: + """End-to-end: a Swarm whose first member is a ManagerWorkers. The + manager's LLM emits ``transfer_to_``; the parent graph re-emits + the handoff to the Swarm, which routes to the sibling Agent and lets it + answer — proving a sub-agent-bearing agent can participate in a Swarm + (the case the LangGraph adapter used to reject).""" + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + from pyagentspec.swarm import Swarm + + manager_agent = Agent( + name="Coordinator", + description="Coordinates", + system_prompt="You coordinate.", + llm_config=_llm_cfg("manager_llm"), + ) + worker = Agent( + name="Research Helper", + description="Handles research", + system_prompt="You research.", + llm_config=_llm_cfg("worker_llm"), + ) + team = ManagerWorkers(name="Team", group_manager=manager_agent, workers=[worker]) + specialist = Agent( + name="Specialist", + description="Domain specialist", + system_prompt="You are the specialist.", + llm_config=_llm_cfg("specialist_llm"), + ) + swarm = Swarm( + name="Crew", + first_agent=team, + relationships=[(team, specialist), (specialist, team)], + ) + + # Manager turn 1: hand the conversation off to the Specialist sibling. + fake_manager = _fake_manager( + AIMessage( + content="", + tool_calls=[{"name": "transfer_to_specialist", "args": {}, "id": "call_h1"}], + ) + ) + fake_specialist = _fake_manager(AIMessage(content="Specialist handled it.")) + fake_worker = _fake_manager(AIMessage(content="(worker, unused)")) + + def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: + return { + "manager_llm": fake_manager, + "worker_llm": fake_worker, + "specialist_llm": fake_specialist, + }[llm_config.name] + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=_dispatch, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **kw: self_obj, + ): + compiled = loader.load_component(swarm) + + result = compiled.invoke( + {"messages": [HumanMessage(content="Please help.")]}, + {"configurable": {"thread_id": "crew-1"}}, + ) + messages = result["messages"] + + # The Specialist produced the final answer → the handoff actually routed. + assert isinstance(messages[-1], AIMessage) + assert "Specialist handled it." in messages[-1].content + + # The transfer is a well-formed AIMessage(tool_call) → ToolMessage pair. + transferred = [ + m + for m in messages + if isinstance(m, ToolMessage) and m.content == "Successfully transferred to Specialist" + ] + assert transferred and transferred[0].tool_call_id == "call_h1" + + # Transcript validity: every ToolMessage answers a preceding AIMessage + # tool_call with a matching id (an orphan ToolMessage would 400 a real LLM). + open_call_ids: set = set() + for m in messages: + for tc in getattr(m, "tool_calls", None) or []: + open_call_ids.add(tc["id"]) + if isinstance(m, ToolMessage): + assert ( + m.tool_call_id in open_call_ids + ), f"orphan ToolMessage {m.tool_call_id} with no preceding tool_call" + + +def test_swarm_rejects_unsupported_member_type() -> None: + """A Swarm member that is neither an Agent nor a ManagerWorkers (here a + nested Swarm) raises a clear NotImplementedError — there is no single + LLM to attach the handoff tools to.""" + import pytest + + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.swarm import Swarm + + a1 = Agent(name="A1", description="a1", system_prompt="x", llm_config=_llm_cfg("l1")) + a2 = Agent(name="A2", description="a2", system_prompt="y", llm_config=_llm_cfg("l2")) + nested = Swarm(name="Nested", first_agent=a1, relationships=[(a1, a2)]) + outer = Swarm(name="Outer", first_agent=nested, relationships=[(nested, a1)]) + + with pytest.raises(NotImplementedError, match="Swarm"): + AgentSpecToLangGraphConverter().convert(outer, tool_registry={}) diff --git a/pyagentspec/tests/adapters/langgraph/test_run_async_in_sync.py b/pyagentspec/tests/adapters/langgraph/test_run_async_in_sync.py new file mode 100644 index 00000000..882cd9fe --- /dev/null +++ b/pyagentspec/tests/adapters/langgraph/test_run_async_in_sync.py @@ -0,0 +1,79 @@ +# Copyright © 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +"""``run_async_in_sync`` must carry the caller's contextvars into the worker +thread it spawns for the async→sync-from-async case. + +When ``run_async_in_sync`` is called from a running event loop +(``AsyncContext.ASYNC``) it runs the coroutine on a brand-new worker thread with +its own event loop. A fresh thread starts with an EMPTY contextvars context, so +without copying the caller's context, request-scoped state the coroutine reads — +tenant/user identity, the OTEL trace context — is silently lost. This is exactly +what dropped the per-request headers of an MCP client loaded synchronously from +an async request handler. +""" + +import contextvars + +import pytest + +from pyagentspec.adapters.langgraph.mcp_utils import ( + AsyncContext, + get_execution_context, + run_async_in_sync, +) + +_probe: contextvars.ContextVar[str] = contextvars.ContextVar("probe", default="") + + +@pytest.mark.anyio +async def test_run_async_in_sync_propagates_contextvars_across_worker_thread() -> None: + # Being inside the event loop, this is the ASYNC case — the one that spawns + # the worker thread; the propagation gap only exists there. + assert get_execution_context() is AsyncContext.ASYNC + + async def read_probe() -> str: + return _probe.get() + + token = _probe.set("azaaza") + try: + assert run_async_in_sync(read_probe) == "azaaza" + finally: + _probe.reset(token) + + +@pytest.mark.anyio +async def test_run_async_in_sync_when_async_library_marker_is_set() -> None: + # Simulate an anyio-managed caller: the sniffio async-library marker is set, + # so copy_context() carries it into the worker thread. A naive anyio.run() + # there raises "Already running in this thread"; the fix must clear the + # inherited marker AND still propagate the caller's contextvars. + from sniffio import current_async_library_cvar + + async def read_probe() -> str: + return _probe.get() + + marker = current_async_library_cvar.set("asyncio") + probe = _probe.set("azaaza") + try: + assert run_async_in_sync(read_probe) == "azaaza" + finally: + _probe.reset(probe) + current_async_library_cvar.reset(marker) + + +def test_run_async_in_sync_runs_in_plain_sync_context() -> None: + # Sanity: the synchronous case (no loop) already shares the caller's context, + # so this passed before the fix too — it guards against a regression that + # would break the common path. + async def read_probe() -> str: + return _probe.get() + + token = _probe.set("sync-tenant") + try: + assert run_async_in_sync(read_probe) == "sync-tenant" + finally: + _probe.reset(token) diff --git a/pyagentspec/tests/adapters/langgraph/test_tools.py b/pyagentspec/tests/adapters/langgraph/test_tools.py index f27a0d82..a2fffb8f 100644 --- a/pyagentspec/tests/adapters/langgraph/test_tools.py +++ b/pyagentspec/tests/adapters/langgraph/test_tools.py @@ -4,7 +4,9 @@ # (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. +import asyncio import threading +import time from typing import Any from unittest.mock import patch @@ -837,6 +839,224 @@ async def double_tool_func(x: int) -> int: assert "10" in str(tool_result_message.content) +def test_server_tool_confirmation_with_typed_object_output_works() -> None: + """Tools with requires_confirmation and a typed (object) output schema + should load without error and execute the approved path. The output schema + is metadata for the LLM, not a runtime constraint, and on rejection the + denial string maps cleanly into a single declared output.""" + from langchain_core.runnables import RunnableConfig + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + + bash_result = {"stdout": "hello", "stderr": "", "exit_code": 0} + + def bash_func(command: str) -> dict: + return bash_result + + server_tool = ServerTool( + name="bash", + description="Run a shell command", + inputs=[Property(title="command", json_schema={"title": "command", "type": "string"})], + outputs=[ + Property( + title="result", + json_schema={ + "type": "object", + "properties": { + "stdout": {"type": "string"}, + "stderr": {"type": "string"}, + "exit_code": {"type": "number"}, + }, + }, + ), + ], + requires_confirmation=True, + ) + flow = _make_simple_flow_with_tool(ToolNode(name="bash_node", tool=server_tool)) + + app = AgentSpecLoader( + tool_registry={"bash": bash_func}, + checkpointer=MemorySaver(), + ).load_component(flow) + + config = RunnableConfig({"configurable": {"thread_id": "typed-out-1"}}) + + interrupt_payload = _invoke_until_interrupt( + app, {"inputs": {"command": "echo hello"}}, config=config + ) + assert interrupt_payload["action_requests"][0]["name"] == "bash" + + result = app.invoke(_approve_command(), config=config) + assert result["outputs"]["result"] == bash_result + + +def _make_multi_output_flow_with_tool(tool_node): + """Build Start -> Tool -> End wiring all three bash outputs (stdout, stderr, exit_code).""" + from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge + from pyagentspec.flows.flow import Flow + from pyagentspec.flows.nodes import EndNode, StartNode + + start_node = StartNode( + name="start", + inputs=[Property(title="command", json_schema={"title": "command", "type": "string"})], + ) + end_node = EndNode( + name="end", + outputs=[ + Property(title="stdout", json_schema={"type": "string"}), + Property(title="stderr", json_schema={"type": "string"}), + Property(title="exit_code", json_schema={"type": "number"}), + ], + ) + return Flow( + name="flow", + start_node=start_node, + nodes=[start_node, tool_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_tool", from_node=start_node, to_node=tool_node), + ControlFlowEdge(name="tool_to_end", from_node=tool_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="cmd_edge", + source_node=start_node, + source_output="command", + destination_node=tool_node, + destination_input="command", + ), + DataFlowEdge( + name="stdout_edge", + source_node=tool_node, + source_output="stdout", + destination_node=end_node, + destination_input="stdout", + ), + DataFlowEdge( + name="stderr_edge", + source_node=tool_node, + source_output="stderr", + destination_node=end_node, + destination_input="stderr", + ), + DataFlowEdge( + name="exit_code_edge", + source_node=tool_node, + source_output="exit_code", + destination_node=end_node, + destination_input="exit_code", + ), + ], + ) + + +def _make_multi_output_bash_server_tool(): + return ServerTool( + name="bash", + description="Run a shell command", + inputs=[Property(title="command", json_schema={"title": "command", "type": "string"})], + outputs=[ + Property(title="stdout", json_schema={"type": "string"}), + Property(title="stderr", json_schema={"type": "string"}), + Property(title="exit_code", json_schema={"type": "number"}), + ], + requires_confirmation=True, + ) + + +def test_server_tool_confirmation_with_multi_output_in_flow_tool_node_approve_executes() -> None: + """A ServerTool with multiple outputs and requires_confirmation loads and executes + correctly when the user approves. The outputs are mapped from the returned dict.""" + from langchain_core.runnables import RunnableConfig + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + + bash_result = {"stdout": "hello", "stderr": "", "exit_code": 0} + + def bash_func(command: str) -> dict: + return bash_result + + server_tool = _make_multi_output_bash_server_tool() + flow = _make_multi_output_flow_with_tool(ToolNode(name="bash_node", tool=server_tool)) + + app = AgentSpecLoader( + tool_registry={"bash": bash_func}, + checkpointer=MemorySaver(), + ).load_component(flow) + + config = RunnableConfig({"configurable": {"thread_id": "multi-out-approve-1"}}) + interrupt_payload = _invoke_until_interrupt( + app, {"inputs": {"command": "echo hello"}}, config=config + ) + assert interrupt_payload["action_requests"][0]["name"] == "bash" + + result = app.invoke(_approve_command(), config=config) + assert result["outputs"]["stdout"] == "hello" + assert result["outputs"]["stderr"] == "" + assert result["outputs"]["exit_code"] == 0 + + +def test_server_tool_confirmation_with_multi_output_in_flow_tool_node_reject_raises() -> None: + """When a ServerTool with multiple outputs is denied inside a Flow ToolNode, a + RuntimeError is raised with a clear message rather than returning an unmappable + denial string.""" + from langchain_core.runnables import RunnableConfig + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + + def bash_func(command: str) -> dict: + return {"stdout": "hello", "stderr": "", "exit_code": 0} + + server_tool = _make_multi_output_bash_server_tool() + flow = _make_multi_output_flow_with_tool(ToolNode(name="bash_node", tool=server_tool)) + + app = AgentSpecLoader( + tool_registry={"bash": bash_func}, + checkpointer=MemorySaver(), + ).load_component(flow) + + config = RunnableConfig({"configurable": {"thread_id": "multi-out-reject-1"}}) + _ = _invoke_until_interrupt(app, {"inputs": {"command": "echo hello"}}, config=config) + + with pytest.raises(Exception, match="denied"): + app.invoke(_reject_command("nope"), config=config) + + +def test_client_tool_confirmation_with_multi_output_in_flow_tool_node_reject_raises() -> None: + """When a ClientTool with multiple outputs is denied inside a Flow ToolNode, a + RuntimeError is raised with a clear message rather than an unmappable denial string.""" + from langchain_core.runnables import RunnableConfig + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + + client_tool = ClientTool( + name="bash", + description="Run a shell command", + inputs=[Property(title="command", json_schema={"title": "command", "type": "string"})], + outputs=[ + Property(title="stdout", json_schema={"type": "string"}), + Property(title="stderr", json_schema={"type": "string"}), + Property(title="exit_code", json_schema={"type": "number"}), + ], + requires_confirmation=True, + ) + flow = _make_multi_output_flow_with_tool(ToolNode(name="bash_node", tool=client_tool)) + + app = AgentSpecLoader( + tool_registry={}, + checkpointer=MemorySaver(), + ).load_component(flow) + + config = RunnableConfig({"configurable": {"thread_id": "client-multi-out-reject-1"}}) + _ = _invoke_until_interrupt(app, {"inputs": {"command": "echo hello"}}, config=config) + + with pytest.raises(Exception, match="denied"): + app.invoke(_reject_command("nope"), config=config) + + def test_requires_confirmation_without_checkpointer_raises_for_server_tool_in_flow() -> None: from pyagentspec.adapters.langgraph import AgentSpecLoader @@ -889,6 +1109,39 @@ def test_server_tool_missing_from_registry_raises() -> None: AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()).load_component(flow) +@pytest.mark.anyio +async def test_remote_tool_coroutine_does_not_block_event_loop() -> None: + from pyagentspec.adapters.langgraph import AgentSpecLoader + + def mock_request(*args: Any, **kwargs: Any) -> DummyResponse: + time.sleep(0.2) + return DummyResponse({"ok": True, "body": kwargs["json"]}) + + remote_tool = RemoteTool( + name="remote_echo", + description="Echoes the input value", + url="https://example.com/echo", + http_method="POST", + data={"x": "{{x}}"}, + inputs=[IntegerProperty(title="x")], + outputs=[Property(title="result", json_schema={})], + ) + + lang_tool = AgentSpecLoader().load_component(remote_tool) + + assert lang_tool.coroutine is not None + + with patch("httpx.request", side_effect=mock_request): + started_at = time.monotonic() + task = asyncio.create_task(lang_tool.coroutine(x=5)) + + await asyncio.sleep(0.01) + + assert time.monotonic() - started_at < 0.1 + assert not task.done() + assert await task == {"ok": True, "body": {"x": "5"}} + + @pytest.mark.anyio async def test_async_server_tool_callable_converts_to_structured_tool_coroutine() -> None: from pyagentspec.adapters.langgraph import AgentSpecLoader diff --git a/pyagentspec/tests/adapters/test_template_rendering.py b/pyagentspec/tests/adapters/test_template_rendering.py index 806e3d44..e81c7c43 100644 --- a/pyagentspec/tests/adapters/test_template_rendering.py +++ b/pyagentspec/tests/adapters/test_template_rendering.py @@ -7,8 +7,18 @@ from typing import Any, Dict import pytest +from pydantic import BaseModel + +from pyagentspec.adapters._utils import ( + render_nested_json_template, + render_nested_object_template, + render_template, +) -from pyagentspec.adapters._utils import render_nested_object_template, render_template + +class _Member(BaseModel): + userId: str + roles: Any = None @pytest.mark.parametrize( @@ -112,3 +122,40 @@ def test_json_template_are_properly_rendered( template: str, inputs: Dict[str, Any], expected: str ) -> None: assert render_nested_object_template(template, inputs) == expected + + +@pytest.mark.parametrize( + "template, inputs, expected", + [ + # A whole-placeholder value keeps its type (list/dict/number/bool/None), + # so structured tool arguments survive into a JSON body. + ("{{a}}", {"a": [1, 2]}, [1, 2]), + ("{{a}}", {"a": None}, None), + ("{{a}}", {"a": 5}, 5), + ("{{a}}", {"a": True}, True), + ("{{a}}", {"a": {"k": "v"}}, {"k": "v"}), + # Strings stay strings; embedded placeholders still interpolate. + ("{{a}}", {"a": "oneOnOne"}, "oneOnOne"), + ("id-{{a}}", {"a": 5}, "id-5"), + # Dict keys are always rendered as strings; only values keep their type. + ( + {"input": {"members": "{{members}}", "topic": "{{topic}}", "type": "{{type}}"}}, + { + "members": [_Member(userId="u1", roles=None)], + "topic": None, + "type": "group", + }, + { + "input": { + "members": [{"userId": "u1", "roles": None}], + "topic": None, + "type": "group", + } + }, + ), + ], +) +def test_json_body_template_preserves_value_types( + template: Any, inputs: Dict[str, Any], expected: Any +) -> None: + assert render_nested_json_template(template, inputs) == expected diff --git a/pyagentspec/tests/test_bare_object_schema.py b/pyagentspec/tests/test_bare_object_schema.py new file mode 100644 index 00000000..96d48463 --- /dev/null +++ b/pyagentspec/tests/test_bare_object_schema.py @@ -0,0 +1,49 @@ +# Copyright © 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +"""A bare ``{"type": "object"}`` schema (no declared properties) must map to a +passthrough dict, not an empty pydantic model that silently strips every key +the LLM supplies (pydantic defaults to ``extra="ignore"``).""" + +from typing import Any, Dict + +from pyagentspec.adapters._utils import create_pydantic_model_from_properties +from pyagentspec.property import Property + + +def _model_for(json_schema: Dict[str, Any]) -> Any: + prop = Property(title="components", json_schema=json_schema) + return create_pydantic_model_from_properties("ToolArgs", [prop]) + + +def test_array_of_bare_objects_keeps_item_keys() -> None: + model = _model_for({"type": "array", "items": {"type": "object"}}) + + parsed = model(components=[{"id": "root", "component": "Card", "child": "title"}]) + + assert parsed.components == [{"id": "root", "component": "Card", "child": "title"}] + + +def test_bare_object_keeps_keys() -> None: + model = _model_for({"type": "object"}) + + parsed = model(components={"id": "root", "nested": {"a": 1}}) + + assert parsed.components == {"id": "root", "nested": {"a": 1}} + + +def test_object_with_declared_properties_still_builds_a_model() -> None: + model = _model_for( + { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + ) + + parsed = model(components={"name": "Alice"}) + + assert parsed.components.name == "Alice" diff --git a/tsagentspec/src/component-registry.ts b/tsagentspec/src/component-registry.ts index d31e0a97..2f087334 100644 --- a/tsagentspec/src/component-registry.ts +++ b/tsagentspec/src/component-registry.ts @@ -19,11 +19,19 @@ import { createAgentSpecializationParameters, } from "./agents/specialized-agent.js"; +import { LlmConfigSchema, createLlmConfig } from "./llms/llm-config.js"; import { OpenAiCompatibleConfigSchema, createOpenAiCompatibleConfig } from "./llms/openai-compatible-config.js"; import { OllamaConfigSchema, createOllamaConfig } from "./llms/ollama-config.js"; import { VllmConfigSchema, createVllmConfig } from "./llms/vllm-config.js"; import { OpenAiConfigSchema, createOpenAiConfig } from "./llms/openai-config.js"; import { OciGenAiConfigSchema, createOciGenAiConfig } from "./llms/oci-genai-config.js"; +import { GeminiConfigSchema, createGeminiConfig } from "./llms/gemini-config.js"; +import { + GeminiAIStudioAuthConfigSchema, + GeminiVertexAIAuthConfigSchema, + createGeminiAIStudioAuthConfig, + createGeminiVertexAIAuthConfig, +} from "./llms/gemini-auth-config.js"; import { OciClientConfigWithApiKeySchema, OciClientConfigWithInstancePrincipalSchema, @@ -140,6 +148,10 @@ export const BUILTIN_SCHEMA_MAP: Record = { SpecializedAgent: SpecializedAgentSchema, AgentSpecializationParameters: AgentSpecializationParametersSchema, + LlmConfig: LlmConfigSchema, + GeminiConfig: GeminiConfigSchema, + GeminiAIStudioAuthConfig: GeminiAIStudioAuthConfigSchema, + GeminiVertexAIAuthConfig: GeminiVertexAIAuthConfigSchema, OpenAiCompatibleConfig: OpenAiCompatibleConfigSchema, OllamaConfig: OllamaConfigSchema, VllmConfig: VllmConfigSchema, @@ -211,6 +223,10 @@ export const BUILTIN_FACTORY_MAP: Record = { SpecializedAgent: createSpecializedAgent, AgentSpecializationParameters: createAgentSpecializationParameters, + LlmConfig: createLlmConfig, + GeminiConfig: createGeminiConfig, + GeminiAIStudioAuthConfig: createGeminiAIStudioAuthConfig, + GeminiVertexAIAuthConfig: createGeminiVertexAIAuthConfig, OpenAiCompatibleConfig: createOpenAiCompatibleConfig, OllamaConfig: createOllamaConfig, VllmConfig: createVllmConfig, diff --git a/tsagentspec/src/component.ts b/tsagentspec/src/component.ts index c5fb92d8..7c847319 100644 --- a/tsagentspec/src/component.ts +++ b/tsagentspec/src/component.ts @@ -80,6 +80,10 @@ export type ComponentTypeName = | "BuiltinTool" | "MCPTool" | "MCPToolSpec" + | "LlmConfig" + | "GeminiConfig" + | "GeminiAIStudioAuthConfig" + | "GeminiVertexAIAuthConfig" | "OpenAiCompatibleConfig" | "OllamaConfig" | "VllmConfig" diff --git a/tsagentspec/src/index.ts b/tsagentspec/src/index.ts index 854ff538..284e4397 100644 --- a/tsagentspec/src/index.ts +++ b/tsagentspec/src/index.ts @@ -59,11 +59,29 @@ export { SENSITIVE_FIELDS, isSensitiveField } from "./sensitive-field.js"; // LLM configs export { + LlmConfigBaseSchema, LlmConfigUnion, + LlmConfigSchema, LlmGenerationConfigSchema, OpenAIAPIType, + createLlmConfig, type LlmConfig, + type LlmConfigBase, type LlmGenerationConfig, + RetryPolicySchema, + JitterType, + type RetryPolicy, + GeminiAuthConfigUnion, + GeminiAIStudioAuthConfigSchema, + GeminiVertexAIAuthConfigSchema, + createGeminiAIStudioAuthConfig, + createGeminiVertexAIAuthConfig, + type GeminiAuthConfig, + type GeminiAIStudioAuthConfig, + type GeminiVertexAIAuthConfig, + GeminiConfigSchema, + createGeminiConfig, + type GeminiConfig, OpenAiCompatibleConfigSchema, createOpenAiCompatibleConfig, type OpenAiCompatibleConfig, diff --git a/tsagentspec/src/llms/gemini-auth-config.ts b/tsagentspec/src/llms/gemini-auth-config.ts new file mode 100644 index 00000000..68415e73 --- /dev/null +++ b/tsagentspec/src/llms/gemini-auth-config.ts @@ -0,0 +1,61 @@ +/** + * Gemini auth config components. + */ +import { z } from "zod"; +import { ComponentBaseSchema } from "../component.js"; + +export const GeminiAIStudioAuthConfigSchema = ComponentBaseSchema.extend({ + componentType: z.literal("GeminiAIStudioAuthConfig"), + apiKey: z.string().optional(), +}); +export type GeminiAIStudioAuthConfig = z.infer< + typeof GeminiAIStudioAuthConfigSchema +>; + +export const GeminiVertexAIAuthConfigSchema = ComponentBaseSchema.extend({ + componentType: z.literal("GeminiVertexAIAuthConfig"), + projectId: z.string().optional(), + location: z.string().default("global"), + credentials: z.union([z.string(), z.record(z.unknown())]).optional(), +}); +export type GeminiVertexAIAuthConfig = z.infer< + typeof GeminiVertexAIAuthConfigSchema +>; + +export const GeminiAuthConfigUnion = z.discriminatedUnion("componentType", [ + GeminiAIStudioAuthConfigSchema, + GeminiVertexAIAuthConfigSchema, +]); +export type GeminiAuthConfig = z.infer; + +export function createGeminiAIStudioAuthConfig(opts: { + name: string; + apiKey?: string; + id?: string; + description?: string; + metadata?: Record; +}): GeminiAIStudioAuthConfig { + return Object.freeze( + GeminiAIStudioAuthConfigSchema.parse({ + ...opts, + componentType: "GeminiAIStudioAuthConfig", + }), + ); +} + +export function createGeminiVertexAIAuthConfig(opts: { + name: string; + projectId?: string; + location?: string; + credentials?: string | Record; + id?: string; + description?: string; + metadata?: Record; +}): GeminiVertexAIAuthConfig { + return Object.freeze( + GeminiVertexAIAuthConfigSchema.parse({ + ...opts, + componentType: "GeminiVertexAIAuthConfig", + }), + ); +} diff --git a/tsagentspec/src/llms/gemini-config.ts b/tsagentspec/src/llms/gemini-config.ts new file mode 100644 index 00000000..0e45550f --- /dev/null +++ b/tsagentspec/src/llms/gemini-config.ts @@ -0,0 +1,35 @@ +/** + * Gemini LLM config. + */ +import { z } from "zod"; +import { LlmConfigBaseSchema, LlmGenerationConfigSchema } from "./llm-config.js"; +import { + GeminiAuthConfigUnion, + type GeminiAuthConfig, +} from "./gemini-auth-config.js"; +import { RetryPolicySchema } from "./retry-policy.js"; + +// provider is fixed to "google", url/apiKey/apiProvider/apiType are not applicable to Gemini. +export const GeminiConfigSchema = LlmConfigBaseSchema + .omit({ url: true, apiKey: true, apiProvider: true, provider: true, apiType: true }) + .extend({ + componentType: z.literal("GeminiConfig"), + auth: GeminiAuthConfigUnion, + }); + +export type GeminiConfig = z.infer; + +export function createGeminiConfig(opts: { + name: string; + modelId: string; + auth: GeminiAuthConfig; + id?: string; + description?: string; + metadata?: Record; + defaultGenerationParameters?: z.infer; + retryPolicy?: z.infer; +}): GeminiConfig { + return Object.freeze( + GeminiConfigSchema.parse({ ...opts, componentType: "GeminiConfig" }), + ); +} diff --git a/tsagentspec/src/llms/index.ts b/tsagentspec/src/llms/index.ts index 1d9a546b..041c0b12 100644 --- a/tsagentspec/src/llms/index.ts +++ b/tsagentspec/src/llms/index.ts @@ -2,29 +2,60 @@ * LLM config types barrel export. */ import { z } from "zod"; +import { LlmConfigSchema } from "./llm-config.js"; import { OpenAiCompatibleConfigSchema } from "./openai-compatible-config.js"; import { OllamaConfigSchema } from "./ollama-config.js"; import { VllmConfigSchema } from "./vllm-config.js"; import { OpenAiConfigSchema } from "./openai-config.js"; import { OciGenAiConfigSchema } from "./oci-genai-config.js"; +import { GeminiConfigSchema } from "./gemini-config.js"; /** Discriminated union of all LLM config types */ export const LlmConfigUnion = z.discriminatedUnion("componentType", [ + LlmConfigSchema, OpenAiCompatibleConfigSchema, OllamaConfigSchema, VllmConfigSchema, OpenAiConfigSchema, OciGenAiConfigSchema, + GeminiConfigSchema, ]); export type LlmConfig = z.infer; export { + LlmConfigBaseSchema, + LlmConfigSchema, LlmGenerationConfigSchema, OpenAIAPIType, + createLlmConfig, + type LlmConfigBase, type LlmGenerationConfig, } from "./llm-config.js"; +export { + RetryPolicySchema, + JitterType, + type RetryPolicy, +} from "./retry-policy.js"; + +export { + GeminiAuthConfigUnion, + GeminiAIStudioAuthConfigSchema, + GeminiVertexAIAuthConfigSchema, + createGeminiAIStudioAuthConfig, + createGeminiVertexAIAuthConfig, + type GeminiAuthConfig, + type GeminiAIStudioAuthConfig, + type GeminiVertexAIAuthConfig, +} from "./gemini-auth-config.js"; + +export { + GeminiConfigSchema, + createGeminiConfig, + type GeminiConfig, +} from "./gemini-config.js"; + export { OpenAiCompatibleConfigSchema, createOpenAiCompatibleConfig, diff --git a/tsagentspec/src/llms/llm-config.ts b/tsagentspec/src/llms/llm-config.ts index 5afb1e32..8a90d256 100644 --- a/tsagentspec/src/llms/llm-config.ts +++ b/tsagentspec/src/llms/llm-config.ts @@ -2,6 +2,8 @@ * LLM generation config and shared enums. */ import { z } from "zod"; +import { ComponentBaseSchema } from "../component.js"; +import { RetryPolicySchema } from "./retry-policy.js"; /** LlmGenerationConfig - NOT a Component, just a config object */ export const LlmGenerationConfigSchema = z @@ -21,3 +23,58 @@ export const OpenAIAPIType = { } as const; export type OpenAIAPIType = (typeof OpenAIAPIType)[keyof typeof OpenAIAPIType]; + +/** + * Shared base for all LLM config components. + * componentType is inherited as z.string() from ComponentBaseSchema; + * each concrete schema narrows it to its own literal. + */ +export const LlmConfigBaseSchema = ComponentBaseSchema.extend({ + modelId: z.string(), + provider: z.string().optional(), + apiProvider: z.string().optional(), + apiType: z.string().optional(), + url: z.string().optional(), + apiKey: z.string().optional(), + defaultGenerationParameters: LlmGenerationConfigSchema.optional(), + retryPolicy: RetryPolicySchema.optional(), +}); + +export type LlmConfigBase = z.infer; + +/** Shared fields for OpenAI-compatible runtimes that add TLS and require a URL */ +export const LocalInferenceFields = { + url: z.string(), + apiType: z + .enum([OpenAIAPIType.CHAT_COMPLETIONS, OpenAIAPIType.RESPONSES]) + .default(OpenAIAPIType.CHAT_COMPLETIONS), + keyFile: z.string().optional(), + certFile: z.string().optional(), + caFile: z.string().optional(), +} as const; + +/** Bare LlmConfig component - generic LLM configuration */ +export const LlmConfigSchema = LlmConfigBaseSchema.extend({ + componentType: z.literal("LlmConfig"), +}); + +export type LlmConfig = z.infer; + +export function createLlmConfig(opts: { + name: string; + modelId: string; + id?: string; + description?: string; + metadata?: Record; + provider?: string; + apiProvider?: string; + apiType?: string; + url?: string; + apiKey?: string; + defaultGenerationParameters?: z.infer; + retryPolicy?: z.infer; +}): LlmConfig { + return Object.freeze( + LlmConfigSchema.parse({ ...opts, componentType: "LlmConfig" }), + ); +} diff --git a/tsagentspec/src/llms/oci-genai-config.ts b/tsagentspec/src/llms/oci-genai-config.ts index 817b4e92..c8d3711e 100644 --- a/tsagentspec/src/llms/oci-genai-config.ts +++ b/tsagentspec/src/llms/oci-genai-config.ts @@ -2,9 +2,9 @@ * OCI GenAI LLM config. */ import { z } from "zod"; -import { ComponentBaseSchema } from "../component.js"; -import { LlmGenerationConfigSchema } from "./llm-config.js"; +import { LlmConfigBaseSchema, LlmGenerationConfigSchema } from "./llm-config.js"; import { OciClientConfigUnion, type OciClientConfig } from "./oci-client-config.js"; +import { RetryPolicySchema } from "./retry-policy.js"; /** Serving mode enum */ export const ServingMode = { @@ -20,6 +20,7 @@ export const ModelProvider = { GROK: "GROK", COHERE: "COHERE", OTHER: "OTHER", + XAI: "XAI", } as const; export type ModelProvider = (typeof ModelProvider)[keyof typeof ModelProvider]; @@ -33,32 +34,35 @@ export const OciAPIType = { export type OciAPIType = (typeof OciAPIType)[keyof typeof OciAPIType]; -export const OciGenAiConfigSchema = ComponentBaseSchema.extend({ - componentType: z.literal("OciGenAiConfig"), - modelId: z.string(), - compartmentId: z.string(), - servingMode: z - .enum([ServingMode.ON_DEMAND, ServingMode.DEDICATED]) - .default(ServingMode.ON_DEMAND), - provider: z - .enum([ - ModelProvider.META, - ModelProvider.GROK, - ModelProvider.COHERE, - ModelProvider.OTHER, - ]) - .optional(), - clientConfig: OciClientConfigUnion, - apiType: z - .enum([ - OciAPIType.OPENAI_CHAT_COMPLETIONS, - OciAPIType.OPENAI_RESPONSES, - OciAPIType.OCI, - ]) - .default(OciAPIType.OCI), - conversationStoreId: z.string().optional(), - defaultGenerationParameters: LlmGenerationConfigSchema.optional(), -}); +// apiProvider is fixed to "oci", url and apiKey are not applicable to OCI GenAI. +// provider and apiType are overridden with OCI-specific enums. +export const OciGenAiConfigSchema = LlmConfigBaseSchema + .omit({ url: true, apiKey: true, apiProvider: true, apiType: true }) + .extend({ + componentType: z.literal("OciGenAiConfig"), + compartmentId: z.string(), + servingMode: z + .enum([ServingMode.ON_DEMAND, ServingMode.DEDICATED]) + .default(ServingMode.ON_DEMAND), + provider: z + .enum([ + ModelProvider.META, + ModelProvider.GROK, + ModelProvider.COHERE, + ModelProvider.OTHER, + ModelProvider.XAI, + ]) + .optional(), + clientConfig: OciClientConfigUnion, + apiType: z + .enum([ + OciAPIType.OPENAI_CHAT_COMPLETIONS, + OciAPIType.OPENAI_RESPONSES, + OciAPIType.OCI, + ]) + .default(OciAPIType.OCI), + conversationStoreId: z.string().optional(), + }); export type OciGenAiConfig = z.infer; @@ -75,11 +79,9 @@ export function createOciGenAiConfig(opts: { apiType?: OciAPIType; conversationStoreId?: string; defaultGenerationParameters?: z.infer; + retryPolicy?: z.infer; }): OciGenAiConfig { return Object.freeze( - OciGenAiConfigSchema.parse({ - ...opts, - componentType: "OciGenAiConfig" as const, - }), + OciGenAiConfigSchema.parse({ ...opts, componentType: "OciGenAiConfig" }), ); } diff --git a/tsagentspec/src/llms/ollama-config.ts b/tsagentspec/src/llms/ollama-config.ts index a6ce241f..b86bdb75 100644 --- a/tsagentspec/src/llms/ollama-config.ts +++ b/tsagentspec/src/llms/ollama-config.ts @@ -2,19 +2,16 @@ * Ollama LLM config. */ import { z } from "zod"; -import { ComponentBaseSchema } from "../component.js"; -import { LlmGenerationConfigSchema, OpenAIAPIType } from "./llm-config.js"; +import { LlmConfigBaseSchema, LlmGenerationConfigSchema, LocalInferenceFields, OpenAIAPIType } from "./llm-config.js"; +import { RetryPolicySchema } from "./retry-policy.js"; -export const OllamaConfigSchema = ComponentBaseSchema.extend({ - componentType: z.literal("OllamaConfig"), - url: z.string(), - modelId: z.string(), - apiType: z - .enum([OpenAIAPIType.CHAT_COMPLETIONS, OpenAIAPIType.RESPONSES]) - .default(OpenAIAPIType.CHAT_COMPLETIONS), - defaultGenerationParameters: LlmGenerationConfigSchema.optional(), - apiKey: z.string().optional(), -}); +// apiProvider is fixed to "ollama" and excluded from serialization, so omitted here. +export const OllamaConfigSchema = LlmConfigBaseSchema + .omit({ apiProvider: true }) + .extend({ + componentType: z.literal("OllamaConfig"), + ...LocalInferenceFields, + }); export type OllamaConfig = z.infer; @@ -28,10 +25,13 @@ export function createOllamaConfig(opts: { apiType?: OpenAIAPIType; defaultGenerationParameters?: z.infer; apiKey?: string; + provider?: string; + keyFile?: string; + certFile?: string; + caFile?: string; + retryPolicy?: z.infer; }): OllamaConfig { - const parsed = OllamaConfigSchema.parse({ - ...opts, - componentType: "OllamaConfig" as const, - }); - return Object.freeze(parsed); + return Object.freeze( + OllamaConfigSchema.parse({ ...opts, componentType: "OllamaConfig" }), + ); } diff --git a/tsagentspec/src/llms/openai-compatible-config.ts b/tsagentspec/src/llms/openai-compatible-config.ts index 3098cd5d..1d9f6cf8 100644 --- a/tsagentspec/src/llms/openai-compatible-config.ts +++ b/tsagentspec/src/llms/openai-compatible-config.ts @@ -2,23 +2,15 @@ * OpenAI-compatible LLM config. */ import { z } from "zod"; -import { ComponentBaseSchema } from "../component.js"; -import { LlmGenerationConfigSchema, OpenAIAPIType } from "./llm-config.js"; +import { LlmConfigBaseSchema, LlmGenerationConfigSchema, LocalInferenceFields, OpenAIAPIType } from "./llm-config.js"; +import { RetryPolicySchema } from "./retry-policy.js"; -export const OpenAiCompatibleConfigSchema = ComponentBaseSchema.extend({ +export const OpenAiCompatibleConfigSchema = LlmConfigBaseSchema.extend({ componentType: z.literal("OpenAiCompatibleConfig"), - url: z.string(), - modelId: z.string(), - apiType: z - .enum([OpenAIAPIType.CHAT_COMPLETIONS, OpenAIAPIType.RESPONSES]) - .default(OpenAIAPIType.CHAT_COMPLETIONS), - defaultGenerationParameters: LlmGenerationConfigSchema.optional(), - apiKey: z.string().optional(), + ...LocalInferenceFields, }); -export type OpenAiCompatibleConfig = z.infer< - typeof OpenAiCompatibleConfigSchema ->; +export type OpenAiCompatibleConfig = z.infer; export function createOpenAiCompatibleConfig(opts: { name: string; @@ -30,11 +22,14 @@ export function createOpenAiCompatibleConfig(opts: { apiType?: OpenAIAPIType; defaultGenerationParameters?: z.infer; apiKey?: string; + apiProvider?: string; + provider?: string; + keyFile?: string; + certFile?: string; + caFile?: string; + retryPolicy?: z.infer; }): OpenAiCompatibleConfig { - const raw = { - ...opts, - componentType: "OpenAiCompatibleConfig" as const, - }; - const parsed = OpenAiCompatibleConfigSchema.parse(raw); - return Object.freeze(parsed); + return Object.freeze( + OpenAiCompatibleConfigSchema.parse({ ...opts, componentType: "OpenAiCompatibleConfig" }), + ); } diff --git a/tsagentspec/src/llms/openai-config.ts b/tsagentspec/src/llms/openai-config.ts index 4355fb38..ebc21ef8 100644 --- a/tsagentspec/src/llms/openai-config.ts +++ b/tsagentspec/src/llms/openai-config.ts @@ -2,18 +2,19 @@ * OpenAI config (no url field). */ import { z } from "zod"; -import { ComponentBaseSchema } from "../component.js"; -import { LlmGenerationConfigSchema, OpenAIAPIType } from "./llm-config.js"; +import { LlmConfigBaseSchema, LlmGenerationConfigSchema, OpenAIAPIType } from "./llm-config.js"; +import { RetryPolicySchema } from "./retry-policy.js"; -export const OpenAiConfigSchema = ComponentBaseSchema.extend({ - componentType: z.literal("OpenAiConfig"), - modelId: z.string(), - apiType: z - .enum([OpenAIAPIType.CHAT_COMPLETIONS, OpenAIAPIType.RESPONSES]) - .default(OpenAIAPIType.CHAT_COMPLETIONS), - defaultGenerationParameters: LlmGenerationConfigSchema.optional(), - apiKey: z.string().optional(), -}); +// provider and apiProvider are fixed to "openai" and excluded from serialization, +// so they are omitted from the schema. url is not applicable to OpenAI's hosted API. +export const OpenAiConfigSchema = LlmConfigBaseSchema + .omit({ url: true, provider: true, apiProvider: true }) + .extend({ + componentType: z.literal("OpenAiConfig"), + apiType: z + .enum([OpenAIAPIType.CHAT_COMPLETIONS, OpenAIAPIType.RESPONSES]) + .default(OpenAIAPIType.CHAT_COMPLETIONS), + }); export type OpenAiConfig = z.infer; @@ -26,10 +27,9 @@ export function createOpenAiConfig(opts: { apiType?: OpenAIAPIType; defaultGenerationParameters?: z.infer; apiKey?: string; + retryPolicy?: z.infer; }): OpenAiConfig { - const parsed = OpenAiConfigSchema.parse({ - ...opts, - componentType: "OpenAiConfig" as const, - }); - return Object.freeze(parsed); + return Object.freeze( + OpenAiConfigSchema.parse({ ...opts, componentType: "OpenAiConfig" }), + ); } diff --git a/tsagentspec/src/llms/retry-policy.ts b/tsagentspec/src/llms/retry-policy.ts new file mode 100644 index 00000000..89a0f384 --- /dev/null +++ b/tsagentspec/src/llms/retry-policy.ts @@ -0,0 +1,33 @@ +/** + * RetryPolicy config object (NOT a Component - no id/name). + */ +import { z } from "zod"; + +export const JitterType = { + EQUAL: "equal", + FULL: "full", + FULL_AND_EQUAL_FOR_THROTTLE: "full_and_equal_for_throttle", + DECORRELATED: "decorrelated", +} as const; +export type JitterType = (typeof JitterType)[keyof typeof JitterType]; + +const jitterValues = Object.values(JitterType) as [JitterType, ...JitterType[]]; + +export const RetryPolicySchema = z.object({ + maxAttempts: z.number().int().min(0).default(2), + requestTimeout: z.number().positive().optional(), + initialRetryDelay: z.number().min(0).default(1.0), + maxRetryDelay: z.number().min(0).default(8.0), + backoffFactor: z.number().positive().default(2.0), + jitter: z + .enum(jitterValues) + .nullable() + .optional() + .default(JitterType.FULL_AND_EQUAL_FOR_THROTTLE), + serviceErrorRetryOnAny5xx: z.boolean().default(true), + recoverableStatuses: z + .record(z.array(z.string())) + .default({ "409": [], "429": [] }), +}); + +export type RetryPolicy = z.infer; diff --git a/tsagentspec/src/llms/vllm-config.ts b/tsagentspec/src/llms/vllm-config.ts index a55b2af2..83e85a5e 100644 --- a/tsagentspec/src/llms/vllm-config.ts +++ b/tsagentspec/src/llms/vllm-config.ts @@ -2,19 +2,16 @@ * vLLM config. */ import { z } from "zod"; -import { ComponentBaseSchema } from "../component.js"; -import { LlmGenerationConfigSchema, OpenAIAPIType } from "./llm-config.js"; +import { LlmConfigBaseSchema, LlmGenerationConfigSchema, LocalInferenceFields, OpenAIAPIType } from "./llm-config.js"; +import { RetryPolicySchema } from "./retry-policy.js"; -export const VllmConfigSchema = ComponentBaseSchema.extend({ - componentType: z.literal("VllmConfig"), - url: z.string(), - modelId: z.string(), - apiType: z - .enum([OpenAIAPIType.CHAT_COMPLETIONS, OpenAIAPIType.RESPONSES]) - .default(OpenAIAPIType.CHAT_COMPLETIONS), - defaultGenerationParameters: LlmGenerationConfigSchema.optional(), - apiKey: z.string().optional(), -}); +// apiProvider is fixed to "vllm" and excluded from serialization, so omitted here. +export const VllmConfigSchema = LlmConfigBaseSchema + .omit({ apiProvider: true }) + .extend({ + componentType: z.literal("VllmConfig"), + ...LocalInferenceFields, + }); export type VllmConfig = z.infer; @@ -28,10 +25,13 @@ export function createVllmConfig(opts: { apiType?: OpenAIAPIType; defaultGenerationParameters?: z.infer; apiKey?: string; + provider?: string; + keyFile?: string; + certFile?: string; + caFile?: string; + retryPolicy?: z.infer; }): VllmConfig { - const parsed = VllmConfigSchema.parse({ - ...opts, - componentType: "VllmConfig" as const, - }); - return Object.freeze(parsed); + return Object.freeze( + VllmConfigSchema.parse({ ...opts, componentType: "VllmConfig" }), + ); } diff --git a/tsagentspec/src/sensitive-field.ts b/tsagentspec/src/sensitive-field.ts index a7b2f0a3..9da8fb85 100644 --- a/tsagentspec/src/sensitive-field.ts +++ b/tsagentspec/src/sensitive-field.ts @@ -7,9 +7,12 @@ export const SENSITIVE_FIELD_MARKER = "SENSITIVE_FIELD_MARKER" as const; /** Maps componentType -> set of field names that are sensitive */ export const SENSITIVE_FIELDS = { - OpenAiCompatibleConfig: new Set(["apiKey"]), - OllamaConfig: new Set(["apiKey"]), - VllmConfig: new Set(["apiKey"]), + LlmConfig: new Set(["apiKey"]), + GeminiAIStudioAuthConfig: new Set(["apiKey"]), + GeminiVertexAIAuthConfig: new Set(["credentials"]), + OpenAiCompatibleConfig: new Set(["apiKey", "keyFile", "certFile", "caFile"]), + OllamaConfig: new Set(["apiKey", "keyFile", "certFile", "caFile"]), + VllmConfig: new Set(["apiKey", "keyFile", "certFile", "caFile"]), OpenAiConfig: new Set(["apiKey"]), RemoteTool: new Set(["sensitiveHeaders"]), ApiNode: new Set(["sensitiveHeaders"]), diff --git a/tsagentspec/src/serialization/builtin-deserialization-plugin.ts b/tsagentspec/src/serialization/builtin-deserialization-plugin.ts index e3041d24..4bf4cd31 100644 --- a/tsagentspec/src/serialization/builtin-deserialization-plugin.ts +++ b/tsagentspec/src/serialization/builtin-deserialization-plugin.ts @@ -29,11 +29,7 @@ import { snakeToCamel } from "./serialization-context.js"; */ const PROPERTY_ARRAY_FIELDS = new Set(["inputs", "outputs"]); -/** - * Fields (camelCase) whose object values are model objects with snake_case keys - * that need conversion. All other object values are user data with preserved keys. - */ -const MODEL_OBJECT_FIELDS = new Set(["defaultGenerationParameters"]); +const MODEL_OBJECT_FIELDS = new Set(["defaultGenerationParameters", "retryPolicy"]); /** Deserialize a jsonSchema dict into a Property */ function deserializeProperty(value: unknown): Property { @@ -104,7 +100,6 @@ export class BuiltinsComponentDeserializationPlugin continue; } - // Handle model object fields (keys need snake_case -> camelCase) if ( MODEL_OBJECT_FIELDS.has(camelKey) && typeof value === "object" && diff --git a/tsagentspec/src/serialization/builtin-serialization-plugin.ts b/tsagentspec/src/serialization/builtin-serialization-plugin.ts index 93a47897..295395aa 100644 --- a/tsagentspec/src/serialization/builtin-serialization-plugin.ts +++ b/tsagentspec/src/serialization/builtin-serialization-plugin.ts @@ -20,6 +20,7 @@ const EXCLUDED_FIELDS = new Set(["componentType"]); */ const MODEL_OBJECT_FIELDS: Record = { defaultGenerationParameters: true, // LlmGenerationConfig - exclude nulls + retryPolicy: true, // RetryPolicy - exclude nulls }; function hasSerializedSensitiveValue(value: unknown): boolean { diff --git a/tsagentspec/src/serialization/serialization-context.ts b/tsagentspec/src/serialization/serialization-context.ts index b8aed4ce..4c3ae2db 100644 --- a/tsagentspec/src/serialization/serialization-context.ts +++ b/tsagentspec/src/serialization/serialization-context.ts @@ -33,6 +33,7 @@ export function camelToSnake(str: string): string { return str .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/([a-z])([0-9])/g, "$1_$2") .toLowerCase(); } diff --git a/tsagentspec/src/serialization/version-gates.ts b/tsagentspec/src/serialization/version-gates.ts index 3798c48c..059e2ab1 100644 --- a/tsagentspec/src/serialization/version-gates.ts +++ b/tsagentspec/src/serialization/version-gates.ts @@ -48,14 +48,45 @@ export const VERSION_GATED_FIELDS = { ManagerWorkers: { _self: AgentSpecVersion.V25_4_2, }, + LlmConfig: { + _self: AgentSpecVersion.V26_2_0, + }, + GeminiConfig: { + _self: AgentSpecVersion.V26_2_0, + }, OpenAiConfig: { apiType: AgentSpecVersion.V25_4_2, + apiKey: AgentSpecVersion.V25_4_2, + retryPolicy: AgentSpecVersion.V26_2_0, }, OpenAiCompatibleConfig: { apiType: AgentSpecVersion.V25_4_2, + apiKey: AgentSpecVersion.V25_4_2, + apiProvider: AgentSpecVersion.V26_2_0, + keyFile: AgentSpecVersion.V26_2_0, + certFile: AgentSpecVersion.V26_2_0, + caFile: AgentSpecVersion.V26_2_0, + provider: AgentSpecVersion.V26_2_0, + retryPolicy: AgentSpecVersion.V26_2_0, + }, + OllamaConfig: { + keyFile: AgentSpecVersion.V26_2_0, + certFile: AgentSpecVersion.V26_2_0, + caFile: AgentSpecVersion.V26_2_0, + provider: AgentSpecVersion.V26_2_0, + retryPolicy: AgentSpecVersion.V26_2_0, + }, + VllmConfig: { + keyFile: AgentSpecVersion.V26_2_0, + certFile: AgentSpecVersion.V26_2_0, + caFile: AgentSpecVersion.V26_2_0, + provider: AgentSpecVersion.V26_2_0, + retryPolicy: AgentSpecVersion.V26_2_0, }, OciGenAiConfig: { apiType: AgentSpecVersion.V25_4_2, + conversationStoreId: AgentSpecVersion.V25_4_2, + retryPolicy: AgentSpecVersion.V26_2_0, }, ApiNode: { sensitiveHeaders: AgentSpecVersion.V25_4_2, diff --git a/tsagentspec/tests/llms/gemini-config.test.ts b/tsagentspec/tests/llms/gemini-config.test.ts new file mode 100644 index 00000000..8f5b6458 --- /dev/null +++ b/tsagentspec/tests/llms/gemini-config.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; +import { + createGeminiConfig, + createGeminiAIStudioAuthConfig, + createGeminiVertexAIAuthConfig, + AgentSpecSerializer, + AgentSpecDeserializer, + AgentSpecVersion, +} from "../../src/index.js"; + +const serializer = new AgentSpecSerializer(); +const deserializer = new AgentSpecDeserializer(); + +function makeAIStudioAuth() { + return createGeminiAIStudioAuthConfig({ name: "auth", apiKey: "gk-test" }); +} + +function makeVertexAuth() { + return createGeminiVertexAIAuthConfig({ + name: "vertex-auth", + projectId: "my-project", + location: "us-central1", + }); +} + +describe("GeminiAIStudioAuthConfig", () => { + it("should create with required fields only", () => { + const auth = createGeminiAIStudioAuthConfig({ name: "auth" }); + expect(auth.componentType).toBe("GeminiAIStudioAuthConfig"); + expect(auth.apiKey).toBeUndefined(); + expect(auth.id).toBeDefined(); + expect(Object.isFrozen(auth)).toBe(true); + }); + + it("should accept optional apiKey", () => { + const auth = createGeminiAIStudioAuthConfig({ name: "auth", apiKey: "gk-abc" }); + expect(auth.apiKey).toBe("gk-abc"); + }); +}); + +describe("GeminiVertexAIAuthConfig", () => { + it("should create with required fields only", () => { + const auth = createGeminiVertexAIAuthConfig({ name: "va" }); + expect(auth.componentType).toBe("GeminiVertexAIAuthConfig"); + expect(auth.location).toBe("global"); + expect(auth.projectId).toBeUndefined(); + expect(Object.isFrozen(auth)).toBe(true); + }); + + it("should accept projectId, location, and credentials", () => { + const auth = createGeminiVertexAIAuthConfig({ + name: "va", + projectId: "proj-1", + location: "europe-west1", + credentials: { key: "value" }, + }); + expect(auth.projectId).toBe("proj-1"); + expect(auth.location).toBe("europe-west1"); + expect(auth.credentials).toEqual({ key: "value" }); + }); +}); + +describe("GeminiConfig", () => { + it("should create with AIStudio auth", () => { + const config = createGeminiConfig({ + name: "gemini", + modelId: "gemini-1.5-pro", + auth: makeAIStudioAuth(), + }); + expect(config.componentType).toBe("GeminiConfig"); + expect(config.modelId).toBe("gemini-1.5-pro"); + expect(config.auth.componentType).toBe("GeminiAIStudioAuthConfig"); + expect(Object.isFrozen(config)).toBe(true); + }); + + it("should create with VertexAI auth", () => { + const config = createGeminiConfig({ + name: "gemini", + modelId: "gemini-1.5-flash", + auth: makeVertexAuth(), + }); + expect(config.auth.componentType).toBe("GeminiVertexAIAuthConfig"); + }); + + it("should serialise to snake_case YAML", () => { + const config = createGeminiConfig({ + id: "test-id", + name: "gemini", + modelId: "gemini-1.5-pro", + auth: createGeminiAIStudioAuthConfig({ id: "auth-id", name: "auth" }), + }); + const yaml = serializer.toYaml(config); + expect(yaml).toContain("component_type: GeminiConfig"); + expect(yaml).toContain("model_id: gemini-1.5-pro"); + expect(yaml).toContain("component_type: GeminiAIStudioAuthConfig"); + }); + + it("should exclude apiKey from serialised auth", () => { + const config = createGeminiConfig({ + id: "test-id", + name: "gemini", + modelId: "gemini-1.5-pro", + auth: createGeminiAIStudioAuthConfig({ id: "auth-id", name: "auth", apiKey: "gk-secret" }), + }); + const yaml = serializer.toYaml(config); + expect(yaml).not.toContain("gk-secret"); + }); + + it("should round-trip without sensitive fields", () => { + const config = createGeminiConfig({ + id: "test-id", + name: "gemini", + modelId: "gemini-1.5-pro", + auth: createGeminiAIStudioAuthConfig({ id: "auth-id", name: "auth" }), + }); + const yaml = serializer.toYaml(config); + const restored = deserializer.fromYaml(yaml); + expect(restored).toEqual(config); + }); + + it("should throw when serialising at version before 26.2.0", () => { + const config = createGeminiConfig({ + name: "gemini", + modelId: "gemini-1.5-pro", + auth: makeAIStudioAuth(), + }); + expect(() => + serializer.toYaml(config, { agentspecVersion: AgentSpecVersion.V25_4_2 }), + ).toThrow(/26\.2\.0/); + }); +}); diff --git a/tsagentspec/tests/llms/llm-config-base.test.ts b/tsagentspec/tests/llms/llm-config-base.test.ts new file mode 100644 index 00000000..3b82eedf --- /dev/null +++ b/tsagentspec/tests/llms/llm-config-base.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { + createLlmConfig, + AgentSpecSerializer, + AgentSpecDeserializer, + AgentSpecVersion, +} from "../../src/index.js"; + +const serializer = new AgentSpecSerializer(); +const deserializer = new AgentSpecDeserializer(); + +describe("LlmConfig (bare)", () => { + it("should create with only required fields", () => { + const config = createLlmConfig({ name: "generic", modelId: "gpt-4o" }); + expect(config.componentType).toBe("LlmConfig"); + expect(config.modelId).toBe("gpt-4o"); + expect(config.provider).toBeUndefined(); + expect(config.apiProvider).toBeUndefined(); + expect(config.apiType).toBeUndefined(); + expect(config.url).toBeUndefined(); + expect(config.apiKey).toBeUndefined(); + }); + + it("should accept all optional fields", () => { + const config = createLlmConfig({ + name: "generic", + modelId: "gpt-4o", + provider: "openai", + apiProvider: "openai", + apiType: "chat_completions", + url: "https://api.openai.com/v1", + apiKey: "sk-test", + }); + expect(config.provider).toBe("openai"); + expect(config.apiProvider).toBe("openai"); + expect(config.apiType).toBe("chat_completions"); + expect(config.url).toBe("https://api.openai.com/v1"); + expect(config.apiKey).toBe("sk-test"); + }); + + it("should auto-generate an id and be frozen", () => { + const config = createLlmConfig({ name: "generic", modelId: "m" }); + expect(config.id).toBeDefined(); + expect(Object.isFrozen(config)).toBe(true); + }); + + it("should serialise to snake_case YAML with expected fields", () => { + const config = createLlmConfig({ + id: "test-id", + name: "generic", + modelId: "gpt-4o", + provider: "openai", + apiProvider: "openai", + apiType: "chat_completions", + }); + const yaml = serializer.toYaml(config); + expect(yaml).toContain("component_type: LlmConfig"); + expect(yaml).toContain("model_id: gpt-4o"); + expect(yaml).toContain("provider: openai"); + expect(yaml).toContain("api_provider: openai"); + expect(yaml).toContain("api_type: chat_completions"); + }); + + it("should exclude apiKey from serialised output (sensitive field)", () => { + const config = createLlmConfig({ + id: "test-id", + name: "generic", + modelId: "gpt-4o", + apiKey: "sk-secret", + }); + const yaml = serializer.toYaml(config); + expect(yaml).not.toContain("sk-secret"); + }); + + it("should round-trip without apiKey", () => { + const config = createLlmConfig({ + id: "test-id", + name: "generic", + modelId: "gpt-4o", + provider: "openai", + apiProvider: "openai", + apiType: "chat_completions", + url: "https://api.openai.com/v1", + }); + const yaml = serializer.toYaml(config); + const restored = deserializer.fromYaml(yaml); + expect(restored).toEqual(config); + }); + + it("should throw when serialising at version before 26.2.0", () => { + const config = createLlmConfig({ name: "generic", modelId: "m" }); + expect(() => + serializer.toYaml(config, { agentspecVersion: AgentSpecVersion.V25_4_2 }), + ).toThrow(/26\.2\.0/); + }); +}); diff --git a/tsagentspec/tests/llms/openai-compatible-config.test.ts b/tsagentspec/tests/llms/openai-compatible-config.test.ts index 4b15bb74..e1849097 100644 --- a/tsagentspec/tests/llms/openai-compatible-config.test.ts +++ b/tsagentspec/tests/llms/openai-compatible-config.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { createOpenAiCompatibleConfig, OpenAIAPIType, + AgentSpecSerializer, + AgentSpecDeserializer, } from "../../src/index.js"; describe("OpenAiCompatibleConfig", () => { @@ -101,4 +103,43 @@ describe("OpenAiCompatibleConfig", () => { }); expect(config.metadata).toEqual({}); }); + + it("should accept TLS fields", () => { + const config = createOpenAiCompatibleConfig({ + name: "test", + url: "https://localhost", + modelId: "model1", + keyFile: "/path/to/client.key", + certFile: "/path/to/client.crt", + caFile: "/path/to/ca.crt", + }); + expect(config.keyFile).toBe("/path/to/client.key"); + expect(config.certFile).toBe("/path/to/client.crt"); + expect(config.caFile).toBe("/path/to/ca.crt"); + }); + + it("should accept provider field", () => { + const config = createOpenAiCompatibleConfig({ + name: "test", + url: "http://localhost", + modelId: "model1", + provider: "custom-provider", + }); + expect(config.provider).toBe("custom-provider"); + }); + + it("should round-trip with provider field", () => { + const serializer = new AgentSpecSerializer(); + const deserializer = new AgentSpecDeserializer(); + const config = createOpenAiCompatibleConfig({ + id: "test-id", + name: "test", + url: "https://localhost", + modelId: "model1", + provider: "my-provider", + }); + const yaml = serializer.toYaml(config); + const restored = deserializer.fromYaml(yaml); + expect(restored).toEqual(config); + }); }); diff --git a/tsagentspec/tests/llms/retry-policy.test.ts b/tsagentspec/tests/llms/retry-policy.test.ts new file mode 100644 index 00000000..6007f127 --- /dev/null +++ b/tsagentspec/tests/llms/retry-policy.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { + RetryPolicySchema, + JitterType, + AgentSpecSerializer, + AgentSpecDeserializer, + createOpenAiCompatibleConfig, +} from "../../src/index.js"; + +describe("RetryPolicy", () => { + it("should parse with all defaults", () => { + const policy = RetryPolicySchema.parse({}); + expect(policy.maxAttempts).toBe(2); + expect(policy.initialRetryDelay).toBe(1.0); + expect(policy.maxRetryDelay).toBe(8.0); + expect(policy.backoffFactor).toBe(2.0); + expect(policy.jitter).toBe(JitterType.FULL_AND_EQUAL_FOR_THROTTLE); + expect(policy.serviceErrorRetryOnAny5xx).toBe(true); + expect(policy.recoverableStatuses).toEqual({ "409": [], "429": [] }); + }); + + it("should accept custom values", () => { + const policy = RetryPolicySchema.parse({ + maxAttempts: 5, + requestTimeout: 30, + initialRetryDelay: 0.5, + maxRetryDelay: 60, + backoffFactor: 1.5, + jitter: JitterType.EQUAL, + serviceErrorRetryOnAny5xx: false, + recoverableStatuses: { "503": ["Unavailable"] }, + }); + expect(policy.maxAttempts).toBe(5); + expect(policy.requestTimeout).toBe(30); + expect(policy.jitter).toBe(JitterType.EQUAL); + expect(policy.serviceErrorRetryOnAny5xx).toBe(false); + expect(policy.recoverableStatuses).toEqual({ "503": ["Unavailable"] }); + }); + + it("should allow jitter to be null", () => { + const policy = RetryPolicySchema.parse({ jitter: null }); + expect(policy.jitter).toBeNull(); + }); + + it("should reject negative maxAttempts", () => { + expect(() => RetryPolicySchema.parse({ maxAttempts: -1 })).toThrow(); + }); + + it("should reject non-positive backoffFactor", () => { + expect(() => RetryPolicySchema.parse({ backoffFactor: 0 })).toThrow(); + }); + + it("should round-trip through serialization with non-default values", () => { + const serializer = new AgentSpecSerializer(); + const deserializer = new AgentSpecDeserializer(); + const config = createOpenAiCompatibleConfig({ + id: "test-id", + name: "llm", + url: "http://localhost", + modelId: "model", + retryPolicy: { + maxAttempts: 5, + serviceErrorRetryOnAny5xx: false, + jitter: JitterType.EQUAL, + recoverableStatuses: { "503": ["ServiceUnavailable"] }, + }, + }); + const yaml = serializer.toYaml(config); + expect(yaml).toContain("service_error_retry_on_any_5xx: false"); + expect(yaml).toContain("max_attempts: 5"); + const restored = deserializer.fromYaml(yaml) as typeof config; + expect(restored.retryPolicy?.maxAttempts).toBe(5); + expect(restored.retryPolicy?.serviceErrorRetryOnAny5xx).toBe(false); + expect(restored.retryPolicy?.jitter).toBe(JitterType.EQUAL); + expect(restored.retryPolicy?.recoverableStatuses).toEqual({ + "503": ["ServiceUnavailable"], + }); + }); + + it("should expose all JitterType values", () => { + expect(JitterType.EQUAL).toBe("equal"); + expect(JitterType.FULL).toBe("full"); + expect(JitterType.FULL_AND_EQUAL_FOR_THROTTLE).toBe( + "full_and_equal_for_throttle", + ); + expect(JitterType.DECORRELATED).toBe("decorrelated"); + }); +}); diff --git a/tsagentspec/tests/serialization/sensitive-fields.test.ts b/tsagentspec/tests/serialization/sensitive-fields.test.ts index 29453115..efe2b2f4 100644 --- a/tsagentspec/tests/serialization/sensitive-fields.test.ts +++ b/tsagentspec/tests/serialization/sensitive-fields.test.ts @@ -7,6 +7,9 @@ import { createVllmConfig, createOpenAiConfig, createRemoteTool, + createGeminiConfig, + createGeminiAIStudioAuthConfig, + createGeminiVertexAIAuthConfig, stringProperty, } from "../../src/index.js"; @@ -119,6 +122,82 @@ describe("sensitive field exclusion", () => { expect("sensitive_headers" in tools[0]!).toBe(false); }); + it("should exclude apiKey from GeminiAIStudioAuthConfig", () => { + const serializer = new AgentSpecSerializer(); + const auth = createGeminiAIStudioAuthConfig({ id: "auth-id", name: "auth", apiKey: "gk-secret" }); + const config = createGeminiConfig({ + id: "gemini-id", + name: "gemini", + modelId: "gemini-1.5-pro", + auth, + }); + const yaml = serializer.toYaml(config); + expect(yaml).not.toContain("gk-secret"); + expect(yaml).not.toContain("api_key"); + }); + + it("should exclude credentials from GeminiVertexAIAuthConfig", () => { + const serializer = new AgentSpecSerializer(); + const auth = createGeminiVertexAIAuthConfig({ + id: "va-id", + name: "va", + credentials: { private_key: "secret-key-data" }, + }); + const config = createGeminiConfig({ + id: "gemini-id", + name: "gemini", + modelId: "gemini-1.5-pro", + auth, + }); + const yaml = serializer.toYaml(config); + expect(yaml).not.toContain("secret-key-data"); + expect(yaml).not.toContain("credentials"); + }); + + it("should exclude TLS cert fields from OpenAiCompatibleConfig", () => { + const serializer = new AgentSpecSerializer(); + const llm = createOpenAiCompatibleConfig({ + name: "llm", + url: "https://localhost", + modelId: "model", + keyFile: "/secret/client.key", + certFile: "/secret/client.crt", + caFile: "/secret/ca.crt", + }); + const agent = createAgent({ + name: "agent", + llmConfig: llm, + systemPrompt: "Hello", + }); + const json = serializer.toJson(agent) as string; + const dict = JSON.parse(json); + const llmDict = dict["llm_config"] as Record; + expect("key_file" in llmDict).toBe(false); + expect("cert_file" in llmDict).toBe(false); + expect("ca_file" in llmDict).toBe(false); + }); + + it("should include sensitive fields when includeSensitiveFields is true", () => { + const serializer = new AgentSpecSerializer(); + const llm = createOpenAiCompatibleConfig({ + name: "llm", + url: "http://localhost", + modelId: "gpt-4", + apiKey: "sk-secret", + keyFile: "/secret/client.key", + }); + const agent = createAgent({ + name: "agent", + llmConfig: llm, + systemPrompt: "Hello", + }); + const json = serializer.toJson(agent, { includeSensitiveFields: true }) as string; + const dict = JSON.parse(json); + const llmDict = dict["llm_config"] as Record; + expect(llmDict["api_key"]).toBe("sk-secret"); + expect(llmDict["key_file"]).toBe("/secret/client.key"); + }); + it("should keep non-sensitive fields intact", () => { const serializer = new AgentSpecSerializer(); const llm = createOpenAiCompatibleConfig({ diff --git a/tsagentspec/tests/serialization/serialization-context.test.ts b/tsagentspec/tests/serialization/serialization-context.test.ts index d74d4cf7..3331de2b 100644 --- a/tsagentspec/tests/serialization/serialization-context.test.ts +++ b/tsagentspec/tests/serialization/serialization-context.test.ts @@ -408,6 +408,18 @@ describe("camelToSnake edge cases", () => { it("should handle empty string", () => { expect(camelToSnake("")).toBe(""); }); + + it("should insert underscore between lowercase and digit", () => { + expect(camelToSnake("serviceErrorRetryOnAny5xx")).toBe( + "service_error_retry_on_any_5xx", + ); + }); + + it("should round-trip through snakeToCamel for digit-containing names", () => { + const snake = camelToSnake("serviceErrorRetryOnAny5xx"); + expect(snake).toBe("service_error_retry_on_any_5xx"); + expect(snakeToCamel(snake)).toBe("serviceErrorRetryOnAny5xx"); + }); }); describe("snakeToCamel edge cases", () => { diff --git a/tsagentspec/tests/serialization/version-gates.test.ts b/tsagentspec/tests/serialization/version-gates.test.ts index 603e5d01..6ff56048 100644 --- a/tsagentspec/tests/serialization/version-gates.test.ts +++ b/tsagentspec/tests/serialization/version-gates.test.ts @@ -8,6 +8,9 @@ import { createBuiltinTool, createMCPToolBox, createStdioTransport, + createLlmConfig, + createGeminiConfig, + createGeminiAIStudioAuthConfig, stringProperty, } from "../../src/index.js"; @@ -187,6 +190,69 @@ describe("version-gated field serialization", () => { expect("requires_confirmation" in tools[0]!).toBe(true); }); + it("should throw when serializing LlmConfig at version before 26.2.0", () => { + const serializer = new AgentSpecSerializer(); + const llm = createLlmConfig({ name: "generic", modelId: "gpt-4o" }); + expect(() => + serializer.toYaml(llm, { agentspecVersion: AgentSpecVersion.V25_4_2 }), + ).toThrow(/26\.2\.0/); + }); + + it("should throw when serializing GeminiConfig at version before 26.2.0", () => { + const serializer = new AgentSpecSerializer(); + const gemini = createGeminiConfig({ + name: "gemini", + modelId: "gemini-1.5-pro", + auth: createGeminiAIStudioAuthConfig({ name: "auth" }), + }); + expect(() => + serializer.toYaml(gemini, { agentspecVersion: AgentSpecVersion.V25_4_2 }), + ).toThrow(/26\.2\.0/); + }); + + it("should exclude retryPolicy from OpenAiCompatibleConfig for versions before 26.2.0", () => { + const serializer = new AgentSpecSerializer(); + const llm = createOpenAiCompatibleConfig({ + name: "llm", + url: "http://localhost", + modelId: "model", + retryPolicy: { maxAttempts: 5 }, + }); + const agent = createAgent({ + name: "agent", + llmConfig: llm, + systemPrompt: "Hello", + }); + const json = serializer.toJson(agent, { + agentspecVersion: AgentSpecVersion.V25_4_2, + }) as string; + const dict = JSON.parse(json); + const llmDict = dict["llm_config"] as Record; + expect("retry_policy" in llmDict).toBe(false); + }); + + it("should include retryPolicy from OpenAiCompatibleConfig for version 26.2.0+", () => { + const serializer = new AgentSpecSerializer(); + const llm = createOpenAiCompatibleConfig({ + name: "llm", + url: "http://localhost", + modelId: "model", + retryPolicy: { maxAttempts: 5 }, + }); + const agent = createAgent({ + name: "agent", + llmConfig: llm, + systemPrompt: "Hello", + }); + const json = serializer.toJson(agent, { + agentspecVersion: AgentSpecVersion.V26_2_0, + }) as string; + const dict = JSON.parse(json); + const llmDict = dict["llm_config"] as Record; + expect("retry_policy" in llmDict).toBe(true); + expect((llmDict["retry_policy"] as Record)["max_attempts"]).toBe(5); + }); + it("should throw when serializing BuiltinTool at version before 25.4.2", () => { const serializer = new AgentSpecSerializer(); const tool = createBuiltinTool({