From d5633facba93fe3f2c593a70fe05bb8f031a6f49 Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Mon, 10 Aug 2026 19:24:19 +0200 Subject: [PATCH 01/17] Upgrading agent-framework adapter * Upgrading agent-framework adapter to work on latest release * Fix api key in langgraph adapter * Upgrade openai agents * Generalization * Remove negative test for api key in langgraph * Fix autogen * Fix documentation * Changelog, fix docs --- docs/pyagentspec/source/changelog.rst | 8 ++ .../adapter_agent_framework_quickstart.py | 12 +-- .../adapter_openai_quickstart.py | 2 +- pyagentspec/constraints/constraints.txt | 5 +- pyagentspec/constraints/constraints_dev.txt | 5 +- pyagentspec/setup.py | 15 ++- .../_agentframeworkconverter.py | 46 ++++++--- .../agent_framework/_agentspecconverter.py | 26 +++-- .../adapters/agent_framework/_types.py | 35 +++---- .../adapters/autogen/_autogenconverter.py | 3 +- .../adapters/langgraph/_langgraphconverter.py | 21 ++-- .../test_agentframework_to_agentspec.py | 54 ++++++++--- .../test_agentspec_to_agentframework.py | 97 +++++++++++++++---- .../langgraph/test_agentspec_to_langraph.py | 23 ----- 14 files changed, 226 insertions(+), 126 deletions(-) diff --git a/docs/pyagentspec/source/changelog.rst b/docs/pyagentspec/source/changelog.rst index db693b2a..f606794e 100644 --- a/docs/pyagentspec/source/changelog.rst +++ b/docs/pyagentspec/source/changelog.rst @@ -7,6 +7,14 @@ Agent Spec |release| Improvements ^^^^^^^^^^^^ +* **Updated Microsoft Agent Framework dependency** + + Updated ``agent-framework`` to version ``1.13.0``. + +* **Updated OpenAI Agents SDK dependency** + + Updated ``openai-agents`` to version ``0.19.4``. + * **Sensitive field export opt-in** Serializers now support ``include_sensitive_fields`` to include sensitive field values in diff --git a/docs/pyagentspec/source/code_examples/adapter_agent_framework_quickstart.py b/docs/pyagentspec/source/code_examples/adapter_agent_framework_quickstart.py index 537cf3dc..961a804f 100644 --- a/docs/pyagentspec/source/code_examples/adapter_agent_framework_quickstart.py +++ b/docs/pyagentspec/source/code_examples/adapter_agent_framework_quickstart.py @@ -48,7 +48,7 @@ def subtract(a: float, b: float) -> float: return a - b async def main(): - from agent_framework import TextContent + from agent_framework import Content loader = AgentSpecLoader(tool_registry={"subtraction-tool": subtract}) assistant = loader.load_json(agentspec_config) @@ -58,7 +58,7 @@ async def main(): break result = await assistant.run(user_input) agent_message = result.messages[-1].contents[-1] - if not isinstance(agent_message, TextContent): + if not isinstance(agent_message, Content): raise ValueError(f"Unexpected agent_message type {type(agent_message)}") print(f"AGENT >> {agent_message.text}") @@ -69,7 +69,7 @@ async def main(): # .. end-agentspec_to_runtime # .. start-runtime_to_agentspec # Create an Agent Framework Agent -from agent_framework import ChatAgent, tool +from agent_framework import Agent, tool from agent_framework.openai import OpenAIChatClient @tool() @@ -85,11 +85,11 @@ def get_weather(city: str) -> str: """ return f"The weather in {city} is sunny." -agent_framework_agent = ChatAgent( - chat_client=OpenAIChatClient( +agent_framework_agent = Agent( + client=OpenAIChatClient( api_key="ollama", base_url="url.to.agi.model", - model_id="agi_ollama_model", + model="agi_ollama_model", ), name="Weather Agent", instructions="You are a weather agent. Use the provided tool to get data related to the weather based on the city mentioned in the user query.", diff --git a/docs/pyagentspec/source/code_examples/adapter_openai_quickstart.py b/docs/pyagentspec/source/code_examples/adapter_openai_quickstart.py index 54ec20fc..ab3a0308 100644 --- a/docs/pyagentspec/source/code_examples/adapter_openai_quickstart.py +++ b/docs/pyagentspec/source/code_examples/adapter_openai_quickstart.py @@ -66,7 +66,7 @@ async def main(): # .. end-agentspec_to_runtime # .. start-runtime_to_agentspec # Create an OpenAI Agent -from agents.agent import Agent, function_tool +from agents import Agent, function_tool @function_tool def subtraction_tool(a: float, b: float) -> float: diff --git a/pyagentspec/constraints/constraints.txt b/pyagentspec/constraints/constraints.txt index 23ab31e4..ae4d4264 100644 --- a/pyagentspec/constraints/constraints.txt +++ b/pyagentspec/constraints/constraints.txt @@ -26,10 +26,11 @@ langgraph-swarm==0.1.0 wayflowcore==26.1.2 # AgentFramework adapter -agent-framework==1.0.0b260130 +agent-framework-core==1.13.0 +agent-framework-openai==1.12.0 # OpenAI Agents adapter -openai-agents==0.6.9 +openai-agents==0.19.4 libcst==1.8.5 # Evaluation diff --git a/pyagentspec/constraints/constraints_dev.txt b/pyagentspec/constraints/constraints_dev.txt index 23ab31e4..ae4d4264 100644 --- a/pyagentspec/constraints/constraints_dev.txt +++ b/pyagentspec/constraints/constraints_dev.txt @@ -26,10 +26,11 @@ langgraph-swarm==0.1.0 wayflowcore==26.1.2 # AgentFramework adapter -agent-framework==1.0.0b260130 +agent-framework-core==1.13.0 +agent-framework-openai==1.12.0 # OpenAI Agents adapter -openai-agents==0.6.9 +openai-agents==0.19.4 libcst==1.8.5 # Evaluation diff --git a/pyagentspec/setup.py b/pyagentspec/setup.py index b35e807f..30a4cb36 100644 --- a/pyagentspec/setup.py +++ b/pyagentspec/setup.py @@ -103,7 +103,7 @@ def read(file_name): ], "openai-agents": [ # 3rd party dependencies (imported in code) - "openai-agents>=0.6.9", + "openai-agents>=0.19.0", "libcst>=1.5,<2", "httpx>0.28.0", # 4rth party dependencies @@ -156,14 +156,13 @@ def read(file_name): ], "agent-framework": [ # 3rd party dependencies (imported in code) - "agent-framework>=1.0.0b260130; python_version < '3.14'", - "httpx>0.28.0; python_version < '3.14'", + "agent-framework-core>=1.10.0", + "agent-framework-openai>=1.10.0", + "httpx>0.28.0", # 4rth party dependencies - "certifi>=2025.1.31; python_version < '3.14'", # needed to avoid CVE present in earlier versions - "cryptography>=46.0.7; python_version < '3.14'", # needed to avoid CVE present in earlier versions - # including otel-semconv-ai to address internal agent-framework bug - "opentelemetry-semantic-conventions-ai<0.4.14", - "urllib3>=2.7.0; python_version < '3.14'", # needed to avoid CVE present in earlier versions + "certifi>=2025.1.31", # needed to avoid CVE present in earlier versions + "cryptography>=46.0.7", # needed to avoid CVE present in earlier versions + "urllib3>=2.7.0", # needed to avoid CVE present in earlier versions ], "evaluation": [ # 3rd party dependencies (imported in code) diff --git a/pyagentspec/src/pyagentspec/adapters/agent_framework/_agentframeworkconverter.py b/pyagentspec/src/pyagentspec/adapters/agent_framework/_agentframeworkconverter.py index 5cf6fd21..7e827375 100644 --- a/pyagentspec/src/pyagentspec/adapters/agent_framework/_agentframeworkconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/agent_framework/_agentframeworkconverter.py @@ -10,22 +10,23 @@ from pyagentspec.adapters._tools_common import _create_remote_tool_func from pyagentspec.adapters.agent_framework._types import ( + Agent, AgentFrameworkComponent, AgentFrameworkMCPTool, AgentFrameworkTool, BaseChatClient, - ChatAgent, FunctionTool, MCPStdioTool, MCPStreamableHTTPTool, OpenAIChatClient, + OpenAIChatCompletionClient, ) from pyagentspec.agent import Agent as AgentSpecAgent from pyagentspec.component import Component as AgentSpecComponent from pyagentspec.llms.llmconfig import LlmConfig as AgentSpecLlmConfig from pyagentspec.llms.llmgenerationconfig import LlmGenerationConfig from pyagentspec.llms.ollamaconfig import OllamaConfig -from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig +from pyagentspec.llms.openaicompatibleconfig import OpenAIAPIType, OpenAiCompatibleConfig from pyagentspec.llms.openaiconfig import OpenAiConfig from pyagentspec.mcp.tools import MCPTool as AgentSpecMCPTool from pyagentspec.property import Property as AgentSpecProperty @@ -234,6 +235,19 @@ def _llm_convert_to_agent_framework( tool_registry: dict[str, AgentFrameworkTool], converted_components: dict[str, AgentFrameworkComponent], ) -> BaseChatClient: + def openai_client(**kwargs: Any) -> BaseChatClient: + if llm_config.api_type in (OpenAIAPIType.RESPONSES, "responses"): + return OpenAIChatClient(**kwargs) + if llm_config.api_type in ( + None, + OpenAIAPIType.CHAT_COMPLETIONS, + "chat_completions", + ): + return OpenAIChatCompletionClient(**kwargs) + raise NotImplementedError( + f"LlmConfig with api_type='{llm_config.api_type}' is not supported in agent_framework" + ) + if isinstance(llm_config, OpenAiCompatibleConfig): from urllib.parse import urljoin @@ -242,30 +256,28 @@ def _llm_convert_to_agent_framework( base_url = f"http://{base_url}" if "/v1" not in base_url: base_url = urljoin(base_url + "/", "v1") - return OpenAIChatClient( + return openai_client( api_key="openai", base_url=base_url, - model_id=llm_config.model_id, + model=llm_config.model_id, ) elif isinstance(llm_config, OllamaConfig): - return OpenAIChatClient( + return openai_client( api_key="ollama", base_url=llm_config.url, - model_id=llm_config.model_id, + model=llm_config.model_id, ) elif isinstance(llm_config, OpenAiConfig): - return OpenAIChatClient( - model_id=llm_config.model_id, - ) + return openai_client(model=llm_config.model_id) else: # Bare LlmConfig — dispatch on api_provider string if llm_config.api_provider == "openai": - kwargs: dict[str, Any] = {"model_id": llm_config.model_id} + kwargs: dict[str, Any] = {"model": llm_config.model_id} if llm_config.url is not None: kwargs["base_url"] = llm_config.url if llm_config.api_key is not None: kwargs["api_key"] = llm_config.api_key - return OpenAIChatClient(**kwargs) + return openai_client(**kwargs) raise NotImplementedError( f"LlmConfig with api_provider='{llm_config.api_provider}' is not yet supported " f"in agent_framework. Consider using a specific LlmConfig subclass instead." @@ -283,14 +295,16 @@ def _agent_convert_to_agent_framework( chat_client = self.convert(agent.llm_config, tool_registry, converted_components) tools = [self.convert(tool, tool_registry, converted_components) for tool in agent.tools] prompt = agent.system_prompt - return ChatAgent( + return Agent( id=agent.id, name=agent.name, description=agent.description, - chat_client=cast(BaseChatClient, chat_client), + client=cast(BaseChatClient, chat_client), tools=cast(AgentFrameworkTool, tools), instructions=prompt, - temperature=generation_parameters.temperature, - top_p=generation_parameters.top_p, - max_tokens=generation_parameters.max_tokens, + additional_properties=dict( + temperature=generation_parameters.temperature, + top_p=generation_parameters.top_p, + max_tokens=generation_parameters.max_tokens, + ), ) diff --git a/pyagentspec/src/pyagentspec/adapters/agent_framework/_agentspecconverter.py b/pyagentspec/src/pyagentspec/adapters/agent_framework/_agentspecconverter.py index 22b41960..59dc56aa 100644 --- a/pyagentspec/src/pyagentspec/adapters/agent_framework/_agentspecconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/agent_framework/_agentspecconverter.py @@ -9,20 +9,21 @@ from pyagentspec.adapters._utils import _get_obj_reference from pyagentspec.adapters.agent_framework._types import ( + Agent, AgentFrameworkLlmConfig, AgentFrameworkMCPTool, AgentFrameworkTool, - ChatAgent, FunctionTool, MCPStdioTool, MCPStreamableHTTPTool, OpenAIChatClient, + OpenAIChatCompletionClient, ) from pyagentspec.agent import Agent as AgentSpecAgent from pyagentspec.component import Component as AgentSpecComponent from pyagentspec.llms.llmconfig import LlmConfig from pyagentspec.llms.llmgenerationconfig import LlmGenerationConfig -from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig +from pyagentspec.llms.openaicompatibleconfig import OpenAIAPIType, OpenAiCompatibleConfig from pyagentspec.mcp.clienttransport import StdioTransport, StreamableHTTPTransport from pyagentspec.mcp.tools import MCPTool from pyagentspec.property import Property as AgentSpecProperty @@ -92,7 +93,7 @@ def convert( # If we did not find the object, we create it, and we record it in the referenced_objects registry agentspec_component: AgentSpecComponent - if isinstance(runtime_component, ChatAgent): + if isinstance(runtime_component, Agent): agentspec_component = self._agent_convert_to_agentspec( runtime_component, referenced_objects, @@ -187,22 +188,27 @@ def _llm_convert_to_agentspec( chat_client: AgentFrameworkLlmConfig, referenced_objects: dict[str, AgentSpecComponent], ) -> OpenAiCompatibleConfig: - if isinstance(chat_client, OpenAIChatClient): - if chat_client.model_id is None: + if isinstance(chat_client, (OpenAIChatClient, OpenAIChatCompletionClient)): + if chat_client.model is None: # Defensive check for None in some versions due to fast iteration # Once the framework stabilizes and the type is set in stone this check can be removed - raise ValueError(f"model_id for {type(chat_client)} is not set.") + raise ValueError(f"model for {type(chat_client)} is not set.") return OpenAiCompatibleConfig( - name=chat_client.model_id, - model_id=chat_client.model_id, + name=chat_client.model, + model_id=chat_client.model, url=chat_client.service_url(), + api_type=( + OpenAIAPIType.RESPONSES + if isinstance(chat_client, OpenAIChatClient) + else OpenAIAPIType.CHAT_COMPLETIONS + ), ) else: raise NotImplementedError(f"Chat client {type(chat_client)} not supported") def _agent_convert_to_agentspec( self, - chat_agent: ChatAgent, + chat_agent: Agent, referenced_objects: dict[str, AgentSpecComponent], ) -> AgentSpecComponent: generation_config = LlmGenerationConfig( @@ -213,7 +219,7 @@ def _agent_convert_to_agentspec( llm_config = cast( LlmConfig, self.convert( - chat_agent.chat_client, + chat_agent.client, referenced_objects, ), ) diff --git a/pyagentspec/src/pyagentspec/adapters/agent_framework/_types.py b/pyagentspec/src/pyagentspec/adapters/agent_framework/_types.py index cf0d4295..e000441c 100644 --- a/pyagentspec/src/pyagentspec/adapters/agent_framework/_types.py +++ b/pyagentspec/src/pyagentspec/adapters/agent_framework/_types.py @@ -10,49 +10,50 @@ if TYPE_CHECKING: from agent_framework import ( + Agent, BaseChatClient, - ChatAgent, - ChatClientProtocol, ChatOptions, FunctionTool, MCPStdioTool, MCPStreamableHTTPTool, MCPWebsocketTool, - ToolProtocol, ) - from agent_framework.openai import OpenAIChatClient + from agent_framework.openai import OpenAIChatClient, OpenAIChatCompletionClient else: ChatOptions = LazyLoader("agent_framework").ChatOptions FunctionTool = LazyLoader("agent_framework").FunctionTool BaseChatClient = LazyLoader("agent_framework").BaseChatClient - ChatAgent = LazyLoader("agent_framework").ChatAgent - ChatClientProtocol = LazyLoader("agent_framework").ChatClientProtocol + Agent = LazyLoader("agent_framework").Agent MCPStdioTool = LazyLoader("agent_framework").MCPStdioTool MCPStreamableHTTPTool = LazyLoader("agent_framework").MCPStreamableHTTPTool MCPWebsocketTool = LazyLoader("agent_framework").MCPWebsocketTool - ToolProtocol = LazyLoader("agent_framework").ToolProtocol - OpenAIChatClient = LazyLoader("agent_framework.openai").OpenAIChatClient + _openai = LazyLoader("agent_framework.openai") + OpenAIChatClient = _openai.OpenAIChatClient + OpenAIChatCompletionClient = _openai.OpenAIChatCompletionClient -AgentFrameworkComponent: TypeAlias = ChatAgent +AgentFrameworkMCPTool: TypeAlias = MCPStdioTool | MCPStreamableHTTPTool | MCPWebsocketTool AgentFrameworkTool: TypeAlias = ( - ToolProtocol - | FunctionTool[Any, Any] + FunctionTool | Callable[..., Any] | MutableMapping[str, Any] - | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] + | Sequence[Callable[..., Any] | MutableMapping[str, Any]] + | AgentFrameworkMCPTool ) -AgentFrameworkLlmConfig: TypeAlias = BaseChatClient | ChatClientProtocol -AgentFrameworkMCPTool: TypeAlias = MCPStdioTool | MCPStreamableHTTPTool | MCPWebsocketTool +AgentFrameworkLlmConfig: TypeAlias = BaseChatClient +AgentFrameworkComponent: TypeAlias = Agent | AgentFrameworkTool | AgentFrameworkLlmConfig __all__ = [ + "AgentFrameworkComponent", + "AgentFrameworkLlmConfig", + "AgentFrameworkMCPTool", + "AgentFrameworkTool", "BaseChatClient", "FunctionTool", - "ChatAgent", - "ChatClientProtocol", + "Agent", "MCPStdioTool", "MCPStreamableHTTPTool", "MCPWebsocketTool", - "ToolProtocol", "OpenAIChatClient", + "OpenAIChatCompletionClient", "ChatOptions", ] diff --git a/pyagentspec/src/pyagentspec/adapters/autogen/_autogenconverter.py b/pyagentspec/src/pyagentspec/adapters/autogen/_autogenconverter.py index ea46d27d..3e15b9f9 100644 --- a/pyagentspec/src/pyagentspec/adapters/autogen/_autogenconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/autogen/_autogenconverter.py @@ -204,7 +204,8 @@ def _prepare_llm_args(agentspec_llm_: AgentSpecLlmConfig) -> Dict[str, Any]: if agentspec_llm_.url else None ), - api_key=agentspec_llm_.api_key if agentspec_llm_.api_key else None, + # Use a fake API key to avoid `openai` failing immediately at parsing if no key is needed + api_key=agentspec_llm_.api_key if agentspec_llm_.api_key else "EMPTY", model_info=_prepare_model_info(agentspec_llm_), ) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 7393e9c8..069ea273 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -7,6 +7,7 @@ import inspect import logging +import os import sys from typing import ( TYPE_CHECKING, @@ -1195,7 +1196,9 @@ def _create_react_agent_with_given_info( ) if middleware: create_agent_kwargs["middleware"] = middleware - compiled_graph = langchain_agents.create_agent(**create_agent_kwargs) + compiled_graph: CompiledStateGraph[Any, Any, Any] = langchain_agents.create_agent( + **create_agent_kwargs + ) # To enable flow execution traces monkey patch all the functions that invoke the compiled graph @@ -1323,7 +1326,7 @@ def _llm_convert_to_langgraph( return _create_chat_openai_model( model_id=llm_config.model_id, base_url=_prepare_openai_compatible_url(llm_config.url), - api_key=llm_config.api_key if llm_config.api_key is not None else "EMPTY", + api_key=llm_config.api_key, use_responses_api=use_responses_api, callbacks=callbacks, generation_config=generation_config, @@ -1650,7 +1653,6 @@ def _generation_config_from_agentspec( def _create_chat_openai_model( - *, model_id: str, use_responses_api: bool, callbacks: List[BaseCallbackHandler], @@ -1661,12 +1663,15 @@ def _create_chat_openai_model( ) -> BaseChatModel: """Create a ChatOpenAI model without overriding env-based defaults. - Important: passing `api_key=None` disables LangChain's env-based default (`OPENAI_API_KEY`) - and results in a model without a sync client. Only pass `api_key` when it is explicitly - specified in the Agent Spec config. + Important: passing `api_key=None` fallbacks to env-based default (`OPENAI_API_KEY`) or a fake API key. """ from langchain_openai import ChatOpenAI + # If api_key is None (or not passed), and the environment variable is not set, openai raises an error + # Therefore, we set a fake API key to avoid raising an exception in case no key is required + if api_key is None: + api_key = os.getenv("OPENAI_API_KEY", "EMPTY") or "EMPTY" + optional_kwargs: _ChatOpenAIOptionalKwargs = {} max_retries = retry_config.get("max_retries") if max_retries is not None: @@ -1676,13 +1681,12 @@ def _create_chat_openai_model( optional_kwargs["timeout"] = timeout if base_url is not None: optional_kwargs["base_url"] = base_url - if api_key is not None: - optional_kwargs["api_key"] = SecretStr(api_key) return ChatOpenAI( model=model_id, use_responses_api=use_responses_api, callbacks=callbacks, + api_key=SecretStr(api_key), temperature=generation_config.get("temperature"), max_completion_tokens=generation_config.get("max_tokens"), top_p=generation_config.get("top_p"), @@ -1696,7 +1700,6 @@ class _ChatOpenAIOptionalKwargs(TypedDict): max_retries: NotRequired[int] timeout: NotRequired[float] base_url: NotRequired[str] - api_key: NotRequired[SecretStr] class _GenerationConfig(TypedDict): diff --git a/pyagentspec/tests/adapters/agent_framework/test_agentframework_to_agentspec.py b/pyagentspec/tests/adapters/agent_framework/test_agentframework_to_agentspec.py index 87bb7900..b379d944 100644 --- a/pyagentspec/tests/adapters/agent_framework/test_agentframework_to_agentspec.py +++ b/pyagentspec/tests/adapters/agent_framework/test_agentframework_to_agentspec.py @@ -6,14 +6,14 @@ from typing import cast -from pyagentspec.agent import Agent -from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig +from pyagentspec.agent import Agent as AgentSpecAgent +from pyagentspec.llms.openaicompatibleconfig import OpenAIAPIType, OpenAiCompatibleConfig def test_agent_framework_converts_to_agent_spec_with_server_tool() -> None: - from agent_framework import ChatAgent, tool - from agent_framework.openai import OpenAIChatClient + from agent_framework import Agent, tool + from agent_framework.openai import OpenAIChatCompletionClient from pyagentspec.adapters.agent_framework import AgentSpecExporter @@ -21,31 +21,34 @@ def test_agent_framework_converts_to_agent_spec_with_server_tool() -> None: def add_tool(a: int, b: int) -> int: return a + b - agent = ChatAgent( - chat_client=OpenAIChatClient( + agent = Agent( + client=OpenAIChatCompletionClient( api_key="ollama", base_url="url.to.agi.model", - model_id="agi_ollama_model", + model="agi_ollama_model", ), name="MathAgent", instructions="You are a helpful math agent", tools=add_tool, - temperature=0.2, - top_p=0.5, - max_tokens=10000, + additional_properties=dict( + temperature=0.2, + top_p=0.5, + max_tokens=10000, + ), ) exporter = AgentSpecExporter() - agent_component = cast(Agent, exporter.to_component(agent)) + agent_component = cast(AgentSpecAgent, exporter.to_component(agent)) # Agent config assert agent_component.name == agent.name assert agent_component.description == agent.description assert isinstance(agent_component.llm_config, OpenAiCompatibleConfig) - assert isinstance(agent.chat_client, OpenAIChatClient) + assert agent_component.llm_config.api_type == OpenAIAPIType.CHAT_COMPLETIONS + assert isinstance(agent.client, OpenAIChatCompletionClient) assert agent_component.system_prompt == agent.default_options["instructions"] # Llm Config - assert agent_component.llm_config.url == agent.chat_client.service_url() - assert agent_component.llm_config.model_id == agent.chat_client.model_id + assert agent_component.llm_config.url == agent.client.service_url() + assert agent_component.llm_config.model_id == agent.client.model default_generation_parameters = agent_component.llm_config.default_generation_parameters assert default_generation_parameters is not None assert default_generation_parameters.temperature == agent.additional_properties["temperature"] @@ -65,3 +68,26 @@ def add_tool(a: int, b: int) -> int: assert "b" in (schema["title"] for schema in input_json_schemas) assert all(schema["type"] == "integer" for schema in input_json_schemas) assert output_json_schema["title"] == "result" and output_json_schema["type"] == "integer" + + +def test_agent_framework_responses_client_converts_to_agent_spec() -> None: + from agent_framework import Agent + from agent_framework.openai import OpenAIChatClient + + from pyagentspec.adapters.agent_framework import AgentSpecExporter + + agent = Agent( + client=OpenAIChatClient( + api_key="test-key", + base_url="https://api.example.com/v1", + model="gpt-test", + ), + name="ResponsesAgent", + instructions="Be helpful.", + ) + + agent_component = cast(AgentSpecAgent, AgentSpecExporter().to_component(agent)) + + assert isinstance(agent_component.llm_config, OpenAiCompatibleConfig) + assert agent_component.llm_config.api_type == OpenAIAPIType.RESPONSES + assert agent_component.llm_config.model_id == agent.client.model diff --git a/pyagentspec/tests/adapters/agent_framework/test_agentspec_to_agentframework.py b/pyagentspec/tests/adapters/agent_framework/test_agentspec_to_agentframework.py index b173d700..368f29bf 100644 --- a/pyagentspec/tests/adapters/agent_framework/test_agentspec_to_agentframework.py +++ b/pyagentspec/tests/adapters/agent_framework/test_agentspec_to_agentframework.py @@ -5,28 +5,92 @@ # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. import anyio +import pytest -from pyagentspec.agent import Agent +from pyagentspec.agent import Agent as AgentSpecAgent +from pyagentspec.llms import LlmConfig +from pyagentspec.llms.openaicompatibleconfig import OpenAIAPIType, OpenAiCompatibleConfig from pyagentspec.llms.vllmconfig import VllmConfig from pyagentspec.serialization import AgentSpecDeserializer from .conftest import get_weather +@pytest.mark.parametrize( + ("api_type", "expected_client_name"), + [ + (OpenAIAPIType.CHAT_COMPLETIONS, "OpenAIChatCompletionClient"), + (OpenAIAPIType.RESPONSES, "OpenAIChatClient"), + ], +) +def test_agentspec_api_type_selects_agent_framework_client( + api_type: OpenAIAPIType, expected_client_name: str +) -> None: + from agent_framework.openai import OpenAIChatClient, OpenAIChatCompletionClient + + from pyagentspec.adapters.agent_framework import AgentSpecLoader + + client = AgentSpecLoader().load_component( + OpenAiCompatibleConfig( + name="test-model", + model_id="test-model", + url="http://test.example/v1", + api_type=api_type, + ) + ) + + expected_client = { + "OpenAIChatClient": OpenAIChatClient, + "OpenAIChatCompletionClient": OpenAIChatCompletionClient, + }[expected_client_name] + assert isinstance(client, expected_client) + + +@pytest.mark.parametrize( + ("api_type", "expected_client_name"), + [ + ("chat_completions", "OpenAIChatCompletionClient"), + ("responses", "OpenAIChatClient"), + ], +) +def test_bare_llmconfig_api_type_selects_agent_framework_client( + api_type: str, expected_client_name: str +) -> None: + from agent_framework.openai import OpenAIChatClient, OpenAIChatCompletionClient + + from pyagentspec.adapters.agent_framework import AgentSpecLoader + + client = AgentSpecLoader().load_component( + LlmConfig( + name="test-model", + model_id="test-model", + api_provider="openai", + api_type=api_type, + url="http://test.example/v1", + ) + ) + + expected_client = { + "OpenAIChatClient": OpenAIChatClient, + "OpenAIChatCompletionClient": OpenAIChatCompletionClient, + }[expected_client_name] + assert isinstance(client, expected_client) + + def test_agentspec_converts_to_agent_framework_with_server_tool( weather_agent_server_tool: str, ) -> None: - from agent_framework import ChatAgent, FunctionTool - from agent_framework.openai import OpenAIChatClient + from agent_framework import Agent, FunctionTool + from agent_framework.openai import OpenAIChatCompletionClient from pyagentspec.adapters.agent_framework import AgentSpecLoader agent_component = AgentSpecDeserializer().from_yaml(weather_agent_server_tool) - assert isinstance(agent_component, Agent) + assert isinstance(agent_component, AgentSpecAgent) loader = AgentSpecLoader(tool_registry={"get_weather": get_weather}) agent = loader.load_yaml(weather_agent_server_tool) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) # Agent Config assert agent_component.name == agent.name @@ -35,9 +99,9 @@ def test_agentspec_converts_to_agent_framework_with_server_tool( # Llm Config assert isinstance(agent_component.llm_config, VllmConfig) - assert isinstance(agent.chat_client, OpenAIChatClient) - assert agent_component.llm_config.url in agent.chat_client.service_url() - assert agent_component.llm_config.model_id == agent.chat_client.model_id + assert isinstance(agent.client, OpenAIChatCompletionClient) + assert agent_component.llm_config.url in agent.client.service_url() + assert agent_component.llm_config.model_id == agent.client.model default_generation_parameters = agent_component.llm_config.default_generation_parameters assert default_generation_parameters is not None assert default_generation_parameters.temperature == agent.additional_properties["temperature"] @@ -56,27 +120,26 @@ def test_agentspec_converts_to_agent_framework_with_server_tool( def test_agent_with_server_tool_runs_after_config_load(weather_agent_server_tool: str) -> None: - from agent_framework import ChatAgent + from agent_framework import Agent from pyagentspec.adapters.agent_framework import AgentSpecLoader loader = AgentSpecLoader(tool_registry={"get_weather": get_weather}) agent = loader.load_yaml(weather_agent_server_tool) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) result = anyio.run(agent.run, "What is the weather like in Agadir?") contents = result.messages[-1].contents assert len(contents) > 0 - last_content = contents[-1] def test_remote_tool_with_agent(weather_agent_remote_tool: str) -> None: - from agent_framework import ChatAgent + from agent_framework import Agent from pyagentspec.adapters.agent_framework import AgentSpecLoader agent = AgentSpecLoader().load_yaml(weather_agent_remote_tool) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) result = anyio.run(agent.run, "What is the weather like in Agadir?") contents = result.messages[-1].contents @@ -89,13 +152,13 @@ def test_server_tool_requires_confirmation_with_agent( weather_agent_server_tool_confirmation: str, ) -> None: - from agent_framework import ChatAgent, ChatMessage, FunctionTool, Role + from agent_framework import Agent, FunctionTool, Message from pyagentspec.adapters.agent_framework import AgentSpecLoader loader = AgentSpecLoader(tool_registry={"get_weather": get_weather}) agent = loader.load_yaml(weather_agent_server_tool_confirmation) - assert isinstance(agent, ChatAgent) + assert isinstance(agent, Agent) # Ensure approval mode is set to always_require for the converted tool tools = agent.default_options["tools"] or [] @@ -118,8 +181,8 @@ async def run_agent_with_confirmation(agent): final_result = await agent.run( [ "What is the weather like in Agadir?", - ChatMessage(role=Role.ASSISTANT, contents=[req]), - ChatMessage(role=Role.USER, contents=[req.to_function_approval_response(True)]), + Message(role="assistant", contents=[req]), + Message(role="user", contents=[req.to_function_approval_response(True)]), ], ) contents = final_result.messages[-1].contents diff --git a/pyagentspec/tests/adapters/langgraph/test_agentspec_to_langraph.py b/pyagentspec/tests/adapters/langgraph/test_agentspec_to_langraph.py index c1b35287..7c077651 100644 --- a/pyagentspec/tests/adapters/langgraph/test_agentspec_to_langraph.py +++ b/pyagentspec/tests/adapters/langgraph/test_agentspec_to_langraph.py @@ -220,29 +220,6 @@ def weather_agent_server_tool_openaicompatible_yaml(weather_agent_server_tool_ya ) -def test_weather_agent_with_server_tool_with_openaicompatible_llm_raises_without_api_key( - weather_agent_server_tool_openaicompatible_yaml: str, -) -> None: - """ - This test is checking the case of OpenAiCompatibleConfig. - The VllmConfig is already tested in all the tests above - """ - import openai - - from pyagentspec.adapters.langgraph import AgentSpecLoader - - old_value = os.environ.pop("OPENAI_API_KEY", None) - - try: - with pytest.raises(openai.OpenAIError, match="api_key"): - AgentSpecLoader(tool_registry={"get_weather": get_weather}).load_yaml( - weather_agent_server_tool_openaicompatible_yaml - ) - finally: - if old_value is not None: - os.environ["OPENAI_API_KEY"] = old_value - - @mock.patch.dict(os.environ, {"OPENAI_API_KEY": "MOCKED_KEY"}) @retry_test(max_attempts=3, wait_between_tries=2) def test_execute_weather_agent_with_server_tool_with_openaicompatible_llm( From c9b0a20f1a5d09aecb081c49e760ee2f77325c76 Mon Sep 17 00:00:00 2001 From: Tanmay Bagaria Date: Fri, 14 Aug 2026 02:16:41 -0700 Subject: [PATCH 02/17] feat: add DBMS vector chain LLM config * feat: add DBMS vector chain LLM config Signed-off-by: Tanmay Bagaria * Add DBMS Vector Chain LLM configuration Signed-off-by: Tanmay Bagaria * Address DBMS Vector Chain review feedback Signed-off-by: Tanmay Bagaria * Clarify DBMS Vector Chain configuration semantics Signed-off-by: Tanmay Bagaria --------- Signed-off-by: Tanmay Bagaria --- .../source/_components/all_components.json | 4 + .../source/_components/llm_config_tabs.rst | 14 ++ .../json_spec/agentspec_json_spec_26_2_0.json | 174 ++++++++++++++++++ .../agentspec/language_spec_nightly.rst | 43 +++++ docs/pyagentspec/source/api/llmmodels.rst | 8 + docs/pyagentspec/source/changelog.rst | 5 + .../howto_llm_from_different_providers.py | 13 ++ .../howto_llm_from_different_providers.rst | 68 +++++++ .../src/pyagentspec/_component_registry.py | 2 + pyagentspec/src/pyagentspec/llms/__init__.py | 2 + .../llms/dbmsvectorchainllmconfig.py | 90 +++++++++ .../test_dbms_vector_chain_llm_config.py | 84 +++++++++ pyagentspec/tests/test_schema_generation.py | 19 ++ .../test_dbms_vector_chain_llm_config.py | 133 +++++++++++++ 14 files changed, 659 insertions(+) create mode 100644 pyagentspec/src/pyagentspec/llms/dbmsvectorchainllmconfig.py create mode 100644 pyagentspec/tests/serialization/test_dbms_vector_chain_llm_config.py create mode 100644 pyagentspec/tests/validation/test_dbms_vector_chain_llm_config.py diff --git a/docs/pyagentspec/source/_components/all_components.json b/docs/pyagentspec/source/_components/all_components.json index a0ce07bd..c4b35a6f 100644 --- a/docs/pyagentspec/source/_components/all_components.json +++ b/docs/pyagentspec/source/_components/all_components.json @@ -103,6 +103,10 @@ "path": "pyagentspec.llms.llmconfig.LlmConfig", "name": "pyagentspec.llms.LlmConfig" }, + { + "path": "pyagentspec.llms.dbmsvectorchainllmconfig.DbmsVectorChainLlmConfig", + "name": "pyagentspec.llms.DbmsVectorChainLlmConfig" + }, { "path": "pyagentspec.llms.llmgenerationconfig.LlmGenerationConfig", "name": "pyagentspec.llms.LlmGenerationConfig" diff --git a/docs/pyagentspec/source/_components/llm_config_tabs.rst b/docs/pyagentspec/source/_components/llm_config_tabs.rst index 7053601c..3eea6a26 100644 --- a/docs/pyagentspec/source/_components/llm_config_tabs.rst +++ b/docs/pyagentspec/source/_components/llm_config_tabs.rst @@ -16,6 +16,20 @@ model_id="model-id", ) + .. tab:: DBMS Vector Chain + + .. code-block:: python + + from pyagentspec.llms import DbmsVectorChainLlmConfig + + llm_config = DbmsVectorChainLlmConfig( + name="Database OpenAI", + model_id="gpt-4o", + provider="openai", + url="https://api.openai.com/v1/chat/completions", + credential_name="MY_OPENAI_CREDENTIAL", + ) + .. tab:: OCI GenAI .. code-block:: python diff --git a/docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_2_0.json b/docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_2_0.json index 1b3518fd..d390712d 100644 --- a/docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_2_0.json +++ b/docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_2_0.json @@ -214,6 +214,16 @@ } ] }, + "DbmsVectorChainLlmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseDbmsVectorChainLlmConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, "EndNode": { "anyOf": [ { @@ -2638,6 +2648,146 @@ ], "x-abstract-component": true }, + "BaseDbmsVectorChainLlmConfig": { + "additionalProperties": false, + "description": "Configure an LLM executed by Oracle Database through DBMS_VECTOR_CHAIN.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "minLength": 1, + "title": "Model Id", + "type": "string" + }, + "provider": { + "minLength": 1, + "title": "Provider", + "type": "string" + }, + "url": { + "minLength": 1, + "title": "Url", + "type": "string" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "host": { + "anyOf": [ + { + "const": "local", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Host" + }, + "credential_name": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Credential Name" + }, + "transfer_timeout": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Transfer Timeout" + }, + "connection_config": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/OracleDatabaseConnectionConfig" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "DbmsVectorChainLlmConfig" + } + }, + "required": [ + "model_id", + "name", + "provider", + "url" + ], + "title": "DbmsVectorChainLlmConfig", + "type": "object", + "x-abstract-component": false + }, "BaseEndNode": { "additionalProperties": false, "description": "End nodes denote the end of the execution of a flow.\n\nThere might be several end nodes in a flow, in which case the executor of the flow\nshould be able to track which one was reached and pass that back to the caller.\n\n- **Inputs**\n The list of inputs of the step. If both input and output properties are specified they\n must be an exact match\n\n If None is given, ``pyagentspec`` copies the outputs provided, if any. Otherwise, no input is exposed.\n- **Outputs**\n The list of outputs that should be exposed by the flow. If both input and output properties\n are specified they must be an exact match\n\n If None is given, ``pyagentspec`` copies the inputs provided, if any. Otherwise, no output is exposed.\n- **Branches**\n None.\n\nExamples\n--------\n>>> from pyagentspec.agent import Agent\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import AgentNode, BranchingNode, StartNode, EndNode\n>>> from pyagentspec.property import Property\n>>> languages_to_branch_name = {\n... \"english\": \"ENGLISH\",\n... \"spanish\": \"SPANISH\",\n... \"italian\": \"ITALIAN\",\n... }\n>>> language_property = Property(\n... json_schema={\"title\": \"language\", \"type\": \"string\"}\n... )\n>>> agent = Agent(\n... name=\"Language detector agent\",\n... llm_config=llm_config,\n... system_prompt=(\n... \"Your task is to understand the language spoken by the user.\"\n... \"Please output only the language in lowercase and submit.\"\n... ),\n... outputs=[language_property],\n... )\n>>> start_node = StartNode(name=\"start\")\n>>> english_end_node = EndNode(\n... name=\"english end\", branch_name=languages_to_branch_name[\"english\"]\n... )\n>>> spanish_end_node = EndNode(\n... name=\"spanish end\", branch_name=languages_to_branch_name[\"spanish\"]\n... )\n>>> italian_end_node = EndNode(\n... name=\"italian end\", branch_name=languages_to_branch_name[\"italian\"]\n... )\n>>> unknown_end_node = EndNode(name=\"unknown language end\", branch_name=\"unknown\")\n>>> branching_node = BranchingNode(\n... name=\"language check\",\n... mapping=languages_to_branch_name,\n... inputs=[language_property]\n... )\n>>> agent_node = AgentNode(\n... name=\"User input agent node\",\n... agent=agent,\n... )\n>>> assistant = Flow(\n... name=\"Check access flow\",\n... start_node=start_node,\n... nodes=[\n... start_node,\n... agent_node,\n... branching_node,\n... english_end_node,\n... spanish_end_node,\n... italian_end_node,\n... unknown_end_node,\n... ],\n... control_flow_connections=[\n... ControlFlowEdge(\n... name=\"start_to_agent\", from_node=start_node, to_node=agent_node\n... ),\n... ControlFlowEdge(\n... name=\"agent_to_branching\", from_node=agent_node, to_node=branching_node\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_english_end\",\n... from_node=branching_node,\n... from_branch=languages_to_branch_name[\"english\"],\n... to_node=english_end_node,\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_spanish_end\",\n... from_node=branching_node,\n... from_branch=languages_to_branch_name[\"spanish\"],\n... to_node=spanish_end_node,\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_italian_end\",\n... from_node=branching_node,\n... from_branch=languages_to_branch_name[\"italian\"],\n... to_node=italian_end_node,\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_unknown_end\",\n... from_node=branching_node,\n... from_branch=BranchingNode.DEFAULT_BRANCH,\n... to_node=unknown_end_node,\n... ),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"language_edge\",\n... source_node=agent_node,\n... source_output=\"language\",\n... destination_node=branching_node,\n... destination_input=\"language\",\n... ),\n... ],\n... )", @@ -3386,6 +3536,9 @@ }, "BaseLlmConfig": { "anyOf": [ + { + "$ref": "#/$defs/DbmsVectorChainLlmConfig" + }, { "$ref": "#/$defs/GeminiConfig" }, @@ -7732,6 +7885,9 @@ { "$ref": "#/$defs/BaseDatastore" }, + { + "$ref": "#/$defs/BaseDbmsVectorChainLlmConfig" + }, { "$ref": "#/$defs/BaseEndNode" }, @@ -8192,6 +8348,21 @@ } } }, + "VersionedDbmsVectorChainLlmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseDbmsVectorChainLlmConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, "VersionedEndNode": { "anyOf": [ { @@ -9077,6 +9248,9 @@ { "$ref": "#/$defs/VersionedDatastore" }, + { + "$ref": "#/$defs/VersionedDbmsVectorChainLlmConfig" + }, { "$ref": "#/$defs/VersionedEndNode" }, diff --git a/docs/pyagentspec/source/agentspec/language_spec_nightly.rst b/docs/pyagentspec/source/agentspec/language_spec_nightly.rst index 80aeb86d..bb28188f 100644 --- a/docs/pyagentspec/source/agentspec/language_spec_nightly.rst +++ b/docs/pyagentspec/source/agentspec/language_spec_nightly.rst @@ -617,6 +617,49 @@ Null value is equivalent to an empty dictionary, i.e., no default generation par and ``api_type`` values. Specific extensions of ``LlmConfig`` for the most common providers are also provided for convenience, offering additional provider-specific configuration options. +DBMS Vector Chain +^^^^^^^^^^^^^^^^^ + +``DbmsVectorChainLlmConfig`` configures an LLM request executed by Oracle +Database through ``DBMS_VECTOR_CHAIN``. + +.. code-block:: python + + class DbmsVectorChainLlmConfig(LlmConfig): + model_id: str + provider: str + url: str + credential_name: Optional[str] + host: Optional[Literal["local"]] + transfer_timeout: Optional[int] + connection_config: Optional[OracleDatabaseConnectionConfig] + +The ``connection_config`` field is an optional Oracle Database connection +configuration. If ``connection_config`` is not specified, the runtime must use +an existing Oracle Database connection from its execution context. If no +connection is available, the runtime must raise an error. + +The ``host`` field can only be set to ``"local"`` and is supported only when +``provider`` is ``"openai"`` or ``"ollama"``. It indicates that the provider +is running locally and disables database credential authentication. When +``host`` is specified, ``credential_name`` must be omitted. + +The ``transfer_timeout`` field specifies the maximum number of seconds to wait +for the provider request to complete. If it is not specified, +``DBMS_VECTOR_CHAIN`` uses its default timeout of 60 seconds. + +The runtime maps these fields to the corresponding ``DBMS_VECTOR_CHAIN`` +parameters: ``model_id`` to ``model``, and ``provider``, ``url``, +``credential_name``, ``host``, and ``transfer_timeout`` to parameters with the +same names. ``connection_config`` configures the runtime's database connection +and is not a ``DBMS_VECTOR_CHAIN`` parameter. + +For a remote provider, ``credential_name`` identifies a credential created in +Oracle Database with ``DBMS_VECTOR_CHAIN.CREATE_CREDENTIAL``. For a local +OpenAI or Ollama provider, set ``host`` to ``"local"`` and omit +``credential_name``. Refer to the `DBMS_VECTOR_CHAIN documentation `_ +for supported providers and provider-specific parameters. + Structured Generation ^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/pyagentspec/source/api/llmmodels.rst b/docs/pyagentspec/source/api/llmmodels.rst index c1db63fd..c17c8997 100644 --- a/docs/pyagentspec/source/api/llmmodels.rst +++ b/docs/pyagentspec/source/api/llmmodels.rst @@ -11,6 +11,14 @@ LlmConfig :exclude-members: model_post_init, model_config +DBMS Vector Chain LLM Config +---------------------------- + +.. _dbmsvectorchainllmconfig: +.. autoclass:: pyagentspec.llms.dbmsvectorchainllmconfig.DbmsVectorChainLlmConfig + :exclude-members: model_post_init, model_config + + LLM Generation Config --------------------- diff --git a/docs/pyagentspec/source/changelog.rst b/docs/pyagentspec/source/changelog.rst index f606794e..ac842b89 100644 --- a/docs/pyagentspec/source/changelog.rst +++ b/docs/pyagentspec/source/changelog.rst @@ -47,6 +47,11 @@ Bug fixes New features ^^^^^^^^^^^^ +* **DBMS Vector Chain LLM configuration** + + Added ``DbmsVectorChainLlmConfig`` for configuring LLM requests executed + through Oracle Database ``DBMS_VECTOR_CHAIN``. + * **MCP tool retry policies** Added ``retry_policy`` support to ``MCPTool`` and ``MCPToolBox`` so runtimes can diff --git a/docs/pyagentspec/source/code_examples/howto_llm_from_different_providers.py b/docs/pyagentspec/source/code_examples/howto_llm_from_different_providers.py index 44544b42..f1ab2a3e 100644 --- a/docs/pyagentspec/source/code_examples/howto_llm_from_different_providers.py +++ b/docs/pyagentspec/source/code_examples/howto_llm_from_different_providers.py @@ -23,6 +23,19 @@ ) # .. llmconfig-end +# .. dbmsvectorchain-start +from pyagentspec.llms import DbmsVectorChainLlmConfig + +llm = DbmsVectorChainLlmConfig( + name="database-openai", + model_id="gpt-4o", + provider="openai", + url="https://api.openai.com/v1/chat/completions", + credential_name="MY_OPENAI_CREDENTIAL", + transfer_timeout=120, +) +# .. dbmsvectorchain-end + # .. oci-start from pyagentspec.llms import OciGenAiConfig from pyagentspec.llms import LlmGenerationConfig diff --git a/docs/pyagentspec/source/howtoguides/howto_llm_from_different_providers.rst b/docs/pyagentspec/source/howtoguides/howto_llm_from_different_providers.rst index d0f393ce..89294df5 100644 --- a/docs/pyagentspec/source/howtoguides/howto_llm_from_different_providers.rst +++ b/docs/pyagentspec/source/howtoguides/howto_llm_from_different_providers.rst @@ -7,6 +7,7 @@ field to describe any provider, or you can use a dedicated subclass for provider The available LLM configurations are: - :ref:`LlmConfig ` (generic, provider-agnostic) +- :ref:`DbmsVectorChainLlmConfig ` - :ref:`OpenAiConfig ` - :ref:`GeminiConfig ` - :ref:`OciGenAiConfig ` @@ -81,6 +82,73 @@ or when you want a simple, portable configuration. :end-before: .. llmconfig-end +DbmsVectorChainLlmConfig +======================== + +Use ``DbmsVectorChainLlmConfig`` when Oracle Database executes the LLM request +through ``DBMS_VECTOR_CHAIN``. It refers to a credential managed by the +database. + +Refer to the `DBMS_VECTOR_CHAIN documentation `_ +for supported ``provider`` values and provider-specific configuration +requirements. + +``credential_name`` is the name of an existing database credential, not an API +key or credential value. Create the credential with +``DBMS_VECTOR_CHAIN.CREATE_CREDENTIAL``; refer to the linked documentation for +credential creation instructions. For a remote provider, specify +``credential_name`` and omit ``host``. For a local OpenAI or Ollama provider, +specify ``host="local"`` and omit ``credential_name``. + +**Parameters** + +.. option:: model_id: str + + Name of the text-generation model used by the database. + +.. option:: provider: str + + Database-supported LLM provider, such as ``openai``, ``cohere``, or + ``ollama``. + +.. option:: url: str + + Provider endpoint URL used by Oracle Database. + +.. option:: credential_name: str, null + + Name of a credential created in Oracle Database with + ``DBMS_VECTOR_CHAIN.CREATE_CREDENTIAL``. Required unless ``host`` is set. + +.. option:: host: "local", null + + Set to ``"local"`` for a local OpenAI or Ollama provider. When set, omit + ``credential_name``. + +.. option:: transfer_timeout: int, null + + Maximum number of seconds the database waits for the provider request. + +.. option:: connection_config: OracleDatabaseConnectionConfig, null + + Optional Oracle Database connection configuration. + +.. option:: default_generation_parameters: dict, null + + Default generation settings, such as ``temperature`` and ``max_tokens``. + +.. option:: retry_policy: dict, null + + Agent Spec retry settings for recoverable LLM-request failures. + +**Examples** + +.. literalinclude:: ../code_examples/howto_llm_from_different_providers.py + :language: python + :start-after: .. dbmsvectorchain-start + :end-before: .. dbmsvectorchain-end + + OciGenAiConfig ============== diff --git a/pyagentspec/src/pyagentspec/_component_registry.py b/pyagentspec/src/pyagentspec/_component_registry.py index be871a30..783191a4 100644 --- a/pyagentspec/src/pyagentspec/_component_registry.py +++ b/pyagentspec/src/pyagentspec/_component_registry.py @@ -44,6 +44,7 @@ ToolNode, ) from pyagentspec.llms import ( + DbmsVectorChainLlmConfig, GeminiConfig, OciGenAiConfig, OllamaConfig, @@ -105,6 +106,7 @@ "ControlFlowEdge": ControlFlowEdge, "DataFlowEdge": DataFlowEdge, "Datastore": Datastore, + "DbmsVectorChainLlmConfig": DbmsVectorChainLlmConfig, "EndNode": EndNode, "Flow": Flow, "FlowNode": FlowNode, diff --git a/pyagentspec/src/pyagentspec/llms/__init__.py b/pyagentspec/src/pyagentspec/llms/__init__.py index c7170151..d7aba5a4 100644 --- a/pyagentspec/src/pyagentspec/llms/__init__.py +++ b/pyagentspec/src/pyagentspec/llms/__init__.py @@ -6,6 +6,7 @@ """Define LLM configuration abstractions and provider-specific implementations.""" +from .dbmsvectorchainllmconfig import DbmsVectorChainLlmConfig from .geminiconfig import GeminiConfig from .llmconfig import LlmConfig from .llmgenerationconfig import LlmGenerationConfig @@ -16,6 +17,7 @@ from .vllmconfig import VllmConfig __all__ = [ + "DbmsVectorChainLlmConfig", "LlmConfig", "LlmGenerationConfig", "GeminiConfig", diff --git a/pyagentspec/src/pyagentspec/llms/dbmsvectorchainllmconfig.py b/pyagentspec/src/pyagentspec/llms/dbmsvectorchainllmconfig.py new file mode 100644 index 00000000..343f928f --- /dev/null +++ b/pyagentspec/src/pyagentspec/llms/dbmsvectorchainllmconfig.py @@ -0,0 +1,90 @@ +# 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. + +"""Define the LLM configuration used by Oracle Database DBMS_VECTOR_CHAIN.""" + +from typing import Literal, Optional + +from pydantic import Field +from pydantic.json_schema import SkipJsonSchema +from typing_extensions import Self + +from pyagentspec.datastores.oracle import OracleDatabaseConnectionConfig +from pyagentspec.llms.llmconfig import LlmConfig +from pyagentspec.sensitive_field import SensitiveField +from pyagentspec.validation_helpers import model_validator_with_error_accumulation +from pyagentspec.versioning import AgentSpecVersionEnum + + +class DbmsVectorChainLlmConfig(LlmConfig): + """Configure an LLM executed by Oracle Database through DBMS_VECTOR_CHAIN.""" + + min_agentspec_version: SkipJsonSchema[AgentSpecVersionEnum] = Field( + default=AgentSpecVersionEnum.v26_2_0, + init=False, + exclude=True, + ) + + model_id: str = Field(min_length=1) + """Model identifier used by Oracle Database for the provider request.""" + + provider: str = Field(min_length=1) + """Provider of the model, such as ``openai`` or ``cohere``.""" + + url: str = Field(min_length=1) + """Provider API endpoint used by Oracle Database.""" + + host: Optional[Literal["local"]] = None + """Set to ``local`` for a local OpenAI or Ollama provider without a database credential.""" + + credential_name: Optional[str] = Field(default=None, min_length=1) + """Name of a credential managed by Oracle Database.""" + + transfer_timeout: Optional[int] = Field(default=None, ge=0) + """Maximum transfer time in seconds for the provider request.""" + + connection_config: Optional[OracleDatabaseConnectionConfig] = None + """Optional Oracle Database connection configuration.""" + + # Authentication is configured through the database credential referenced by + # ``credential_name``. + api_provider: SkipJsonSchema[Optional[str]] = Field(default=None, exclude=True) + api_type: SkipJsonSchema[Optional[str]] = Field(default=None, exclude=True) + api_key: SkipJsonSchema[SensitiveField[Optional[str]]] = Field(default=None, exclude=True) + + @model_validator_with_error_accumulation + def _validate_credential_name(self) -> Self: + if self.host is not None and self.credential_name is not None: + raise ValueError("`host` and `credential_name` cannot both be specified.") + if self.host is None and self.credential_name is None: + raise ValueError("`credential_name` is required unless `host` is 'local'.") + if self.host is not None and self.provider not in {"openai", "ollama"}: + raise ValueError("`host` is supported only for the `openai` and `ollama` providers.") + return self + + @model_validator_with_error_accumulation + def _validate_unsupported_inherited_fields(self) -> Self: + unsupported_fields = { + "api_provider": self.api_provider, + "api_type": self.api_type, + "api_key": self.api_key, + } + configured_fields = [ + field_name for field_name, value in unsupported_fields.items() if value is not None + ] + if configured_fields: + raise ValueError( + "DbmsVectorChainLlmConfig does not support inherited field(s): " + + ", ".join(configured_fields) + ) + return self + + def _versioned_model_fields_to_exclude( + self, agentspec_version: AgentSpecVersionEnum + ) -> set[str]: + fields_to_exclude = super()._versioned_model_fields_to_exclude(agentspec_version) + fields_to_exclude.update({"api_provider", "api_type", "api_key"}) + return fields_to_exclude diff --git a/pyagentspec/tests/serialization/test_dbms_vector_chain_llm_config.py b/pyagentspec/tests/serialization/test_dbms_vector_chain_llm_config.py new file mode 100644 index 00000000..26926bc1 --- /dev/null +++ b/pyagentspec/tests/serialization/test_dbms_vector_chain_llm_config.py @@ -0,0 +1,84 @@ +# 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. + +import json + +import pytest + +from pyagentspec.llms import DbmsVectorChainLlmConfig, LlmGenerationConfig +from pyagentspec.retrypolicy import RetryPolicy +from pyagentspec.serialization import AgentSpecDeserializer, AgentSpecSerializer +from pyagentspec.versioning import AgentSpecVersionEnum + + +def test_dbms_vector_chain_llm_config_serializes() -> None: + config = DbmsVectorChainLlmConfig( + id="database-llm", + name="database-openai", + model_id="gpt-4o", + provider="openai", + url="https://api.openai.com/v1/chat/completions", + credential_name="MY_OPENAI_CREDENTIAL", + transfer_timeout=120, + default_generation_parameters=LlmGenerationConfig(temperature=0.3), + retry_policy=RetryPolicy(max_attempts=3), + ) + + serialized = json.loads(AgentSpecSerializer().to_json(config)) + + assert serialized["component_type"] == "DbmsVectorChainLlmConfig" + assert serialized["model_id"] == "gpt-4o" + assert "model" not in serialized + assert serialized["provider"] == "openai" + assert serialized["url"] == "https://api.openai.com/v1/chat/completions" + assert serialized["host"] is None + assert serialized["credential_name"] == "MY_OPENAI_CREDENTIAL" + assert serialized["transfer_timeout"] == 120 + assert serialized["connection_config"] is None + assert serialized["default_generation_parameters"]["temperature"] == 0.3 + assert "api_key" not in serialized + assert "api_provider" not in serialized + assert "api_type" not in serialized + assert serialized["retry_policy"]["max_attempts"] == 3 + + +def test_dbms_vector_chain_llm_config_round_trips() -> None: + config = DbmsVectorChainLlmConfig( + id="database-llm", + name="database-openai", + model_id="gpt-4o", + provider="openai", + url="https://api.openai.com/v1/chat/completions", + credential_name="MY_OPENAI_CREDENTIAL", + ) + serializer = AgentSpecSerializer() + + serialized = serializer.to_yaml(config) + deserialized = AgentSpecDeserializer().from_yaml(serialized) + + assert "model_id: gpt-4o" in serialized + assert "model:" not in serialized + assert deserialized == config + assert deserialized.model_id == "gpt-4o" + + +def test_dbms_vector_chain_llm_config_requires_v26_2() -> None: + config = DbmsVectorChainLlmConfig( + id="database-llm", + name="database-openai", + model_id="gpt-4o", + provider="openai", + url="https://api.openai.com/v1/chat/completions", + credential_name="MY_OPENAI_CREDENTIAL", + ) + + with pytest.raises(ValueError, match="Invalid agentspec_version"): + AgentSpecSerializer().to_dict(config, agentspec_version=AgentSpecVersionEnum.v26_1_2) + + dumped = AgentSpecSerializer().to_dict(config) + dumped["agentspec_version"] = AgentSpecVersionEnum.v26_1_2.value + with pytest.raises(ValueError, match="Invalid agentspec_version"): + AgentSpecDeserializer().from_dict(dumped) diff --git a/pyagentspec/tests/test_schema_generation.py b/pyagentspec/tests/test_schema_generation.py index 05bb0ce9..4ddb9340 100644 --- a/pyagentspec/tests/test_schema_generation.py +++ b/pyagentspec/tests/test_schema_generation.py @@ -19,6 +19,7 @@ from pyagentspec.flows.flow import Flow from pyagentspec.flows.node import Node from pyagentspec.flows.nodes import LlmNode +from pyagentspec.llms import DbmsVectorChainLlmConfig from pyagentspec.llms.llmconfig import LlmConfig from pyagentspec.llms.vllmconfig import VllmConfig from pyagentspec.serialization import AgentSpecSerializer @@ -43,6 +44,24 @@ def test_llmconfig_schema_contains_all_concrete_llmconfig_types() -> None: assert len(schema["anyOf"]) == len(llm_config_subtypes) + 2 +def test_dbms_vector_chain_llm_config_is_registered_in_schema() -> None: + assert BUILTIN_CLASS_MAP["DbmsVectorChainLlmConfig"] is DbmsVectorChainLlmConfig + + schema = DbmsVectorChainLlmConfig.model_json_schema(only_core_components=True) + config_schema = schema["$defs"]["BaseDbmsVectorChainLlmConfig"] + + assert "model_id" in config_schema["properties"] + assert "model" not in config_schema["properties"] + assert "api_provider" not in config_schema["properties"] + assert "api_type" not in config_schema["properties"] + assert "api_key" not in config_schema["properties"] + assert "retry_policy" in config_schema["properties"] + assert {"$ref": "#/$defs/OracleDatabaseConnectionConfig"} in config_schema["properties"][ + "connection_config" + ]["anyOf"] + assert config_schema["properties"]["host"]["anyOf"][0]["const"] == "local" + + def test_llmnode_schema_contains_all_concrete_llmconfig_types() -> None: schema = LlmNode.model_json_schema(only_core_components=True) # because all concrete components can be either serialized as a reference or as their properties diff --git a/pyagentspec/tests/validation/test_dbms_vector_chain_llm_config.py b/pyagentspec/tests/validation/test_dbms_vector_chain_llm_config.py new file mode 100644 index 00000000..4925fc7a --- /dev/null +++ b/pyagentspec/tests/validation/test_dbms_vector_chain_llm_config.py @@ -0,0 +1,133 @@ +# Copyright © 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +from typing import Any + +import pytest +from pydantic import ValidationError + +from pyagentspec.datastores.oracle import TlsOracleDatabaseConnectionConfig +from pyagentspec.llms import DbmsVectorChainLlmConfig + + +def valid_config_kwargs() -> dict[str, Any]: + return { + "name": "database-openai", + "model_id": "gpt-4o", + "provider": "openai", + "url": "https://api.openai.com/v1/chat/completions", + "credential_name": "MY_OPENAI_CREDENTIAL", + } + + +@pytest.mark.parametrize( + "field_name", + ["model_id", "provider", "url"], +) +def test_dbms_vector_chain_llm_config_rejects_empty_required_fields(field_name: str) -> None: + kwargs = valid_config_kwargs() + kwargs[field_name] = "" + + with pytest.raises(ValidationError, match=field_name): + DbmsVectorChainLlmConfig(**kwargs) + + +def test_dbms_vector_chain_llm_config_rejects_model_alias() -> None: + kwargs = valid_config_kwargs() + kwargs["model"] = kwargs.pop("model_id") + + with pytest.raises(ValidationError, match="model_id"): + DbmsVectorChainLlmConfig(**kwargs) + + +def test_dbms_vector_chain_llm_config_requires_credential_without_host() -> None: + kwargs = valid_config_kwargs() + kwargs.pop("credential_name") + + with pytest.raises(ValidationError, match="credential_name"): + DbmsVectorChainLlmConfig(**kwargs) + + +@pytest.mark.parametrize("provider", ["openai", "ollama"]) +def test_dbms_vector_chain_llm_config_allows_local_host_without_credential( + provider: str, +) -> None: + kwargs = valid_config_kwargs() + kwargs["provider"] = provider + kwargs["host"] = "local" + kwargs.pop("credential_name") + + config = DbmsVectorChainLlmConfig(**kwargs) + + assert config.credential_name is None + + +def test_dbms_vector_chain_llm_config_rejects_host_with_credential() -> None: + kwargs = valid_config_kwargs() + kwargs["host"] = "local" + + with pytest.raises(ValidationError, match="host.*credential_name"): + DbmsVectorChainLlmConfig(**kwargs) + + +def test_dbms_vector_chain_llm_config_rejects_nonlocal_host() -> None: + kwargs = valid_config_kwargs() + kwargs.pop("credential_name") + kwargs["host"] = "public" + + with pytest.raises(ValidationError, match="host"): + DbmsVectorChainLlmConfig(**kwargs) + + +def test_dbms_vector_chain_llm_config_rejects_local_host_for_unsupported_provider() -> None: + kwargs = valid_config_kwargs() + kwargs["provider"] = "cohere" + kwargs["host"] = "local" + kwargs.pop("credential_name") + + with pytest.raises(ValidationError, match="openai.*ollama"): + DbmsVectorChainLlmConfig(**kwargs) + + +def test_dbms_vector_chain_llm_config_rejects_negative_transfer_timeout() -> None: + kwargs = valid_config_kwargs() + kwargs["transfer_timeout"] = -1 + + with pytest.raises(ValidationError, match="transfer_timeout"): + DbmsVectorChainLlmConfig(**kwargs) + + +def test_dbms_vector_chain_llm_config_accepts_optional_connection_config() -> None: + kwargs = valid_config_kwargs() + connection_config = TlsOracleDatabaseConnectionConfig( + name="database-connection", + user="test-user", + password="test-password", # nosec B106 + dsn="test-dsn", + ) + kwargs["connection_config"] = connection_config + + config = DbmsVectorChainLlmConfig(**kwargs) + + assert config.connection_config is connection_config + + +@pytest.mark.parametrize( + "field_name,value", + [ + ("api_provider", "openai"), + ("api_type", "chat_completions"), + ("api_key", "not-a-real-key"), + ], +) +def test_dbms_vector_chain_llm_config_rejects_unsupported_inherited_fields( + field_name: str, value: Any +) -> None: + kwargs = valid_config_kwargs() + kwargs[field_name] = value + + with pytest.raises(ValidationError, match=field_name): + DbmsVectorChainLlmConfig(**kwargs) From 963affc3f9b0b503931a9213d0c524c558a93151 Mon Sep 17 00:00:00 2001 From: Paul Cayet Date: Tue, 25 Aug 2026 11:44:05 +0200 Subject: [PATCH 03/17] Release 26.3.0 (copy) * bump versions * bump version 26.3.0 * Remove CrewAI adapter * open new dev cycle * Revert "Remove CrewAI adapter" This reverts commit 5ca91970c8cc84e2922694b301c1a4324a077274. --------- Co-authored-by: Son Le Co-authored-by: Damien Hilloulin --- VERSION | 2 +- docs/pyagentspec/source/agentspec/index.rst | 6 +- .../json_spec/agentspec_json_spec_26_3_0.json | 9417 ++++++++++++++++ .../json_spec/agentspec_json_spec_26_4_0.json | 9418 +++++++++++++++++ .../source/agentspec/language_spec_26_3_0.rst | 3477 ++++++ .../agentspec/language_spec_nightly.rst | 6 +- docs/pyagentspec/source/changelog.rst | 23 + docs/pyagentspec/source/docs_home.rst | 2 +- docs/pyagentspec/source/oracle_select_ai.rst | 22 + pyagentspec/constraints/constraints.txt | 10 +- pyagentspec/constraints/constraints_dev.txt | 10 +- .../constraints/constraints_v26.3.0.txt | 38 + pyagentspec/pyproject.toml | 4 +- pyagentspec/requirements-dev-common.txt | 2 +- pyagentspec/setup.py | 38 +- .../adapters/langgraph/_langgraphconverter.py | 15 +- .../pyagentspec/adapters/langgraph/tracing.py | 4 +- .../llms/dbmsvectorchainllmconfig.py | 2 +- pyagentspec/src/pyagentspec/mcp/tools.py | 8 +- pyagentspec/src/pyagentspec/versioning.py | 7 +- .../tests/serialization/test_mcp_tools.py | 4 +- .../tests/serialization/test_serialization.py | 8 +- pyagentspec/tests/test_versioning.py | 8 +- 23 files changed, 22471 insertions(+), 60 deletions(-) create mode 100644 docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_3_0.json create mode 100644 docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_4_0.json create mode 100644 docs/pyagentspec/source/agentspec/language_spec_26_3_0.rst create mode 100644 docs/pyagentspec/source/oracle_select_ai.rst create mode 100644 pyagentspec/constraints/constraints_v26.3.0.txt diff --git a/VERSION b/VERSION index ffd7385f..828febb2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -26.2.0.dev7 +26.4.0.dev0 diff --git a/docs/pyagentspec/source/agentspec/index.rst b/docs/pyagentspec/source/agentspec/index.rst index 40192be6..628ed2f1 100644 --- a/docs/pyagentspec/source/agentspec/index.rst +++ b/docs/pyagentspec/source/agentspec/index.rst @@ -25,7 +25,8 @@ You can download the Agent Spec technical report at the following :download:`lin :maxdepth: 2 Introduction, motivation & vision - Language specification (v26.1.2 latest) + Language specification (v26.3.0 latest) + Language specification (v26.1.2) Language specification (v26.1.0) Language specification (v25.4.1) Positioning in the agentic ecosystem @@ -39,7 +40,8 @@ You can download the Agent Spec technical report at the following :download:`lin Introduction, motivation & vision Language specification (under development) - Language specification (v26.1.2 latest release) + Language specification (v26.3.0 latest release) + Language specification (v26.1.2) Language specification (v26.1.0) Language specification (v25.4.1) Positioning in the agentic ecosystem diff --git a/docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_3_0.json b/docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_3_0.json new file mode 100644 index 00000000..4d566690 --- /dev/null +++ b/docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_3_0.json @@ -0,0 +1,9417 @@ +{ + "$defs": { + "A2AAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseA2AAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "A2AConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseA2AConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "A2ASessionParameters": { + "description": "Class to specify parameters of the A2A session.", + "properties": { + "timeout": { + "default": 60.0, + "title": "Timeout", + "type": "number" + }, + "poll_interval": { + "default": 2.0, + "title": "Poll Interval", + "type": "number" + }, + "max_retries": { + "default": 5, + "title": "Max Retries", + "type": "integer" + } + }, + "title": "A2ASessionParameters", + "type": "object" + }, + "Agent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "AgentNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgentNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "AgentSpecVersionEnum": { + "description": "An Enumeration for different versions of Agent Spec.", + "enum": [ + "25.4.1", + "25.4.2", + "26.1.0", + "26.1.2", + "26.3.0" + ], + "title": "AgentSpecVersionEnum", + "type": "string" + }, + "AgentSpecializationParameters": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgentSpecializationParameters" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "AgenticComponent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgenticComponent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ApiNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseApiNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "AuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "BranchingNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseBranchingNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "BuiltinTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseBuiltinTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "CatchExceptionNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseCatchExceptionNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ClientTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseClientTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ClientTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseClientTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ComponentWithIO": { + "anyOf": [ + { + "$ref": "#/$defs/BaseComponentWithIO" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ControlFlowEdge": { + "anyOf": [ + { + "$ref": "#/$defs/BaseControlFlowEdge" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ConversationSummarizationTransform": { + "anyOf": [ + { + "$ref": "#/$defs/BaseConversationSummarizationTransform" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "DataFlowEdge": { + "anyOf": [ + { + "$ref": "#/$defs/BaseDataFlowEdge" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "Datastore": { + "anyOf": [ + { + "$ref": "#/$defs/BaseDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "DbmsVectorChainLlmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseDbmsVectorChainLlmConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "EndNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseEndNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "Flow": { + "anyOf": [ + { + "$ref": "#/$defs/BaseFlow" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "FlowNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseFlowNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "GeminiAIStudioAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiAIStudioAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "GeminiAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "GeminiConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "GeminiVertexAIAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiVertexAIAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "HandoffMode": { + "description": "Controls how agents in a Swarm may delegate work to one another.\n\nThis setting determines whether an agent is equipped with:\n\n * *send_message* \u2014 a tool for asking another agent to perform a sub-task and reply back.\n\n * *handoff_conversation* \u2014 a tool for transferring the full user\u2013agent conversation to another agent.\n\nDepending on the selected mode, agents have different capabilities for delegation and collaboration.", + "enum": [ + "always", + "never", + "optional" + ], + "title": "HandoffMode", + "type": "string" + }, + "InMemoryCollectionDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/BaseInMemoryCollectionDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "InputMessageNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseInputMessageNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "LlmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseLlmConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "LlmGenerationConfig": { + "additionalProperties": true, + "description": "A configuration object defining LLM generation parameters.\n\nParameters include number of tokens, sampling parameters, etc.", + "properties": { + "max_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Tokens" + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Temperature" + }, + "top_p": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Top P" + } + }, + "title": "LlmGenerationConfig", + "type": "object" + }, + "LlmNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseLlmNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "MCPTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMCPTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "MCPToolBox": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMCPToolBox" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "MCPToolSpec": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMCPToolSpec" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "MTlsOracleDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ManagerWorkers": { + "anyOf": [ + { + "$ref": "#/$defs/BaseManagerWorkers" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "MapNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMapNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "MessageSummarizationTransform": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMessageSummarizationTransform" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "MessageTransform": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMessageTransform" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ModelProvider": { + "description": "Provider of the model. It is used to ensure the requests to this model respect\nthe format expected by the provider.", + "enum": [ + "COHERE", + "GROK", + "META", + "OTHER", + "XAI" + ], + "title": "ModelProvider", + "type": "string" + }, + "Node": { + "anyOf": [ + { + "$ref": "#/$defs/BaseNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OAuthClientConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOAuthClientConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OAuthEndpoints": { + "description": "Explicit OAuth endpoint configuration.\n\nUse this component when endpoint discovery is not available or not desired.\nThis groups the relevant endpoints required to execute OAuth authorization\ncode flows and token refresh.", + "properties": { + "authorization_endpoint": { + "title": "Authorization Endpoint", + "type": "string" + }, + "token_endpoint": { + "title": "Token Endpoint", + "type": "string" + }, + "refresh_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Refresh Endpoint" + }, + "revocation_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Revocation Endpoint" + }, + "userinfo_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Userinfo Endpoint" + } + }, + "required": [ + "authorization_endpoint", + "token_endpoint" + ], + "title": "OAuthEndpoints", + "type": "object" + }, + "OciAPIType": { + "description": "Enumeration of API Types.", + "enum": [ + "oci", + "openai_chat_completions", + "openai_responses" + ], + "title": "OciAPIType", + "type": "string" + }, + "OciAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OciClientConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OciClientConfigWithApiKey": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithApiKey" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OciClientConfigWithInstancePrincipal": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithInstancePrincipal" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OciClientConfigWithResourcePrincipal": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithResourcePrincipal" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OciClientConfigWithSecurityToken": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithSecurityToken" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OciGenAiConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciGenAiConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OllamaConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOllamaConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OpenAIAPIType": { + "description": "Enumeration of OpenAI API Types.\n\nchat completions: Chat Completions API\nresponses: Responses API", + "enum": [ + "chat_completions", + "responses" + ], + "title": "OpenAIAPIType", + "type": "string" + }, + "OpenAiCompatibleConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOpenAiCompatibleConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OpenAiConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOpenAiConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OracleDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OracleDatabaseDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOracleDatabaseDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OutputMessageNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOutputMessageNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "PKCEMethod": { + "enum": [ + "S256", + "plain" + ], + "title": "PKCEMethod", + "type": "string" + }, + "PKCEPolicy": { + "description": "Policy configuration for Proof Key for Code Exchange (PKCE).\n\nPKCE mitigates authorization code interception and injection attacks in\nauthorization code flows. Some protocols (such as MCP OAuth) require PKCE.", + "properties": { + "required": { + "default": true, + "title": "Required", + "type": "boolean" + }, + "method": { + "$ref": "#/$defs/PKCEMethod", + "default": "S256" + } + }, + "title": "PKCEPolicy", + "type": "object" + }, + "ParallelFlowNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseParallelFlowNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ParallelMapNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseParallelMapNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "PostgresDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BasePostgresDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "PostgresDatabaseDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/BasePostgresDatabaseDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "Property": { + "description": "This object must be a valid JSON Schema", + "type": "object" + }, + "ReductionMethod": { + "description": "Enumerator for the types of reduction available in the MapNode.", + "enum": [ + "append", + "average", + "max", + "min", + "sum" + ], + "title": "ReductionMethod", + "type": "string" + }, + "RemoteAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseRemoteAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "RemoteTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseRemoteTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "RemoteTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseRemoteTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "RetryPolicy": { + "additionalProperties": false, + "properties": { + "max_attempts": { + "default": 2, + "minimum": 0, + "title": "Max Attempts", + "type": "integer" + }, + "request_timeout": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Request Timeout" + }, + "initial_retry_delay": { + "default": 1.0, + "minimum": 0, + "title": "Initial Retry Delay", + "type": "number" + }, + "max_retry_delay": { + "default": 8.0, + "minimum": 0, + "title": "Max Retry Delay", + "type": "number" + }, + "backoff_factor": { + "default": 2.0, + "exclusiveMinimum": 0, + "title": "Backoff Factor", + "type": "number" + }, + "jitter": { + "anyOf": [ + { + "enum": [ + "decorrelated", + "equal", + "full", + "full_and_equal_for_throttle" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": "full_and_equal_for_throttle", + "title": "Jitter" + }, + "service_error_retry_on_any_5xx": { + "default": true, + "title": "Service Error Retry On Any 5Xx", + "type": "boolean" + }, + "recoverable_statuses": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Recoverable Statuses", + "type": "object" + } + }, + "title": "RetryPolicy", + "type": "object" + }, + "SSETransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSSETransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "SSEmTLSTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSSEmTLSTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ScopePolicy": { + "enum": [ + "fixed", + "use_challenge_or_supported" + ], + "title": "ScopePolicy", + "type": "string" + }, + "ServerTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseServerTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ServingMode": { + "description": "Serving mode to use for the GenAI service", + "enum": [ + "DEDICATED", + "ON_DEMAND" + ], + "title": "ServingMode", + "type": "string" + }, + "SessionParameters": { + "description": "Class to specify parameters of the MCP client session.", + "properties": { + "read_timeout_seconds": { + "default": 60.0, + "title": "Read Timeout Seconds", + "type": "number" + } + }, + "title": "SessionParameters", + "type": "object" + }, + "SpecializedAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSpecializedAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "StartNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStartNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "StdioTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStdioTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "StreamableHTTPTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStreamableHTTPTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "StreamableHTTPmTLSTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStreamableHTTPmTLSTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "Swarm": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSwarm" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "TlsOracleDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "TlsPostgresDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseTlsPostgresDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "Tool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ToolBox": { + "anyOf": [ + { + "$ref": "#/$defs/BaseToolBox" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ToolNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseToolNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "VllmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseVllmConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "BaseA2AAgent": { + "additionalProperties": false, + "description": "Component which communicates with a remote server agent using the A2A Protocol.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "agent_url": { + "title": "Agent Url", + "type": "string" + }, + "connection_config": { + "$ref": "#/$defs/A2AConnectionConfig" + }, + "session_parameters": { + "$ref": "#/$defs/A2ASessionParameters", + "default": { + "timeout": 60.0, + "poll_interval": 2.0, + "max_retries": 5 + } + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "A2AAgent" + } + }, + "required": [ + "agent_url", + "connection_config", + "name" + ], + "title": "A2AAgent", + "type": "object", + "x-abstract-component": false + }, + "BaseA2AConnectionConfig": { + "additionalProperties": false, + "description": "Class to specify configuration settings for establishing a connection in A2A communication.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "timeout": { + "default": 600.0, + "title": "Timeout", + "type": "number" + }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Headers" + }, + "verify": { + "default": true, + "title": "Verify", + "type": "boolean" + }, + "key_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key File" + }, + "cert_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cert File" + }, + "ssl_ca_cert": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ssl Ca Cert" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "A2AConnectionConfig" + } + }, + "required": [ + "name" + ], + "title": "A2AConnectionConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseAgent": { + "additionalProperties": false, + "description": "An agent is a component that can do several rounds of conversation to solve a task.\n\nIt can be executed by itself, or be executed in a flow using an AgentNode.\n\n\nExamples\n--------\n>>> from pyagentspec.agent import Agent\n>>> from pyagentspec.property import Property\n>>> expertise_property=Property(\n... json_schema={\"title\": \"domain_of_expertise\", \"type\": \"string\"}\n... )\n>>> system_prompt = '''You are an expert in {{domain_of_expertise}}.\n... Please help the users with their requests.'''\n>>> agent = Agent(\n... name=\"Adaptive expert agent\",\n... system_prompt=system_prompt,\n... llm_config=llm_config,\n... inputs=[expertise_property],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "llm_config": { + "$ref": "#/$defs/LlmConfig" + }, + "system_prompt": { + "title": "System Prompt", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/$defs/Tool" + }, + "title": "Tools", + "type": "array" + }, + "toolboxes": { + "items": { + "$ref": "#/$defs/ToolBox" + }, + "title": "Toolboxes", + "type": "array" + }, + "human_in_the_loop": { + "default": true, + "title": "Human In The Loop", + "type": "boolean" + }, + "transforms": { + "items": { + "$ref": "#/$defs/MessageTransform" + }, + "title": "Transforms", + "type": "array" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "Agent" + } + }, + "required": [ + "llm_config", + "name", + "system_prompt" + ], + "title": "Agent", + "type": "object", + "x-abstract-component": false + }, + "BaseAgentNode": { + "additionalProperties": false, + "description": "The agent execution node is a node that will execute an agent as part of a flow.\n\nIf branches are configured, the agent will be prompted to select a branch before the agent node\ncompletes to transition to another node of the Flow.\n\n- **Inputs**\n Inferred from the definition of the agent to execute.\n- **Outputs**\n Inferred from the definition of the agent to execute.\n- **Branches**\n One, the default next.\n\nExamples\n--------\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.agent import Agent\n>>> from pyagentspec.property import Property\n>>> from pyagentspec.flows.nodes import AgentNode, StartNode, EndNode\n>>> from pyagentspec.tools import ServerTool\n>>> query_property = Property(json_schema={\"title\": \"query\", \"type\": \"string\"})\n>>> search_results_property = Property(\n... json_schema={\"title\": \"search_results\", \"type\": \"array\", \"items\": {\"type\": \"string\"}}\n... )\n>>> search_tool = ServerTool(\n... name=\"search_tool\",\n... description=(\n... \"This tool runs a web search with the given query \"\n... \"and returns the most relevant results\"\n... ),\n... inputs=[query_property],\n... outputs=[search_results_property],\n... )\n>>> agent = Agent(\n... name=\"Search agent\",\n... llm_config=llm_config,\n... system_prompt=(\n... \"Your task is to gather the required information for the user: {{query}}\"\n... ),\n... tools=[search_tool],\n... outputs=[search_results_property],\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[query_property])\n>>> end_node = EndNode(name=\"end\", outputs=[search_results_property])\n>>> agent_node = AgentNode(\n... name=\"Search agent node\",\n... agent=agent,\n... )\n>>> flow = Flow(\n... name=\"Search agent flow\",\n... start_node=start_node,\n... nodes=[start_node, agent_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_agent\", from_node=start_node, to_node=agent_node),\n... ControlFlowEdge(name=\"agent_to_end\", from_node=agent_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"query_edge\",\n... source_node=start_node,\n... source_output=\"query\",\n... destination_node=agent_node,\n... destination_input=\"query\",\n... ),\n... DataFlowEdge(\n... name=\"search_results_edge\",\n... source_node=agent_node,\n... source_output=\"search_results\",\n... destination_node=end_node,\n... destination_input=\"search_results\"\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "agent": { + "$ref": "#/$defs/AgenticComponent" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "AgentNode" + } + }, + "required": [ + "agent", + "name" + ], + "title": "AgentNode", + "type": "object", + "x-abstract-component": false + }, + "BaseAgentSpecializationParameters": { + "additionalProperties": false, + "description": "Parameters used to specialize an agent for a certain goal or task.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "additional_instructions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Additional Instructions" + }, + "additional_tools": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Additional Tools" + }, + "human_in_the_loop": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Human In The Loop" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "AgentSpecializationParameters" + } + }, + "required": [ + "name" + ], + "title": "AgentSpecializationParameters", + "type": "object", + "x-abstract-component": false + }, + "BaseAgenticComponent": { + "anyOf": [ + { + "$ref": "#/$defs/A2AAgent" + }, + { + "$ref": "#/$defs/Agent" + }, + { + "$ref": "#/$defs/Flow" + }, + { + "$ref": "#/$defs/ManagerWorkers" + }, + { + "$ref": "#/$defs/OciAgent" + }, + { + "$ref": "#/$defs/RemoteAgent" + }, + { + "$ref": "#/$defs/SpecializedAgent" + }, + { + "$ref": "#/$defs/Swarm" + } + ], + "x-abstract-component": true + }, + "BaseApiNode": { + "additionalProperties": false, + "description": "Make an API call.\n\nThis node is intended to be a part of a Flow.\n\n- **Inputs**\n Inferred from the json spec retrieved from API Spec URI, if available and reachable.\n Otherwise, users have to manually specify them.\n- **Outputs**\n Inferred from the json spec retrieved from API Spec URI, if available and reachable.\n Otherwise, users should manually specify them.\n\n If None is given, ``pyagentspec`` infers a generic property of any type named ``response``.\n- **Branches**\n One, the default next.\n\n\nExamples\n--------\n>>> from pyagentspec.flows.nodes import ApiNode\n>>> from pyagentspec.property import Property\n>>> weather_result_property = Property(\n... json_schema={\n... \"title\": \"zurich_weather\",\n... \"type\": \"object\",\n... \"properties\": {\n... \"temperature\": {\n... \"type\": \"number\",\n... \"description\": \"Temperature in celsius degrees\",\n... },\n... \"weather\": {\"type\": \"string\"}\n... },\n... }\n... )\n>>> call_current_weather_step = ApiNode(\n... name=\"Weather API call node\",\n... url=\"https://example.com/weather\",\n... http_method = \"GET\",\n... query_params={\n... \"location\": \"zurich\",\n... },\n... outputs=[weather_result_property]\n... )\n>>>\n>>> item_id_property = Property(\n... json_schema={\"title\": \"item_id\", \"type\": \"string\"}\n... )\n>>> order_id_property = Property(\n... json_schema={\"title\": \"order_id\", \"type\": \"string\"}\n... )\n>>> store_id_property = Property(\n... json_schema={\"title\": \"store_id\", \"type\": \"string\"}\n... )\n>>> session_id_property = Property(\n... json_schema={\"title\": \"session_id\", \"type\": \"string\"}\n... )\n>>> create_order_step = ApiNode(\n... name=\"Orders api call node\",\n... url=\"https://example.com/orders/{{ order_id }}\",\n... http_method=\"POST\",\n... # sending an object which will automatically be transformed into JSON\n... data={\n... # define a static body parameter\n... \"topic_id\": 12345,\n... # define a templated body parameter.\n... # The value for {{ item_id }} will be taken from the IO system at runtime\n... \"item_id\": \"{{ item_id }}\",\n... },\n... query_params={\n... # provide one templated query parameter called \"store_id\"\n... # which will take its value from the IO system from key \"store_id\"\n... \"store_id\": \"{{ store_id }}\",\n... },\n... headers={\n... # set header session_id. the value is coming from the IO system\n... \"session_id\": \"{{ session_id }}\",\n... },\n... inputs=[item_id_property, order_id_property, store_id_property, session_id_property],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "url": { + "title": "Url", + "type": "string" + }, + "http_method": { + "title": "Http Method", + "type": "string" + }, + "api_spec_uri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Spec Uri" + }, + "data": { + "anyOf": [ + { + "type": "string" + }, + { + "format": "binary", + "type": "string" + }, + {} + ], + "title": "Data" + }, + "query_params": { + "additionalProperties": true, + "title": "Query Params", + "type": "object" + }, + "headers": { + "additionalProperties": true, + "title": "Headers", + "type": "object" + }, + "sensitive_headers": { + "additionalProperties": true, + "title": "Sensitive Headers", + "type": "object" + }, + "url_allow_list": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url Allow List" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ApiNode" + } + }, + "required": [ + "http_method", + "name", + "url" + ], + "title": "ApiNode", + "type": "object", + "x-abstract-component": false + }, + "BaseAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/OAuthConfig" + } + ], + "x-abstract-component": true + }, + "BaseBranchingNode": { + "additionalProperties": false, + "description": "Select the next node to transition to based on a mapping.\n\nThe input is used as key for the mapping. If the input does not correspond to any of the keys\nof the mapping the branch 'default' will be selected. This node is intended to be a part of a\nFlow.\n\n- **Inputs**\n The input value that should be used as key for the mapping.\n\n If None is given, ``pyagentspec`` infers a string property named ``branching_mapping_key``.\n- **Outputs**\n None.\n- **Branches**\n One for each value in the mapping, plus a branch called ``default``,\n which is the branch taken by the flow when mapping fails\n (i.e., the input does not match any key in the mapping).\n\nExamples\n--------\n>>> from pyagentspec.agent import Agent\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import AgentNode, BranchingNode, StartNode, EndNode\n>>> from pyagentspec.property import Property\n>>> CORRECT_PASSWORD_BRANCH = \"PASSWORD_OK\"\n>>> password_property = Property(\n... json_schema={\"title\": \"password\", \"type\": \"string\"}\n... )\n>>> agent = Agent(\n... name=\"User input agent\",\n... llm_config=llm_config,\n... system_prompt=(\n... \"Your task is to ask the password to the user. \"\n... \"Once you get it, submit it and end.\"\n... ),\n... outputs=[password_property],\n... )\n>>> start_node = StartNode(name=\"start\")\n>>> access_granted_end_node = EndNode(\n... name=\"access granted end\", branch_name=\"ACCESS_GRANTED\"\n... )\n>>> access_denied_end_node = EndNode(\n... name=\"access denied end\", branch_name=\"ACCESS_DENIED\"\n... )\n>>> branching_node = BranchingNode(\n... name=\"password check\",\n... mapping={\"123456\": CORRECT_PASSWORD_BRANCH},\n... inputs=[password_property]\n... )\n>>> agent_node = AgentNode(\n... name=\"User input agent node\",\n... agent=agent,\n... )\n>>> assistant = Flow(\n... name=\"Check access flow\",\n... start_node=start_node,\n... nodes=[\n... start_node,\n... agent_node,\n... branching_node,\n... access_granted_end_node,\n... access_denied_end_node\n... ],\n... control_flow_connections=[\n... ControlFlowEdge(\n... name=\"start_to_agent\",\n... from_node=start_node,\n... to_node=agent_node\n... ),\n... ControlFlowEdge(\n... name=\"agent_to_branching\",\n... from_node=agent_node,\n... to_node=branching_node\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_access_granted\",\n... from_node=branching_node,\n... from_branch=CORRECT_PASSWORD_BRANCH,\n... to_node=access_granted_end_node,\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_access_denied\",\n... from_node=branching_node,\n... from_branch=BranchingNode.DEFAULT_BRANCH,\n... to_node=access_denied_end_node,\n... ),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"password_edge\",\n... source_node=agent_node,\n... source_output=\"password\",\n... destination_node=branching_node,\n... destination_input=\"password\",\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "mapping": { + "additionalProperties": { + "type": "string" + }, + "title": "Mapping", + "type": "object" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "BranchingNode" + } + }, + "required": [ + "mapping", + "name" + ], + "title": "BranchingNode", + "type": "object", + "x-abstract-component": false + }, + "BaseBuiltinTool": { + "additionalProperties": false, + "description": "A tool that is built into and executed by the orchestrator", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "requires_confirmation": { + "default": false, + "title": "Requires Confirmation", + "type": "boolean" + }, + "tool_type": { + "title": "Tool Type", + "type": "string" + }, + "configuration": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Configuration" + }, + "executor_name": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Executor Name" + }, + "tool_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Version" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "BuiltinTool" + } + }, + "required": [ + "name", + "tool_type" + ], + "title": "BuiltinTool", + "type": "object", + "x-abstract-component": false + }, + "BaseCatchExceptionNode": { + "additionalProperties": false, + "description": "Node to execute a Flow and catch exceptions.\n\n- If no exception is caught, the node will transition to the branches of its subflow.\n- If an exception is caught, it will transition to an exception branch.\n\nInputs\n------\nSame as the inputs from the ``subflow``.\n\nOutputs\n-------\n\n- The outputs of the ``subflow``.\n If an exception is raised, the default values of each output property are used.\n- An additional output named ``caught_exception_info`` with type ``string | null``\n and default value ``null``. Executors may populate it with a non-sensitive\n error description when an exception is caught.\n\nBranches\n--------\n\n- The branches of the ``subflow``\n- One additional branch named ``caught_exception_branch``\n\nSecurity Considerations\n-----------------------\n\nSee security considerations regarding exception catching in\nthe :ref:`Security Considerations `", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "subflow": { + "$ref": "#/$defs/Flow" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "CatchExceptionNode" + } + }, + "required": [ + "name", + "subflow" + ], + "title": "CatchExceptionNode", + "type": "object", + "x-abstract-component": false + }, + "BaseClientTool": { + "additionalProperties": false, + "description": "A tool that needs to be run by the client application.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "requires_confirmation": { + "default": false, + "title": "Requires Confirmation", + "type": "boolean" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ClientTool" + } + }, + "required": [ + "name" + ], + "title": "ClientTool", + "type": "object", + "x-abstract-component": false + }, + "BaseClientTransport": { + "anyOf": [ + { + "$ref": "#/$defs/RemoteTransport" + }, + { + "$ref": "#/$defs/SSETransport" + }, + { + "$ref": "#/$defs/SSEmTLSTransport" + }, + { + "$ref": "#/$defs/StdioTransport" + }, + { + "$ref": "#/$defs/StreamableHTTPTransport" + }, + { + "$ref": "#/$defs/StreamableHTTPmTLSTransport" + } + ], + "x-abstract-component": true + }, + "BaseComponentWithIO": { + "anyOf": [ + { + "$ref": "#/$defs/A2AAgent" + }, + { + "$ref": "#/$defs/Agent" + }, + { + "$ref": "#/$defs/AgentNode" + }, + { + "$ref": "#/$defs/AgentSpecializationParameters" + }, + { + "$ref": "#/$defs/AgenticComponent" + }, + { + "$ref": "#/$defs/ApiNode" + }, + { + "$ref": "#/$defs/BranchingNode" + }, + { + "$ref": "#/$defs/BuiltinTool" + }, + { + "$ref": "#/$defs/CatchExceptionNode" + }, + { + "$ref": "#/$defs/ClientTool" + }, + { + "$ref": "#/$defs/EndNode" + }, + { + "$ref": "#/$defs/Flow" + }, + { + "$ref": "#/$defs/FlowNode" + }, + { + "$ref": "#/$defs/InputMessageNode" + }, + { + "$ref": "#/$defs/LlmNode" + }, + { + "$ref": "#/$defs/MCPTool" + }, + { + "$ref": "#/$defs/MCPToolSpec" + }, + { + "$ref": "#/$defs/ManagerWorkers" + }, + { + "$ref": "#/$defs/MapNode" + }, + { + "$ref": "#/$defs/Node" + }, + { + "$ref": "#/$defs/OciAgent" + }, + { + "$ref": "#/$defs/OutputMessageNode" + }, + { + "$ref": "#/$defs/ParallelFlowNode" + }, + { + "$ref": "#/$defs/ParallelMapNode" + }, + { + "$ref": "#/$defs/RemoteAgent" + }, + { + "$ref": "#/$defs/RemoteTool" + }, + { + "$ref": "#/$defs/ServerTool" + }, + { + "$ref": "#/$defs/SpecializedAgent" + }, + { + "$ref": "#/$defs/StartNode" + }, + { + "$ref": "#/$defs/Swarm" + }, + { + "$ref": "#/$defs/Tool" + }, + { + "$ref": "#/$defs/ToolNode" + } + ], + "x-abstract-component": true + }, + "BaseControlFlowEdge": { + "additionalProperties": false, + "description": "A control flow edge specifies a possible transition from a node to another in a flow.\n\nA single node can have several potential next nodes, in which case several control flow edges\nshould be present in the control flow connections of that flow.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "from_node": { + "$ref": "#/$defs/Node" + }, + "from_branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "From Branch" + }, + "to_node": { + "$ref": "#/$defs/Node" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ControlFlowEdge" + } + }, + "required": [ + "from_node", + "name", + "to_node" + ], + "title": "ControlFlowEdge", + "type": "object", + "x-abstract-component": false + }, + "BaseConversationSummarizationTransform": { + "additionalProperties": false, + "description": "Summarizes conversations exceeding a configured size threshold using an LLM and caches\nconversation summaries in a ``Datastore``.\n\nThis is useful to reduce long conversation history into a concise context for downstream LLM calls.\n\nExamples\n--------\n>>> from pyagentspec.transforms import ConversationSummarizationTransform\n>>> summarization_transform = ConversationSummarizationTransform(\n... name=\"conversation-summarizer\",\n... llm=llm_config,\n... max_num_messages=30,\n... min_num_messages=10\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "llm": { + "$ref": "#/$defs/LlmConfig" + }, + "max_num_messages": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 50, + "title": "Max Num Messages" + }, + "max_num_characters": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Num Characters" + }, + "min_num_messages": { + "default": 10, + "exclusiveMinimum": 0, + "title": "Min Num Messages", + "type": "integer" + }, + "summarization_instructions": { + "default": "Please make a summary of this conversation. Include relevant information and keep it short. Your response will replace the messages, so just output the summary directly, no introduction needed.", + "title": "Summarization Instructions", + "type": "string" + }, + "summarized_conversation_template": { + "default": "Summarized conversation: {{summary}}", + "title": "Summarized Conversation Template", + "type": "string" + }, + "max_cache_size": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 10000, + "title": "Max Cache Size" + }, + "max_cache_lifetime": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 14400, + "title": "Max Cache Lifetime" + }, + "cache_collection_name": { + "default": "summarized_conversations_cache", + "title": "Cache Collection Name", + "type": "string" + }, + "datastore": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/InMemoryCollectionDatastore" + }, + { + "$ref": "#/$defs/OracleDatabaseDatastore" + }, + { + "$ref": "#/$defs/PostgresDatabaseDatastore" + } + ], + "title": "Datastore" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ConversationSummarizationTransform" + } + }, + "required": [ + "llm", + "name" + ], + "title": "ConversationSummarizationTransform", + "type": "object", + "x-abstract-component": false + }, + "BaseDataFlowEdge": { + "additionalProperties": false, + "description": "A data flow edge specifies how the output of a node propagates as input of another node.\n\nAn outputs can be propagated as input of several nodes.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "source_node": { + "$ref": "#/$defs/Node" + }, + "source_output": { + "title": "Source Output", + "type": "string" + }, + "destination_node": { + "$ref": "#/$defs/Node" + }, + "destination_input": { + "title": "Destination Input", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "DataFlowEdge" + } + }, + "required": [ + "destination_input", + "destination_node", + "name", + "source_node", + "source_output" + ], + "title": "DataFlowEdge", + "type": "object", + "x-abstract-component": false + }, + "BaseDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/InMemoryCollectionDatastore" + }, + { + "$ref": "#/$defs/OracleDatabaseDatastore" + }, + { + "$ref": "#/$defs/PostgresDatabaseDatastore" + } + ], + "x-abstract-component": true + }, + "BaseDbmsVectorChainLlmConfig": { + "additionalProperties": false, + "description": "Configure an LLM executed by Oracle Database through DBMS_VECTOR_CHAIN.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "minLength": 1, + "title": "Model Id", + "type": "string" + }, + "provider": { + "minLength": 1, + "title": "Provider", + "type": "string" + }, + "url": { + "minLength": 1, + "title": "Url", + "type": "string" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "host": { + "anyOf": [ + { + "const": "local", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Host" + }, + "credential_name": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Credential Name" + }, + "transfer_timeout": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Transfer Timeout" + }, + "connection_config": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/OracleDatabaseConnectionConfig" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "DbmsVectorChainLlmConfig" + } + }, + "required": [ + "model_id", + "name", + "provider", + "url" + ], + "title": "DbmsVectorChainLlmConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseEndNode": { + "additionalProperties": false, + "description": "End nodes denote the end of the execution of a flow.\n\nThere might be several end nodes in a flow, in which case the executor of the flow\nshould be able to track which one was reached and pass that back to the caller.\n\n- **Inputs**\n The list of inputs of the step. If both input and output properties are specified they\n must be an exact match\n\n If None is given, ``pyagentspec`` copies the outputs provided, if any. Otherwise, no input is exposed.\n- **Outputs**\n The list of outputs that should be exposed by the flow. If both input and output properties\n are specified they must be an exact match\n\n If None is given, ``pyagentspec`` copies the inputs provided, if any. Otherwise, no output is exposed.\n- **Branches**\n None.\n\nExamples\n--------\n>>> from pyagentspec.agent import Agent\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import AgentNode, BranchingNode, StartNode, EndNode\n>>> from pyagentspec.property import Property\n>>> languages_to_branch_name = {\n... \"english\": \"ENGLISH\",\n... \"spanish\": \"SPANISH\",\n... \"italian\": \"ITALIAN\",\n... }\n>>> language_property = Property(\n... json_schema={\"title\": \"language\", \"type\": \"string\"}\n... )\n>>> agent = Agent(\n... name=\"Language detector agent\",\n... llm_config=llm_config,\n... system_prompt=(\n... \"Your task is to understand the language spoken by the user.\"\n... \"Please output only the language in lowercase and submit.\"\n... ),\n... outputs=[language_property],\n... )\n>>> start_node = StartNode(name=\"start\")\n>>> english_end_node = EndNode(\n... name=\"english end\", branch_name=languages_to_branch_name[\"english\"]\n... )\n>>> spanish_end_node = EndNode(\n... name=\"spanish end\", branch_name=languages_to_branch_name[\"spanish\"]\n... )\n>>> italian_end_node = EndNode(\n... name=\"italian end\", branch_name=languages_to_branch_name[\"italian\"]\n... )\n>>> unknown_end_node = EndNode(name=\"unknown language end\", branch_name=\"unknown\")\n>>> branching_node = BranchingNode(\n... name=\"language check\",\n... mapping=languages_to_branch_name,\n... inputs=[language_property]\n... )\n>>> agent_node = AgentNode(\n... name=\"User input agent node\",\n... agent=agent,\n... )\n>>> assistant = Flow(\n... name=\"Check access flow\",\n... start_node=start_node,\n... nodes=[\n... start_node,\n... agent_node,\n... branching_node,\n... english_end_node,\n... spanish_end_node,\n... italian_end_node,\n... unknown_end_node,\n... ],\n... control_flow_connections=[\n... ControlFlowEdge(\n... name=\"start_to_agent\", from_node=start_node, to_node=agent_node\n... ),\n... ControlFlowEdge(\n... name=\"agent_to_branching\", from_node=agent_node, to_node=branching_node\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_english_end\",\n... from_node=branching_node,\n... from_branch=languages_to_branch_name[\"english\"],\n... to_node=english_end_node,\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_spanish_end\",\n... from_node=branching_node,\n... from_branch=languages_to_branch_name[\"spanish\"],\n... to_node=spanish_end_node,\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_italian_end\",\n... from_node=branching_node,\n... from_branch=languages_to_branch_name[\"italian\"],\n... to_node=italian_end_node,\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_unknown_end\",\n... from_node=branching_node,\n... from_branch=BranchingNode.DEFAULT_BRANCH,\n... to_node=unknown_end_node,\n... ),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"language_edge\",\n... source_node=agent_node,\n... source_output=\"language\",\n... destination_node=branching_node,\n... destination_input=\"language\",\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "branch_name": { + "default": "next", + "title": "Branch Name", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "EndNode" + } + }, + "required": [ + "name" + ], + "title": "EndNode", + "type": "object", + "x-abstract-component": false + }, + "BaseFlow": { + "additionalProperties": false, + "description": "A flow is a component to model sequences of operations to do in a precised order.\n\nThe operations and sequence is defined by the nodes and transitions associated to the flow.\nSteps can be deterministic, or for some use LLMs.\n\nExample\n-------\n>>> from pyagentspec.property import Property\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.nodes import LlmNode, StartNode, EndNode\n>>> prompt_property = Property(\n... json_schema={\"title\": \"prompt\", \"type\": \"string\"}\n... )\n>>> llm_output_property = Property(\n... json_schema={\"title\": \"llm_output\", \"type\": \"string\"}\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[prompt_property])\n>>> end_node = EndNode(name=\"end\", outputs=[llm_output_property])\n>>> llm_node = LlmNode(\n... name=\"simple llm node\",\n... llm_config=llm_config,\n... prompt_template=\"{{prompt}}\",\n... inputs=[prompt_property],\n... outputs=[llm_output_property],\n... )\n>>> flow = Flow(\n... name=\"Simple prompting flow\",\n... start_node=start_node,\n... nodes=[start_node, llm_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_llm\", from_node=start_node, to_node=llm_node),\n... ControlFlowEdge(name=\"llm_to_end\", from_node=llm_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"prompt_edge\",\n... source_node=start_node,\n... source_output=\"prompt\",\n... destination_node=llm_node,\n... destination_input=\"prompt\",\n... ),\n... DataFlowEdge(\n... name=\"llm_output_edge\",\n... source_node=llm_node,\n... source_output=\"llm_output\",\n... destination_node=end_node,\n... destination_input=\"llm_output\"\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "start_node": { + "$ref": "#/$defs/Node" + }, + "nodes": { + "items": { + "$ref": "#/$defs/Node" + }, + "title": "Nodes", + "type": "array" + }, + "control_flow_connections": { + "items": { + "$ref": "#/$defs/ControlFlowEdge" + }, + "title": "Control Flow Connections", + "type": "array" + }, + "data_flow_connections": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/DataFlowEdge" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data Flow Connections" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "Flow" + } + }, + "required": [ + "control_flow_connections", + "name", + "nodes", + "start_node" + ], + "title": "Flow", + "type": "object", + "x-abstract-component": false + }, + "BaseFlowNode": { + "additionalProperties": false, + "description": "The flow node executes a subflow as part of a flow.\n\n- **Inputs**\n Inferred from the inner structure. It's the sets of inputs\n required by the StartNode of the inner flow.\n- **Outputs**\n Inferred from the inner structure. It's the union of the\n sets of outputs exposed by the EndNodes of the inner flow.\n- **Branches**\n Inferred from the inner flow, one per each different value of the attribute\n ``branch_name`` of the nodes of type EndNode in the inner flow.\n\nExample\n-------\nThe ``FlowNode`` is particularly suitable when subflows can be reused inside a project.\nLet's see an example with a flow that estimates numerical value\nusing the \"wisdowm of the crowd\" effect:\n\n>>> from pyagentspec.property import Property\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.nodes import MapNode, LlmNode, ToolNode, StartNode, EndNode\n>>> from pyagentspec.tools import ServerTool\n>>> duplication_tool = ServerTool(\n... name=\"duplication_tool\",\n... description=\"\",\n... inputs=[\n... Property(\n... json_schema={\"title\": \"element\", \"description\": \"\", \"type\": \"string\"}\n... ),\n... Property(\n... json_schema={\"title\": \"n\", \"description\": \"\", \"type\": \"integer\"}\n... ),\n... ],\n... outputs=[\n... Property(\n... json_schema={\n... \"title\": \"flow_iterable_queries\",\n... \"type\": \"array\",\n... \"items\": {\"type\": \"string\"}\n... },\n... )\n... ],\n... )\n>>> reduce_tool = ServerTool(\n... name=\"reduce_tool\",\n... description=\"\",\n... inputs=[\n... Property(\n... json_schema={\"title\": \"elements\", \"type\": \"array\", \"items\": {\"type\": \"string\"}}\n... ),\n... ],\n... outputs=[Property(json_schema={\"title\": \"flow_processed_query\", \"type\": \"string\"})],\n... )\n>>> # Defining a simple prompt\n>>> REASONING_PROMPT_TEMPLATE = '''Provide your best numerical estimate for: {{user_input}}\n... Your answer should be a single number.\n... Do not include any units, reasoning, or extra text.'''\n>>> # Defining the subflow for the map step\n>>> user_input_property = Property(\n... json_schema={\"title\": \"user_input\", \"type\": \"string\"}\n... )\n>>> flow_processed_query_property = Property(\n... json_schema={\"title\": \"flow_processed_query\", \"type\": \"string\"}\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[user_input_property])\n>>> end_node = EndNode(name=\"end\", outputs=[flow_processed_query_property])\n>>> llm_node = LlmNode(\n... name=\"reasoning llm node\",\n... llm_config=llm_config,\n... prompt_template=REASONING_PROMPT_TEMPLATE,\n... inputs=[user_input_property],\n... outputs=[flow_processed_query_property],\n... )\n>>> inner_map_flow = Flow(\n... name=\"Map flow\",\n... start_node=start_node,\n... nodes=[start_node, llm_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_llm\", from_node=start_node, to_node=llm_node),\n... ControlFlowEdge(name=\"llm_to_end\", from_node=llm_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"query_edge\",\n... source_node=start_node,\n... source_output=\"user_input\",\n... destination_node=llm_node,\n... destination_input=\"user_input\",\n... ),\n... DataFlowEdge(\n... name=\"search_results_edge\",\n... source_node=llm_node,\n... source_output=\"flow_processed_query\",\n... destination_node=end_node,\n... destination_input=\"flow_processed_query\"\n... ),\n... ],\n... )\n>>> user_query_property = Property(\n... json_schema={\"title\": \"user_query\", \"type\": \"string\"}\n... )\n>>> n_repeat_property = Property(\n... json_schema={\"title\": \"n_repeat\", \"type\": \"integer\"}\n... )\n>>> flow_iterable_queries_property = Property(\n... json_schema={\n... \"title\": \"iterated_user_input\",\n... \"type\": \"array\",\n... \"items\": {\"type\": \"string\"},\n... }\n... )\n>>> flow_processed_queries_property = Property(\n... json_schema={\n... \"title\": \"collected_flow_processed_query\",\n... \"type\": \"array\",\n... \"items\": {\"type\": \"string\"},\n... }\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[user_query_property, n_repeat_property])\n>>> end_node = EndNode(name=\"end\", outputs=[flow_processed_query_property])\n>>> duplication_node = ToolNode(\n... name=\"duplication_tool node\",\n... tool=duplication_tool,\n... )\n>>> reduce_node = ToolNode(\n... name=\"reduce_tool node\",\n... tool=reduce_tool,\n... )\n>>> map_node = MapNode(\n... name=\"map node\",\n... subflow=inner_map_flow,\n... inputs=[flow_iterable_queries_property],\n... outputs=[flow_processed_queries_property],\n... )\n>>> mapreduce_flow = Flow(\n... name=\"Map-reduce flow\",\n... start_node=start_node,\n... nodes=[start_node, duplication_node, map_node, reduce_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(\n... name=\"start_to_duplication\", from_node=start_node, to_node=duplication_node\n... ),\n... ControlFlowEdge(\n... name=\"duplication_to_map\", from_node=duplication_node, to_node=map_node\n... ),\n... ControlFlowEdge(name=\"map_to_reduce\", from_node=map_node, to_node=reduce_node),\n... ControlFlowEdge(name=\"reduce_to_end\", from_node=reduce_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"query_edge\",\n... source_node=start_node,\n... source_output=\"user_query\",\n... destination_node=duplication_node,\n... destination_input=\"element\",\n... ),\n... DataFlowEdge(\n... name=\"n_repeat_edge\",\n... source_node=start_node,\n... source_output=\"n_repeat\",\n... destination_node=duplication_node,\n... destination_input=\"n\",\n... ),\n... DataFlowEdge(\n... name=\"flow_iterables_edge\",\n... source_node=duplication_node,\n... source_output=\"flow_iterable_queries\",\n... destination_node=map_node,\n... destination_input=\"iterated_user_input\",\n... ),\n... DataFlowEdge(\n... name=\"flow_processed_queries_edge\",\n... source_node=map_node,\n... source_output=\"collected_flow_processed_query\",\n... destination_node=reduce_node,\n... destination_input=\"elements\",\n... ),\n... DataFlowEdge(\n... name=\"flow_processed_query_edge\",\n... source_node=reduce_node,\n... source_output=\"flow_processed_query\",\n... destination_node=end_node,\n... destination_input=\"flow_processed_query\",\n... ),\n... ],\n... )\n\nOnce the subflow is created we can simply integrate it with the ``FlowNode``:\n\n>>> from pyagentspec.flows.nodes import FlowNode, AgentNode\n>>> from pyagentspec.agent import Agent\n>>> start_node = StartNode(name=\"start\")\n>>> end_node = EndNode(name=\"end\", outputs=[flow_processed_query_property])\n>>> flow_node = FlowNode(name=\"flow node\", subflow=mapreduce_flow)\n>>> agent = Agent(\n... name=\"User interaction agent\",\n... llm_config=llm_config,\n... system_prompt=(\n... \"Your task is to gather from the user the query and the number of times \"\n... \"it should be asked to an LLM. Once you have this information, submit and exit.\"\n... ),\n... outputs=[user_query_property, n_repeat_property],\n... )\n>>> agent_node = AgentNode(name=\"flow node\", agent=agent)\n>>> flow = Flow(\n... name=\"Map flow\",\n... start_node=start_node,\n... nodes=[start_node, agent_node, flow_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_agent\", from_node=start_node, to_node=agent_node),\n... ControlFlowEdge(name=\"agent_to_flow\", from_node=agent_node, to_node=flow_node),\n... ControlFlowEdge(name=\"flow_to_end\", from_node=flow_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"query_edge\",\n... source_node=agent_node,\n... source_output=\"user_query\",\n... destination_node=flow_node,\n... destination_input=\"user_query\",\n... ),\n... DataFlowEdge(\n... name=\"n_rep_edge\",\n... source_node=agent_node,\n... source_output=\"n_repeat\",\n... destination_node=flow_node,\n... destination_input=\"n_repeat\"\n... ),\n... DataFlowEdge(\n... name=\"n_rep_edge\",\n... source_node=flow_node,\n... source_output=\"flow_processed_query\",\n... destination_node=end_node,\n... destination_input=\"flow_processed_query\"\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "subflow": { + "$ref": "#/$defs/Flow" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "FlowNode" + } + }, + "required": [ + "name", + "subflow" + ], + "title": "FlowNode", + "type": "object", + "x-abstract-component": false + }, + "BaseGeminiAIStudioAuthConfig": { + "additionalProperties": false, + "description": "Authentication settings for Gemini via Google AI Studio.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "GeminiAIStudioAuthConfig" + } + }, + "required": [ + "name" + ], + "title": "GeminiAIStudioAuthConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseGeminiAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/GeminiAIStudioAuthConfig" + }, + { + "$ref": "#/$defs/GeminiVertexAIAuthConfig" + } + ], + "x-abstract-component": true + }, + "BaseGeminiConfig": { + "additionalProperties": false, + "description": "Configure a connection to a Gemini LLM (AI Studio or Vertex AI).", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "title": "Model Id", + "type": "string" + }, + "provider": { + "const": "google", + "default": "google", + "title": "Provider", + "type": "string" + }, + "api_provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Provider" + }, + "api_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Type" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "auth": { + "$ref": "#/$defs/GeminiAuthConfig" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "GeminiConfig" + } + }, + "required": [ + "auth", + "model_id", + "name" + ], + "title": "GeminiConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseGeminiVertexAIAuthConfig": { + "additionalProperties": false, + "description": "Authentication settings for Gemini via Google Vertex AI.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "project_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Project Id" + }, + "location": { + "default": "global", + "title": "Location", + "type": "string" + }, + "credentials": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Credentials" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "GeminiVertexAIAuthConfig" + } + }, + "required": [ + "name" + ], + "title": "GeminiVertexAIAuthConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseInMemoryCollectionDatastore": { + "additionalProperties": false, + "description": "In-memory datastore for testing and development purposes.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "datastore_schema": { + "additionalProperties": { + "$ref": "#/$defs/Property" + }, + "title": "Datastore Schema", + "type": "object" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "InMemoryCollectionDatastore" + } + }, + "required": [ + "datastore_schema", + "name" + ], + "title": "InMemoryCollectionDatastore", + "type": "object", + "x-abstract-component": false + }, + "BaseInputMessageNode": { + "additionalProperties": false, + "description": "This node interrupts the execution of the flow in order to wait for a user input, and restarts after receiving it.\nAn agent message, if given, is appended to the conversation before waiting for input.\nUser input is appended to the conversation as a user message, and it is returned as a string property from the node.\n\n- **Inputs**\n One per variable in the message\n- **Outputs**\n One string property that represents the content of the input user message.\n\n If None is given, ``pyagentspec`` infers a string property named ``user_input``.\n- **Branches**\n One, the default next.\n\nExamples\n--------\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import StartNode, EndNode, InputMessageNode, OutputMessageNode, LlmNode\n>>> from pyagentspec.property import StringProperty\n>>> start_node = StartNode(name=\"start\")\n>>> prompt_node = OutputMessageNode(name=\"ask_input\", message=\"What is the paragraph you want to rephrase?\")\n>>> input_node = InputMessageNode(name=\"user_input\", outputs=[StringProperty(title=\"user_input\")])\n>>> llm_node = LlmNode(\n... name=\"rephrase\",\n... llm_config=llm_config,\n... prompt_template=\"Rephrase {{user_input}}\",\n... outputs=[StringProperty(title=\"rephrased_user_input\")],\n... )\n>>> output_node = OutputMessageNode(name=\"ask_input\", message=\"{{rephrased_user_input}}\")\n>>> end_node = EndNode(name=\"end\")\n>>> flow = Flow(\n... name=\"rephrase_paragraph_flow\",\n... start_node=start_node,\n... nodes=[start_node, prompt_node, input_node, llm_node, output_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"ce1\", from_node=start_node, to_node=prompt_node),\n... ControlFlowEdge(name=\"ce2\", from_node=prompt_node, to_node=input_node),\n... ControlFlowEdge(name=\"ce3\", from_node=input_node, to_node=llm_node),\n... ControlFlowEdge(name=\"ce4\", from_node=llm_node, to_node=output_node),\n... ControlFlowEdge(name=\"ce5\", from_node=output_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"de1\",\n... source_node=input_node,\n... source_output=\"user_input\",\n... destination_node=llm_node,\n... destination_input=\"user_input\",\n... ),\n... DataFlowEdge(\n... name=\"de2\",\n... source_node=llm_node,\n... source_output=\"rephrased_user_input\",\n... destination_node=output_node,\n... destination_input=\"rephrased_user_input\",\n... ),\n... ]\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "InputMessageNode" + } + }, + "required": [ + "name" + ], + "title": "InputMessageNode", + "type": "object", + "x-abstract-component": false + }, + "BaseLlmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/DbmsVectorChainLlmConfig" + }, + { + "$ref": "#/$defs/GeminiConfig" + }, + { + "$ref": "#/$defs/OciGenAiConfig" + }, + { + "$ref": "#/$defs/OllamaConfig" + }, + { + "$ref": "#/$defs/OpenAiCompatibleConfig" + }, + { + "$ref": "#/$defs/OpenAiConfig" + }, + { + "$ref": "#/$defs/VllmConfig" + }, + { + "additionalProperties": false, + "description": "A LLM configuration defines how to connect to a LLM to do generation requests.\n\nThis class can be used directly with the ``provider``, ``api_provider``, and ``api_type``\nfields to describe any LLM without a dedicated subclass. Concrete subclasses provide\nadditional configuration for specific LLM providers.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "title": "Model Id", + "type": "string" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "api_provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Provider" + }, + "api_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Type" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "LlmConfig" + } + }, + "required": [ + "model_id", + "name" + ], + "title": "LlmConfig", + "type": "object" + } + ], + "x-abstract-component": false + }, + "BaseLlmNode": { + "additionalProperties": false, + "description": "Execute a prompt template with a given LLM.\n\nThis node is intended to be a part of a Flow.\n\n- **Inputs**\n One per placeholder in the prompt template.\n- **Outputs**\n The output text generated by the LLM.\n\n If None is given, ``pyagentspec`` infers a string property named ``generated_text``.\n- **Branches**\n One, the default next.\n\nExample\n-------\n>>> from pyagentspec.property import Property\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.nodes import LlmNode, StartNode, EndNode\n>>> country_property = Property(\n... json_schema={\"title\": \"country\", \"type\": \"string\"}\n... )\n>>> capital_property = Property(\n... json_schema={\"title\": \"capital\", \"type\": \"string\"}\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[country_property])\n>>> end_node = EndNode(name=\"end\", outputs=[capital_property])\n>>> llm_node = LlmNode(\n... name=\"simple llm node\",\n... llm_config=llm_config,\n... prompt_template=\"What is the capital of {{ country }}?\",\n... inputs=[country_property],\n... outputs=[capital_property],\n... )\n>>> flow = Flow(\n... name=\"Get the country's capital flow\",\n... start_node=start_node,\n... nodes=[start_node, llm_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_llm\", from_node=start_node, to_node=llm_node),\n... ControlFlowEdge(name=\"llm_to_end\", from_node=llm_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"country_edge\",\n... source_node=start_node,\n... source_output=\"country\",\n... destination_node=llm_node,\n... destination_input=\"country\",\n... ),\n... DataFlowEdge(\n... name=\"capital_edge\",\n... source_node=llm_node,\n... source_output=\"capital\",\n... destination_node=end_node,\n... destination_input=\"capital\"\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "llm_config": { + "$ref": "#/$defs/LlmConfig" + }, + "prompt_template": { + "title": "Prompt Template", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "LlmNode" + } + }, + "required": [ + "llm_config", + "name", + "prompt_template" + ], + "title": "LlmNode", + "type": "object", + "x-abstract-component": false + }, + "BaseMCPTool": { + "additionalProperties": false, + "description": "Class for tools exposed by MCP servers", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "requires_confirmation": { + "default": false, + "title": "Requires Confirmation", + "type": "boolean" + }, + "client_transport": { + "$ref": "#/$defs/ClientTransport" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "MCPTool" + } + }, + "required": [ + "client_transport", + "name" + ], + "title": "MCPTool", + "type": "object", + "x-abstract-component": false + }, + "BaseMCPToolBox": { + "additionalProperties": false, + "description": "Class to dynamically expose a list of tools from a MCP Server.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "requires_confirmation": { + "default": false, + "title": "Requires Confirmation", + "type": "boolean" + }, + "client_transport": { + "$ref": "#/$defs/ClientTransport" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "tool_filter": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/MCPToolSpec" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Filter" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "MCPToolBox" + } + }, + "required": [ + "client_transport", + "name" + ], + "title": "MCPToolBox", + "type": "object", + "x-abstract-component": false + }, + "BaseMCPToolSpec": { + "additionalProperties": false, + "description": "Specification of MCP tool", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "requires_confirmation": { + "default": false, + "title": "Requires Confirmation", + "type": "boolean" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "MCPToolSpec" + } + }, + "required": [ + "name" + ], + "title": "MCPToolSpec", + "type": "object", + "x-abstract-component": false + }, + "BaseMTlsOracleDatabaseConnectionConfig": { + "additionalProperties": false, + "description": "Mutual-TLS Connection Configuration to Oracle Database.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "user": { + "title": "User", + "type": "string" + }, + "password": { + "title": "Password", + "type": "string" + }, + "dsn": { + "title": "Dsn", + "type": "string" + }, + "config_dir": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Config Dir" + }, + "protocol": { + "default": "tcps", + "enum": [ + "tcp", + "tcps" + ], + "title": "Protocol", + "type": "string" + }, + "wallet_location": { + "title": "Wallet Location", + "type": "string" + }, + "wallet_password": { + "title": "Wallet Password", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "MTlsOracleDatabaseConnectionConfig" + } + }, + "required": [ + "dsn", + "name", + "password", + "user", + "wallet_location", + "wallet_password" + ], + "title": "MTlsOracleDatabaseConnectionConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseManagerWorkers": { + "additionalProperties": false, + "description": "Defines a ``ManagerWorkers`` conversational component.\n\nA ``ManagerWorkers`` is a multi-agent conversational component in which a group manager\nassigns tasks to the workers. The group manager and workers can be instantiated from\nany ``AgenticComponent`` type.\n\nExamples\n--------\n>>> from pyagentspec.agent import Agent\n>>> from pyagentspec.managerworkers import ManagerWorkers\n>>> manager_agent = Agent(\n... name=\"manager_agent\",\n... description=\"Agent that manages a group of math agents\",\n... llm_config=llm_config,\n... system_prompt=\"You are the manager of a group of math agents\"\n... )\n>>> multiplication_agent = Agent(\n... name=\"multiplication_agent\",\n... description=\"Agent that can do multiplication\",\n... llm_config=llm_config,\n... system_prompt=\"You can do multiplication.\"\n... )\n>>> division_agent = Agent(\n... name=\"division_agent\",\n... description=\"Agent that can do division\",\n... llm_config=llm_config,\n... system_prompt=\"You can do division.\"\n... )\n>>> group = ManagerWorkers(\n... name=\"managerworkers\",\n... group_manager=manager_agent,\n... workers=[multiplication_agent, division_agent],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "group_manager": { + "$ref": "#/$defs/AgenticComponent" + }, + "workers": { + "items": { + "$ref": "#/$defs/AgenticComponent" + }, + "title": "Workers", + "type": "array" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ManagerWorkers" + } + }, + "required": [ + "group_manager", + "name", + "workers" + ], + "title": "ManagerWorkers", + "type": "object", + "x-abstract-component": false + }, + "BaseMapNode": { + "additionalProperties": false, + "description": "The map node executes a subflow on each element of a given input as part of a flow.\n\n- **Inputs**\n Inferred from the inner structure. It's the sets of inputs\n required by the StartNode of the inner flow.\n The names of the inputs will be the ones of the inner flow,\n complemented with the ``iterated_`` prefix. Their type is\n ``Union[inner_type, List[inner_type]]``, where ``inner_type``\n is the type of the respective input in the inner flow.\n\n If None is given, ``pyagentspec`` infers input properties directly from the inner flow,\n specifying title and type according to the rules defined above.\n\n- **Outputs**\n Inferred from the inner structure. It's the union of the\n sets of outputs exposed by the EndNodes of the inner flow,\n combined with the reducer method of each output.\n The names of the outputs will be the ones of the inner flow,\n complemented with the ``collected_`` prefix. Their type depends\n on the ``reduce`` method specified for that output:\n\n - ``List`` of the respective output type in case of ``append``\n - same type of the respective output type in case of ``sum``, ``avg``\n\n If None is given, ``pyagentspec`` infers outputs by exposing\n an output property for each entry in the ``reducers`` dictionary, specifying title\n and type according to the rules defined above.\n\n- **Branches**\n One, the default next.\n\nExamples\n--------\nIn this example we will create a flow that returns\nan L2-normalized version of a given list of numbers.\n\n>>> from pyagentspec.property import Property\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import EndNode, StartNode, MapNode, ToolNode\n>>> from pyagentspec.tools import ServerTool\n\nFirst, we define a MapNode that returns the square of all the elements in a list.\nIt will be used to compute the L2-norm.\n\n>>> x_property = Property(json_schema={\"title\": \"x\", \"type\": \"number\"})\n>>> x_square_property = Property(\n... json_schema={\"title\": \"x_square\", \"type\": \"number\"}\n... )\n>>> square_tool = ServerTool(\n... name=\"compute_square_tool\",\n... description=\"Computes the square of a number\",\n... inputs=[x_property],\n... outputs=[x_square_property],\n... )\n>>> list_of_x_property = Property(\n... json_schema={\"title\": \"x_list\", \"type\": \"array\", \"items\": {\"type\": \"number\"}}\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[x_property])\n>>> end_node = EndNode(name=\"end\", outputs=[x_square_property])\n>>> square_tool_node = ToolNode(name=\"square tool node\", tool=square_tool)\n>>> square_number_flow = Flow(\n... name=\"Square number flow\",\n... start_node=start_node,\n... nodes=[start_node, square_tool_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(\n... name=\"start_to_tool\", from_node=start_node, to_node=square_tool_node\n... ),\n... ControlFlowEdge(\n... name=\"tool_to_end\", from_node=square_tool_node, to_node=end_node\n... ),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"x_edge\",\n... source_node=start_node,\n... source_output=\"x\",\n... destination_node=square_tool_node,\n... destination_input=\"x\",\n... ),\n... DataFlowEdge(\n... name=\"x_square_edge\",\n... source_node=square_tool_node,\n... source_output=\"x_square\",\n... destination_node=end_node,\n... destination_input=\"x_square\",\n... ),\n... ],\n... )\n>>> list_of_x_square_property = Property(\n... json_schema={\"title\": \"x_square_list\", \"type\": \"array\", \"items\": {\"type\": \"number\"}}\n... )\n>>> square_numbers_map_node = MapNode(\n... name=\"square number map node\",\n... subflow=square_number_flow,\n... )\n\nNow we define the MapNode responsible for normalizing the given list of input numbers.\nThe denominator is the same for all of the numbers,\nwe are going to map only the numerators (i.e., the input numbers).\n\n>>> numerator_property = Property(\n... json_schema={\"title\": \"numerator\", \"type\": \"number\"}\n... )\n>>> denominator_property = Property(\n... json_schema={\"title\": \"denominator\", \"type\": \"number\"}\n... )\n>>> result_property = Property(\n... json_schema={\"title\": \"result\", \"type\": \"number\"}\n... )\n>>> division_tool = ServerTool(\n... name=\"division_tool\",\n... description=\"Computes the ratio between two numbers\",\n... inputs=[numerator_property, denominator_property],\n... outputs=[result_property],\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[numerator_property, denominator_property])\n>>> end_node = EndNode(name=\"end\", outputs=[result_property])\n>>> divide_node = ToolNode(name=\"divide node\", tool=division_tool)\n>>> normalize_flow = Flow(\n... name=\"Normalize flow\",\n... start_node=start_node,\n... nodes=[start_node, divide_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_tool\", from_node=start_node, to_node=divide_node),\n... ControlFlowEdge(name=\"tool_to_end\", from_node=divide_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"numerator_edge\",\n... source_node=start_node,\n... source_output=\"numerator\",\n... destination_node=divide_node,\n... destination_input=\"numerator\",\n... ),\n... DataFlowEdge(\n... name=\"denominator_edge\",\n... source_node=start_node,\n... source_output=\"denominator\",\n... destination_node=divide_node,\n... destination_input=\"denominator\",\n... ),\n... DataFlowEdge(\n... name=\"result_edge\",\n... source_node=divide_node,\n... source_output=\"result\",\n... destination_node=end_node,\n... destination_input=\"result\",\n... ),\n... ],\n... )\n\nFinally, we define the overall flow:\n\n- The list of inputs is squared\n- The squared list is summed and root squared\n- The list of inputs is normalized based on the outcomes of the previous 2 steps\n\n>>> squared_sum_property = Property(\n... json_schema={\"title\": \"squared_sum\", \"type\": \"number\"}\n... )\n>>> normalized_list_of_x_property = Property(\n... json_schema={\n... \"title\": \"x_list_normalized\",\n... \"type\": \"array\",\n... \"items\": {\"type\": \"number\"},\n... }\n... )\n>>> normalize_map_node = MapNode(\n... name=\"normalize map node\",\n... subflow=normalize_flow,\n... )\n>>> squared_sum_tool = ServerTool(\n... name=\"squared_sum_tool\",\n... description=\"Computes the squared sum of a list of numbers\",\n... inputs=[list_of_x_property],\n... outputs=[squared_sum_property],\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[list_of_x_property])\n>>> end_node = EndNode(name=\"end\", outputs=[normalized_list_of_x_property])\n>>> squared_sum_tool_node = ToolNode(name=\"squared sum tool node\", tool=squared_sum_tool)\n>>> flow = Flow(\n... name=\"L2 normalize flow\",\n... start_node=start_node,\n... nodes=[\n... start_node,\n... square_numbers_map_node,\n... squared_sum_tool_node,\n... normalize_map_node,\n... end_node,\n... ],\n... control_flow_connections=[\n... ControlFlowEdge(\n... name=\"start_to_square_numbers\",\n... from_node=start_node,\n... to_node=square_numbers_map_node\n... ),\n... ControlFlowEdge(\n... name=\"square_numbers_to_squared_sum_tool\",\n... from_node=square_numbers_map_node,\n... to_node=squared_sum_tool_node\n... ),\n... ControlFlowEdge(\n... name=\"squared_sum_tool_to_normalize\",\n... from_node=squared_sum_tool_node,\n... to_node=normalize_map_node\n... ),\n... ControlFlowEdge(\n... name=\"normalize_to_end\",\n... from_node=normalize_map_node,\n... to_node=end_node\n... ),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"list_of_x_edge\",\n... source_node=start_node,\n... source_output=\"x_list\",\n... destination_node=square_numbers_map_node,\n... destination_input=\"iterated_x\",\n... ),\n... DataFlowEdge(\n... name=\"x_square_list_edge\",\n... source_node=square_numbers_map_node,\n... source_output=\"collected_x_square\",\n... destination_node=squared_sum_tool_node,\n... destination_input=\"x_list\",\n... ),\n... DataFlowEdge(\n... name=\"numerator_edge\",\n... source_node=start_node,\n... source_output=\"x_list\",\n... destination_node=normalize_map_node,\n... destination_input=\"iterated_numerator\",\n... ),\n... DataFlowEdge(\n... name=\"denominator_edge\",\n... source_node=squared_sum_tool_node,\n... source_output=\"squared_sum\",\n... destination_node=normalize_map_node,\n... destination_input=\"iterated_denominator\",\n... ),\n... DataFlowEdge(\n... name=\"x_list_normalized_edge\",\n... source_node=normalize_map_node,\n... source_output=\"collected_result\",\n... destination_node=end_node,\n... destination_input=\"x_list_normalized\",\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "subflow": { + "$ref": "#/$defs/Flow" + }, + "reducers": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/$defs/ReductionMethod" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reducers" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "MapNode" + } + }, + "required": [ + "name", + "subflow" + ], + "title": "MapNode", + "type": "object", + "x-abstract-component": false + }, + "BaseMessageSummarizationTransform": { + "additionalProperties": false, + "description": "Summarizes oversized messages using an LLM and optionally caches summaries.\n\nThis is useful for long conversations where the context can become too large for the LLM to handle.\n\nExamples\n--------\n>>> from pyagentspec.transforms import MessageSummarizationTransform\n>>> summarization_transform = MessageSummarizationTransform(\n... name=\"message-summarizer\",\n... llm=llm_config,\n... max_message_size=30_000\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "llm": { + "$ref": "#/$defs/LlmConfig" + }, + "max_message_size": { + "default": 20000, + "title": "Max Message Size", + "type": "integer" + }, + "summarization_instructions": { + "default": "Please make a summary of this message. Include relevant information and keep it short. Your response will replace the message, so just output the summary directly, no introduction needed.", + "title": "Summarization Instructions", + "type": "string" + }, + "summarized_message_template": { + "default": "Summarized message: {{summary}}", + "title": "Summarized Message Template", + "type": "string" + }, + "max_cache_size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 10000, + "title": "Max Cache Size" + }, + "max_cache_lifetime": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 14400, + "title": "Max Cache Lifetime" + }, + "cache_collection_name": { + "default": "summarized_messages_cache", + "title": "Cache Collection Name", + "type": "string" + }, + "datastore": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/InMemoryCollectionDatastore" + }, + { + "$ref": "#/$defs/OracleDatabaseDatastore" + }, + { + "$ref": "#/$defs/PostgresDatabaseDatastore" + } + ], + "title": "Datastore" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "MessageSummarizationTransform" + } + }, + "required": [ + "llm", + "name" + ], + "title": "MessageSummarizationTransform", + "type": "object", + "x-abstract-component": false + }, + "BaseMessageTransform": { + "anyOf": [ + { + "$ref": "#/$defs/ConversationSummarizationTransform" + }, + { + "$ref": "#/$defs/MessageSummarizationTransform" + } + ], + "x-abstract-component": true + }, + "BaseNode": { + "anyOf": [ + { + "$ref": "#/$defs/AgentNode" + }, + { + "$ref": "#/$defs/ApiNode" + }, + { + "$ref": "#/$defs/BranchingNode" + }, + { + "$ref": "#/$defs/CatchExceptionNode" + }, + { + "$ref": "#/$defs/EndNode" + }, + { + "$ref": "#/$defs/FlowNode" + }, + { + "$ref": "#/$defs/InputMessageNode" + }, + { + "$ref": "#/$defs/LlmNode" + }, + { + "$ref": "#/$defs/MapNode" + }, + { + "$ref": "#/$defs/OutputMessageNode" + }, + { + "$ref": "#/$defs/ParallelFlowNode" + }, + { + "$ref": "#/$defs/ParallelMapNode" + }, + { + "$ref": "#/$defs/StartNode" + }, + { + "$ref": "#/$defs/ToolNode" + } + ], + "x-abstract-component": true + }, + "BaseOAuthClientConfig": { + "additionalProperties": false, + "description": "OAuth client identity / registration configuration.\n\nThis configuration describes how the runtime establishes the OAuth client\nidentity to use with the authorization server. It supports:\n- Pre-registered clients (static client_id/client_secret)\n- Client ID Metadata Documents (URL-formatted client_id)\n- Dynamic client registration (RFC 7591)", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "min_agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum", + "default": "26.1.2" + }, + "type": { + "enum": [ + "client_id_metadata_document", + "dynamic_registration", + "pre_registered" + ], + "title": "Type", + "type": "string" + }, + "client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Client Id" + }, + "client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Client Secret" + }, + "token_endpoint_auth_method": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Token Endpoint Auth Method" + }, + "client_id_metadata_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Client Id Metadata Url" + }, + "registration_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Registration Endpoint" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OAuthClientConfig" + } + }, + "required": [ + "name", + "type" + ], + "title": "OAuthClientConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseOAuthConfig": { + "additionalProperties": false, + "description": "Configure OAuth-based authentication for a tool or transport.\n\nOAuthConfig is a generic configuration that can be used for both MCP servers\nand non-MCP remote API tools. It supports discovery-based configuration (via\n``issuer``) and explicit endpoints (via ``endpoints``).", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "min_agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum", + "default": "26.1.2" + }, + "issuer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Issuer" + }, + "endpoints": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/OAuthEndpoints" + } + ], + "default": null + }, + "client": { + "$ref": "#/$defs/OAuthClientConfig" + }, + "redirect_uri": { + "title": "Redirect Uri", + "type": "string" + }, + "scopes": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scopes" + }, + "scope_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/ScopePolicy" + } + ], + "default": null + }, + "pkce": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/PKCEPolicy" + } + ], + "default": null + }, + "resource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resource" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OAuthConfig" + } + }, + "required": [ + "client", + "name", + "redirect_uri" + ], + "title": "OAuthConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseOciAgent": { + "additionalProperties": false, + "description": "An agent is a component that can do several rounds of conversation to solve a task.\n\nThe agent is defined on the OCI console and this is only a wrapper to connect to it.\nIt can be executed by itself, or be executed in a flow using an AgentNode.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "agent_endpoint_id": { + "title": "Agent Endpoint Id", + "type": "string" + }, + "client_config": { + "$ref": "#/$defs/OciClientConfig" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OciAgent" + } + }, + "required": [ + "agent_endpoint_id", + "client_config", + "name" + ], + "title": "OciAgent", + "type": "object", + "x-abstract-component": false + }, + "BaseOciClientConfig": { + "anyOf": [ + { + "$ref": "#/$defs/OciClientConfigWithApiKey" + }, + { + "$ref": "#/$defs/OciClientConfigWithInstancePrincipal" + }, + { + "$ref": "#/$defs/OciClientConfigWithResourcePrincipal" + }, + { + "$ref": "#/$defs/OciClientConfigWithSecurityToken" + } + ], + "x-abstract-component": true + }, + "BaseOciClientConfigWithApiKey": { + "additionalProperties": false, + "description": "OCI client config class for authentication using API_KEY and a config file.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "service_endpoint": { + "title": "Service Endpoint", + "type": "string" + }, + "auth_type": { + "const": "API_KEY", + "default": "API_KEY", + "title": "Auth Type", + "type": "string" + }, + "auth_profile": { + "title": "Auth Profile", + "type": "string" + }, + "auth_file_location": { + "title": "Auth File Location", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OciClientConfigWithApiKey" + } + }, + "required": [ + "auth_file_location", + "auth_profile", + "name", + "service_endpoint" + ], + "title": "OciClientConfigWithApiKey", + "type": "object", + "x-abstract-component": false + }, + "BaseOciClientConfigWithInstancePrincipal": { + "additionalProperties": false, + "description": "OCI client config class for authentication using INSTANCE_PRINCIPAL.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "service_endpoint": { + "title": "Service Endpoint", + "type": "string" + }, + "auth_type": { + "const": "INSTANCE_PRINCIPAL", + "default": "INSTANCE_PRINCIPAL", + "title": "Auth Type", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OciClientConfigWithInstancePrincipal" + } + }, + "required": [ + "name", + "service_endpoint" + ], + "title": "OciClientConfigWithInstancePrincipal", + "type": "object", + "x-abstract-component": false + }, + "BaseOciClientConfigWithResourcePrincipal": { + "additionalProperties": false, + "description": "OCI client config class for authentication using RESOURCE_PRINCIPAL.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "service_endpoint": { + "title": "Service Endpoint", + "type": "string" + }, + "auth_type": { + "const": "RESOURCE_PRINCIPAL", + "default": "RESOURCE_PRINCIPAL", + "title": "Auth Type", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OciClientConfigWithResourcePrincipal" + } + }, + "required": [ + "name", + "service_endpoint" + ], + "title": "OciClientConfigWithResourcePrincipal", + "type": "object", + "x-abstract-component": false + }, + "BaseOciClientConfigWithSecurityToken": { + "additionalProperties": false, + "description": "OCI client config class for authentication using SECURITY_TOKEN.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "service_endpoint": { + "title": "Service Endpoint", + "type": "string" + }, + "auth_type": { + "const": "SECURITY_TOKEN", + "default": "SECURITY_TOKEN", + "title": "Auth Type", + "type": "string" + }, + "auth_profile": { + "title": "Auth Profile", + "type": "string" + }, + "auth_file_location": { + "title": "Auth File Location", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OciClientConfigWithSecurityToken" + } + }, + "required": [ + "auth_file_location", + "auth_profile", + "name", + "service_endpoint" + ], + "title": "OciClientConfigWithSecurityToken", + "type": "object", + "x-abstract-component": false + }, + "BaseOciGenAiConfig": { + "additionalProperties": false, + "description": "Class to configure a connection to a OCI GenAI hosted model.\n\nRequires to specify the model id and the client configuration to the OCI GenAI service.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "title": "Model Id", + "type": "string" + }, + "provider": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/ModelProvider" + } + ], + "default": null + }, + "api_provider": { + "const": "oci", + "default": "oci", + "title": "Api Provider", + "type": "string" + }, + "api_type": { + "$ref": "#/$defs/OciAPIType", + "default": "oci" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "compartment_id": { + "title": "Compartment Id", + "type": "string" + }, + "serving_mode": { + "$ref": "#/$defs/ServingMode", + "default": "ON_DEMAND" + }, + "client_config": { + "$ref": "#/$defs/OciClientConfig" + }, + "conversation_store_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Conversation Store Id" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OciGenAiConfig" + } + }, + "required": [ + "client_config", + "compartment_id", + "model_id", + "name" + ], + "title": "OciGenAiConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseOllamaConfig": { + "additionalProperties": false, + "description": "Class to configure a connection to a local model ran with Ollama.\n\nRequires to specify the url and port at which the model is running.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "title": "Model Id", + "type": "string" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "api_provider": { + "const": "ollama", + "default": "ollama", + "title": "Api Provider", + "type": "string" + }, + "api_type": { + "$ref": "#/$defs/OpenAIAPIType", + "default": "chat_completions" + }, + "url": { + "title": "Url", + "type": "string" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "key_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key File" + }, + "cert_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cert File" + }, + "ca_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ca File" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OllamaConfig" + } + }, + "required": [ + "model_id", + "name", + "url" + ], + "title": "OllamaConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseOpenAiCompatibleConfig": { + "anyOf": [ + { + "$ref": "#/$defs/OllamaConfig" + }, + { + "$ref": "#/$defs/VllmConfig" + }, + { + "additionalProperties": false, + "description": "Class to configure a connection to an LLM that is compatible with OpenAI completions APIs.\n\nRequires to specify the url of the APIs to contact.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "title": "Model Id", + "type": "string" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "api_provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Provider" + }, + "api_type": { + "$ref": "#/$defs/OpenAIAPIType", + "default": "chat_completions" + }, + "url": { + "title": "Url", + "type": "string" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "key_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key File" + }, + "cert_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cert File" + }, + "ca_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ca File" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OpenAiCompatibleConfig" + } + }, + "required": [ + "model_id", + "name", + "url" + ], + "title": "OpenAiCompatibleConfig", + "type": "object" + } + ], + "x-abstract-component": false + }, + "BaseOpenAiConfig": { + "additionalProperties": false, + "description": "Class to configure a connection to a OpenAI LLM.\n\nRequires to specify the identity of the model to use.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "title": "Model Id", + "type": "string" + }, + "provider": { + "const": "openai", + "default": "openai", + "title": "Provider", + "type": "string" + }, + "api_provider": { + "const": "openai", + "default": "openai", + "title": "Api Provider", + "type": "string" + }, + "api_type": { + "$ref": "#/$defs/OpenAIAPIType", + "default": "chat_completions" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OpenAiConfig" + } + }, + "required": [ + "model_id", + "name" + ], + "title": "OpenAiConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseOracleDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/MTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/TlsOracleDatabaseConnectionConfig" + } + ], + "x-abstract-component": true + }, + "BaseOracleDatabaseDatastore": { + "additionalProperties": false, + "description": "Datastore that uses Oracle Database as the storage mechanism.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "datastore_schema": { + "additionalProperties": { + "$ref": "#/$defs/Property" + }, + "title": "Datastore Schema", + "type": "object" + }, + "connection_config": { + "$ref": "#/$defs/OracleDatabaseConnectionConfig" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OracleDatabaseDatastore" + } + }, + "required": [ + "connection_config", + "datastore_schema", + "name" + ], + "title": "OracleDatabaseDatastore", + "type": "object", + "x-abstract-component": false + }, + "BaseOutputMessageNode": { + "additionalProperties": false, + "description": "This node appends an agent message to the ongoing flow conversation.\n\n- **Inputs**\n One per variable in the message.\n- **Outputs**\n No outputs.\n- **Branches**\n One, the default next.\n\nExamples\n--------\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import StartNode, EndNode, InputMessageNode, OutputMessageNode, LlmNode\n>>> from pyagentspec.property import StringProperty\n>>> start_node = StartNode(name=\"start\")\n>>> prompt_node = OutputMessageNode(name=\"ask_input\", message=\"What is the paragraph you want to rephrase?\")\n>>> input_node = InputMessageNode(name=\"user_input\", outputs=[StringProperty(title=\"user_input\")])\n>>> llm_node = LlmNode(\n... name=\"rephrase\",\n... llm_config=llm_config,\n... prompt_template=\"Rephrase {{user_input}}\",\n... outputs=[StringProperty(title=\"rephrased_user_input\")],\n... )\n>>> output_node = OutputMessageNode(name=\"ask_input\", message=\"{{rephrased_user_input}}\")\n>>> end_node = EndNode(name=\"end\")\n>>> flow = Flow(\n... name=\"rephrase_paragraph_flow\",\n... start_node=start_node,\n... nodes=[start_node, prompt_node, input_node, llm_node, output_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"ce1\", from_node=start_node, to_node=prompt_node),\n... ControlFlowEdge(name=\"ce2\", from_node=prompt_node, to_node=input_node),\n... ControlFlowEdge(name=\"ce3\", from_node=input_node, to_node=llm_node),\n... ControlFlowEdge(name=\"ce4\", from_node=llm_node, to_node=output_node),\n... ControlFlowEdge(name=\"ce5\", from_node=output_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"de1\",\n... source_node=input_node,\n... source_output=\"user_input\",\n... destination_node=llm_node,\n... destination_input=\"user_input\",\n... ),\n... DataFlowEdge(\n... name=\"de2\",\n... source_node=llm_node,\n... source_output=\"rephrased_user_input\",\n... destination_node=output_node,\n... destination_input=\"rephrased_user_input\",\n... ),\n... ]\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "message": { + "title": "Message", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OutputMessageNode" + } + }, + "required": [ + "message", + "name" + ], + "title": "OutputMessageNode", + "type": "object", + "x-abstract-component": false + }, + "BaseParallelFlowNode": { + "additionalProperties": false, + "description": "The parallel flow node executes multiple subflows in parallel.\n\n- **Inputs**\n Inferred from the inner structure. It's the union of the sets of inputs of the inner flows.\n Inputs of different inner flows that have the same name are merged if they have the same type.\n\n- **Outputs**\n Inferred from the inner structure. It's the union of the outputs of the inner flows.\n Outputs of different inner flows that have the same name are not allowed.\n\n- **Branches**\n One, the default next.\n\nExamples\n--------", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "subflows": { + "items": { + "$ref": "#/$defs/Flow" + }, + "title": "Subflows", + "type": "array" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ParallelFlowNode" + } + }, + "required": [ + "name" + ], + "title": "ParallelFlowNode", + "type": "object", + "x-abstract-component": false + }, + "BaseParallelMapNode": { + "additionalProperties": false, + "description": "The parallel map node executes a subflow on each element of a given input in a parallel manner.\n\n- **Inputs**\n Inferred from the inner structure. It's the sets of inputs\n required by the StartNode of the inner flow.\n The names of the inputs will be the ones of the inner flow,\n complemented with the ``iterated_`` prefix. Their type is\n ``Union[inner_type, List[inner_type]]``, where ``inner_type``\n is the type of the respective input in the inner flow.\n\n If None is given, ``pyagentspec`` infers input properties directly from the inner flow,\n specifying title and type according to the rules defined above.\n\n- **Outputs**\n Inferred from the inner structure. It's the union of the\n sets of outputs exposed by the EndNodes of the inner flow,\n combined with the reducer method of each output.\n The names of the outputs will be the ones of the inner flow,\n complemented with the ``collected_`` prefix. Their type depends\n on the ``reduce`` method specified for that output:\n\n - ``List`` of the respective output type in case of ``append``\n - same type of the respective output type in case of ``sum``, ``avg``\n\n If None is given, ``pyagentspec`` infers outputs by exposing\n an output property for each entry in the ``reducers`` dictionary, specifying title\n and type according to the rules defined above.\n\n- **Branches**\n One, the default next.\n\nExamples\n--------\nIn this example we create a flow that generates a summary of the given articles that talk about LLMs.\n\n>>> from pyagentspec.property import BooleanProperty, StringProperty, ListProperty\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import EndNode, StartNode, ParallelMapNode, LlmNode, BranchingNode\n>>> from pyagentspec.flows.node import Node\n\nFirst we define the flow that determines if an article talks about LLMs or not:\n\n- if it does, we return it, so that we will use it for our summary\n- if it does not, we don't return its text\n\n>>> def create_data_flow_edge(source_node: Node, destination_node: Node, property_name: str) -> DataFlowEdge:\n... return DataFlowEdge(\n... name=f\"{source_node.name}_{destination_node.name}_{property_name}_edge\",\n... source_node=source_node,\n... source_output=property_name,\n... destination_node=destination_node,\n... destination_input=property_name,\n... )\n>>>\n>>> article_property = StringProperty(title=\"article\")\n>>> is_llm_article_property = StringProperty(title=\"is_article\")\n>>> llm_node = LlmNode(\n... name=\"check_if_article_talks_about_llms_node\",\n... prompt_template=\"Look at this article: {{article}}. Does it talk about LLMs? Answer `yes` or `no`.\",\n... llm_config=llm_config,\n... inputs=[article_property],\n... outputs=[is_llm_article_property],\n... )\n>>>\n>>> branching_node = BranchingNode(\n... name=\"decide_if_we_should_return_the_article\",\n... mapping={\"yes\": \"yes\"},\n... inputs=[is_llm_article_property],\n... )\n>>>\n>>> start_node = StartNode(name=\"start\", inputs=[article_property])\n>>> end_node_with_output = EndNode(name=\"end_with_output\", outputs=[article_property])\n>>> end_node_without_output = EndNode(name=\"end_without_output\", outputs=[])\n>>>\n>>> check_if_article_is_about_llm_flow = Flow(\n... name=\"is_article_about_llm_flow\",\n... start_node=start_node,\n... nodes=[start_node, llm_node, end_node_with_output, end_node_without_output, branching_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_llm\", from_node=start_node, to_node=llm_node),\n... ControlFlowEdge(name=\"llm_to_branching\", from_node=llm_node, to_node=branching_node),\n... ControlFlowEdge(name=\"branching_to_end_with\", from_node=branching_node, to_node=end_node_with_output),\n... ControlFlowEdge(name=\"branching_to_end_without\", from_node=branching_node, to_node=end_node_without_output),\n... ],\n... data_flow_connections=[\n... create_data_flow_edge(start_node, llm_node, article_property.title),\n... create_data_flow_edge(llm_node, branching_node, is_llm_article_property.title),\n... create_data_flow_edge(start_node, end_node_with_output, article_property.title),\n... ],\n... outputs=[StringProperty(title=article_property.title, default=\"\")],\n... )\n\nWe put this flow into a ``ParallelMapNode``, so that we can perform the check\nin parallel on multiple articles at the same time.\n\n>>> list_of_articles_property = ListProperty(title=\"iterated_article\", item_type=article_property)\n>>> list_of_articles_about_llm_property = ListProperty(title=\"collected_article\", item_type=article_property)\n>>> parallel_article_check_node = ParallelMapNode(\n... name=\"parallel_check_if_articles_talk_about_llms_node\",\n... subflow=check_if_article_is_about_llm_flow,\n... inputs=[list_of_articles_property],\n... outputs=[list_of_articles_about_llm_property],\n... )\n\nFinally, we create the flow that takes the list of articles as input, filters them through the\n``ParallelMapNode`` we have just created, and we generate the summary with the remaining articles.\n\n>>> summary_property = StringProperty(title=\"summary\")\n>>> summary_llm_node = LlmNode(\n... name=\"generate_summary_of_llm_articles_node\",\n... prompt_template=\"Summarize the following articles that talk about LLMs: {{collected_article}}\",\n... llm_config=llm_config,\n... inputs=[list_of_articles_about_llm_property],\n... outputs=[summary_property],\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[list_of_articles_property])\n>>> end_node = EndNode(name=\"end\", outputs=[summary_property])\n>>> generate_summary_of_llm_articles_flow = Flow(\n... name=\"Generate summary of articles about LLMs flow\",\n... start_node=start_node,\n... nodes=[start_node, parallel_article_check_node, summary_llm_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_parallelmap\", from_node=start_node, to_node=parallel_article_check_node),\n... ControlFlowEdge(name=\"parallelmap_to_llm\", from_node=parallel_article_check_node, to_node=summary_llm_node),\n... ControlFlowEdge(name=\"llm_to_end\", from_node=summary_llm_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... create_data_flow_edge(start_node, parallel_article_check_node, list_of_articles_property.title),\n... create_data_flow_edge(parallel_article_check_node, summary_llm_node, list_of_articles_about_llm_property.title),\n... create_data_flow_edge(summary_llm_node, end_node, summary_property.title),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "subflow": { + "$ref": "#/$defs/Flow" + }, + "reducers": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/$defs/ReductionMethod" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reducers" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ParallelMapNode" + } + }, + "required": [ + "name", + "subflow" + ], + "title": "ParallelMapNode", + "type": "object", + "x-abstract-component": false + }, + "BasePostgresDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/TlsPostgresDatabaseConnectionConfig" + } + ], + "x-abstract-component": true + }, + "BasePostgresDatabaseDatastore": { + "additionalProperties": false, + "description": "Datastore that uses PostgreSQL Database as the storage mechanism.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "datastore_schema": { + "additionalProperties": { + "$ref": "#/$defs/Property" + }, + "title": "Datastore Schema", + "type": "object" + }, + "connection_config": { + "$ref": "#/$defs/PostgresDatabaseConnectionConfig" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "PostgresDatabaseDatastore" + } + }, + "required": [ + "connection_config", + "datastore_schema", + "name" + ], + "title": "PostgresDatabaseDatastore", + "type": "object", + "x-abstract-component": false + }, + "BaseRemoteAgent": { + "anyOf": [ + { + "$ref": "#/$defs/A2AAgent" + }, + { + "$ref": "#/$defs/OciAgent" + } + ], + "x-abstract-component": true + }, + "BaseRemoteTool": { + "additionalProperties": false, + "description": "A tool that is run remotely and called through REST.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "requires_confirmation": { + "default": false, + "title": "Requires Confirmation", + "type": "boolean" + }, + "url": { + "title": "Url", + "type": "string" + }, + "http_method": { + "title": "Http Method", + "type": "string" + }, + "api_spec_uri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Spec Uri" + }, + "data": { + "anyOf": [ + { + "type": "string" + }, + { + "format": "binary", + "type": "string" + }, + {} + ], + "title": "Data" + }, + "query_params": { + "additionalProperties": true, + "title": "Query Params", + "type": "object" + }, + "headers": { + "additionalProperties": true, + "title": "Headers", + "type": "object" + }, + "sensitive_headers": { + "additionalProperties": true, + "title": "Sensitive Headers", + "type": "object" + }, + "url_allow_list": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url Allow List" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "RemoteTool" + } + }, + "required": [ + "http_method", + "name", + "url" + ], + "title": "RemoteTool", + "type": "object", + "x-abstract-component": false + }, + "BaseRemoteTransport": { + "anyOf": [ + { + "$ref": "#/$defs/SSETransport" + }, + { + "$ref": "#/$defs/SSEmTLSTransport" + }, + { + "$ref": "#/$defs/StreamableHTTPTransport" + }, + { + "$ref": "#/$defs/StreamableHTTPmTLSTransport" + } + ], + "x-abstract-component": true + }, + "BaseSSETransport": { + "anyOf": [ + { + "$ref": "#/$defs/SSEmTLSTransport" + }, + { + "additionalProperties": false, + "description": "Transport implementation that connects to an MCP server via Server-Sent Events.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "session_parameters": { + "$ref": "#/$defs/SessionParameters" + }, + "url": { + "title": "Url", + "type": "string" + }, + "auth": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/AuthConfig" + } + ], + "default": null + }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Headers" + }, + "sensitive_headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sensitive Headers" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "SSETransport" + } + }, + "required": [ + "name", + "url" + ], + "title": "SSETransport", + "type": "object" + } + ], + "x-abstract-component": false + }, + "BaseSSEmTLSTransport": { + "additionalProperties": false, + "description": "Transport layer for SSE with mTLS (mutual Transport Layer Security).\n\nThis transport establishes a secure, mutually authenticated TLS connection to the MCP server using client\ncertificates. Production deployments MUST use this transport to ensure both client and server identities\nare verified.\n\nNotes\n-----\n- Users MUST provide a valid client certificate (PEM format) and private key.\n- Users MUST provide (or trust) the correct certificate authority (CA) for the server they're connecting to.\n- The client certificate/key and CA certificate paths can be managed via secrets, config files, or secure\n environment variables in any production system.\n- Executors should ensure that these files are rotated and managed securely.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "session_parameters": { + "$ref": "#/$defs/SessionParameters" + }, + "url": { + "title": "Url", + "type": "string" + }, + "auth": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/AuthConfig" + } + ], + "default": null + }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Headers" + }, + "sensitive_headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sensitive Headers" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "key_file": { + "title": "Key File", + "type": "string" + }, + "cert_file": { + "title": "Cert File", + "type": "string" + }, + "ca_file": { + "title": "Ca File", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "SSEmTLSTransport" + } + }, + "required": [ + "ca_file", + "cert_file", + "key_file", + "name", + "url" + ], + "title": "SSEmTLSTransport", + "type": "object", + "x-abstract-component": false + }, + "BaseServerTool": { + "additionalProperties": false, + "description": "A tool that is registered to and executed by the orchestrator.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "requires_confirmation": { + "default": false, + "title": "Requires Confirmation", + "type": "boolean" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ServerTool" + } + }, + "required": [ + "name" + ], + "title": "ServerTool", + "type": "object", + "x-abstract-component": false + }, + "BaseSpecializedAgent": { + "additionalProperties": false, + "description": "A specialized agent is an agent that uses an existing generic agent and\nspecializes it to solve a given task.\n\nIt can be executed anywhere an Agent can be executed.\n\nExamples\n--------\n>>> from pyagentspec.agent import Agent\n>>> from pyagentspec.property import StringProperty\n>>> from pyagentspec.specialized_agent import AgentSpecializationParameters, SpecializedAgent\n>>> from pyagentspec.tools import ServerTool\n>>> expertise_property = StringProperty(title=\"domain_of_expertise\")\n>>> system_prompt = '''You are an expert in {{domain_of_expertise}}.\n... Please help the users with their requests.'''\n>>> agent = Agent(\n... name=\"Adaptive expert agent\",\n... system_prompt=system_prompt,\n... llm_config=llm_config,\n... inputs=[expertise_property],\n... )\n>>> websearch_tool = ServerTool(\n... name=\"websearch_tool\",\n... description=\"Search the web for information\",\n... inputs=[StringProperty(title=\"query\")],\n... outputs=[StringProperty(title=\"search_result\")],\n... )\n>>> agent_specialization_parameters = AgentSpecializationParameters(\n... name=\"essay_agent\",\n... additional_instructions=\"Your goal is to help the user write an essay around the domain of expertise.\",\n... additional_tools=[websearch_tool]\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "agent": { + "$ref": "#/$defs/Agent" + }, + "agent_specialization_parameters": { + "$ref": "#/$defs/AgentSpecializationParameters" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "SpecializedAgent" + } + }, + "required": [ + "agent", + "agent_specialization_parameters", + "name" + ], + "title": "SpecializedAgent", + "type": "object", + "x-abstract-component": false + }, + "BaseStartNode": { + "additionalProperties": false, + "description": "Start nodes denote the start of the execution of a flow.\n\n- **Inputs**\n The list of inputs that should be the inputs of the flow. If both input and output\n properties are specified they must be an exact match\n\n If None is given, ``pyagentspec`` copies the outputs provided, if any. Otherwise, no input is exposed.\n- **Outputs**\n The list of outputs of the step. If both input and output properties are specified they\n must be an exact match\n\n If None is given, ``pyagentspec`` copies the inputs provided, if any. Otherwise, no output is exposed.\n- **Branches**\n One, the default next.\n\nExamples\n--------\n>>> from pyagentspec.property import Property\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import EndNode, LlmNode, StartNode\n>>> user_question_property = Property(\n... json_schema=dict(\n... title=\"user_question\",\n... description=\"The user question.\",\n... type=\"string\",\n... )\n... )\n>>> answer_property = Property(json_schema=dict(title=\"answer\", type=\"string\"))\n>>> start_node = StartNode(name=\"start\", inputs=[user_question_property])\n>>> end_node = EndNode(name=\"end\", outputs=[answer_property])\n>>> llm_node = LlmNode(\n... name=\"llm node\",\n... prompt_template=\"Answer the user question: {{user_question}}\",\n... llm_config=llm_config,\n... )\n>>> flow = Flow(\n... name=\"flow\",\n... start_node=start_node,\n... nodes=[start_node, llm_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_llm\", from_node=start_node, to_node=llm_node),\n... ControlFlowEdge(name=\"llm_to_end\", from_node=llm_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"query_edge\",\n... source_node=start_node,\n... source_output=\"user_question\",\n... destination_node=llm_node,\n... destination_input=\"user_question\",\n... ),\n... DataFlowEdge(\n... name=\"answer_edge\",\n... source_node=llm_node,\n... source_output=\"generated_text\",\n... destination_node=end_node,\n... destination_input=\"answer\"\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "StartNode" + } + }, + "required": [ + "name" + ], + "title": "StartNode", + "type": "object", + "x-abstract-component": false + }, + "BaseStdioTransport": { + "additionalProperties": false, + "description": "Base transport for connecting to an MCP server via subprocess with stdio.\n\nThis is a base class that can be subclassed for specific command-based\ntransports like Python, Node, Uvx, etc.\n\n.. warning::\n Stdio should be used for local prototyping only.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "session_parameters": { + "$ref": "#/$defs/SessionParameters" + }, + "command": { + "title": "Command", + "type": "string" + }, + "args": { + "items": { + "type": "string" + }, + "title": "Args", + "type": "array" + }, + "env": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Env" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "StdioTransport" + } + }, + "required": [ + "command", + "name" + ], + "title": "StdioTransport", + "type": "object", + "x-abstract-component": false + }, + "BaseStreamableHTTPTransport": { + "anyOf": [ + { + "$ref": "#/$defs/StreamableHTTPmTLSTransport" + }, + { + "additionalProperties": false, + "description": "Transport implementation that connects to an MCP server via Streamable HTTP.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "session_parameters": { + "$ref": "#/$defs/SessionParameters" + }, + "url": { + "title": "Url", + "type": "string" + }, + "auth": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/AuthConfig" + } + ], + "default": null + }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Headers" + }, + "sensitive_headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sensitive Headers" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "StreamableHTTPTransport" + } + }, + "required": [ + "name", + "url" + ], + "title": "StreamableHTTPTransport", + "type": "object" + } + ], + "x-abstract-component": false + }, + "BaseStreamableHTTPmTLSTransport": { + "additionalProperties": false, + "description": "Transport layer for streamable HTTP with mTLS (mutual Transport Layer Security).\n\nThis transport establishes a secure, mutually authenticated TLS connection to the MCP server using client\ncertificates. Production deployments MUST use this transport to ensure both client and server identities\nare verified.\n\nNotes\n-----\n- Users MUST provide a valid client certificate (PEM format) and private key.\n- Users MUST provide (or trust) the correct certificate authority (CA) for the server they're connecting to.\n- The client certificate/key and CA certificate paths can be managed via secrets, config files, or secure\n environment variables in any production system.\n- Executors should ensure that these files are rotated and managed securely.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "session_parameters": { + "$ref": "#/$defs/SessionParameters" + }, + "url": { + "title": "Url", + "type": "string" + }, + "auth": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/AuthConfig" + } + ], + "default": null + }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Headers" + }, + "sensitive_headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sensitive Headers" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "key_file": { + "title": "Key File", + "type": "string" + }, + "cert_file": { + "title": "Cert File", + "type": "string" + }, + "ca_file": { + "title": "Ca File", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "StreamableHTTPmTLSTransport" + } + }, + "required": [ + "ca_file", + "cert_file", + "key_file", + "name", + "url" + ], + "title": "StreamableHTTPmTLSTransport", + "type": "object", + "x-abstract-component": false + }, + "BaseSwarm": { + "additionalProperties": false, + "description": "Defines a ``Swarm`` conversational component.\n\nA ``Swarm`` is a multi-agent conversational component in which each agent determines\nthe next agent to be executed, based on a list of pre-defined relationships.\nAgents in Swarm can be any ``AgenticComponent``.\n\nExamples\n--------\n>>> from pyagentspec.agent import Agent\n>>> from pyagentspec.swarm import Swarm\n>>> addition_agent = Agent(name=\"addition_agent\", description=\"Agent that can do additions\", llm_config=llm_config, system_prompt=\"You can do additions.\")\n>>> multiplication_agent = Agent(name=\"multiplication_agent\", description=\"Agent that can do multiplication\", llm_config=llm_config, system_prompt=\"You can do multiplication.\")\n>>> division_agent = Agent(name=\"division_agent\", description=\"Agent that can do division\", llm_config=llm_config, system_prompt=\"You can do division.\")\n>>>\n>>> swarm = Swarm(\n... name=\"swarm\",\n... first_agent=addition_agent,\n... relationships=[\n... (addition_agent, multiplication_agent),\n... (addition_agent, division_agent),\n... (multiplication_agent, division_agent),\n... ]\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "first_agent": { + "$ref": "#/$defs/AgenticComponent" + }, + "relationships": { + "items": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "$ref": "#/$defs/AgenticComponent" + }, + { + "$ref": "#/$defs/AgenticComponent" + } + ], + "type": "array" + }, + "title": "Relationships", + "type": "array" + }, + "handoff": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/$defs/HandoffMode" + } + ], + "default": "optional", + "title": "Handoff" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "Swarm" + } + }, + "required": [ + "first_agent", + "name", + "relationships" + ], + "title": "Swarm", + "type": "object", + "x-abstract-component": false + }, + "BaseTlsOracleDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/MTlsOracleDatabaseConnectionConfig" + }, + { + "additionalProperties": false, + "description": "TLS Connection Configuration to Oracle Database.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "user": { + "title": "User", + "type": "string" + }, + "password": { + "title": "Password", + "type": "string" + }, + "dsn": { + "title": "Dsn", + "type": "string" + }, + "config_dir": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Config Dir" + }, + "protocol": { + "default": "tcps", + "enum": [ + "tcp", + "tcps" + ], + "title": "Protocol", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "TlsOracleDatabaseConnectionConfig" + } + }, + "required": [ + "dsn", + "name", + "password", + "user" + ], + "title": "TlsOracleDatabaseConnectionConfig", + "type": "object" + } + ], + "x-abstract-component": false + }, + "BaseTlsPostgresDatabaseConnectionConfig": { + "additionalProperties": false, + "description": "Configuration for a PostgreSQL connection with TLS/SSL support.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "user": { + "title": "User", + "type": "string" + }, + "password": { + "title": "Password", + "type": "string" + }, + "url": { + "title": "Url", + "type": "string" + }, + "sslmode": { + "default": "require", + "enum": [ + "allow", + "disable", + "prefer", + "require", + "verify-ca", + "verify-full" + ], + "title": "Sslmode", + "type": "string" + }, + "sslcert": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sslcert" + }, + "sslkey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sslkey" + }, + "sslrootcert": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sslrootcert" + }, + "sslcrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sslcrl" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "TlsPostgresDatabaseConnectionConfig" + } + }, + "required": [ + "name", + "password", + "url", + "user" + ], + "title": "TlsPostgresDatabaseConnectionConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseTool": { + "anyOf": [ + { + "$ref": "#/$defs/BuiltinTool" + }, + { + "$ref": "#/$defs/ClientTool" + }, + { + "$ref": "#/$defs/MCPTool" + }, + { + "$ref": "#/$defs/RemoteTool" + }, + { + "$ref": "#/$defs/ServerTool" + } + ], + "x-abstract-component": true + }, + "BaseToolBox": { + "anyOf": [ + { + "$ref": "#/$defs/MCPToolBox" + } + ], + "x-abstract-component": true + }, + "BaseToolNode": { + "additionalProperties": false, + "description": "The tool execution node is a node that will execute a tool as part of a flow.\n\n- **Inputs**\n Inferred from the definition of the tool to execute.\n- **Outputs**\n Inferred from the definition of the tool to execute.\n- **Branches**\n One, the default next.\n\nExamples\n--------\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import ToolNode, StartNode, EndNode\n>>> from pyagentspec.tools import ServerTool\n>>> from pyagentspec.property import Property\n>>>\n>>> x_property = Property(json_schema={\"title\": \"x\", \"type\": \"number\"})\n>>> x_square_root_property = Property(\n... json_schema={\"title\": \"x_square_root\", \"type\": \"number\"}\n... )\n>>> square_root_tool = ServerTool(\n... name=\"compute_square_root\",\n... description=\"Computes the square root of a number\",\n... inputs=[x_property],\n... outputs=[x_square_root_property],\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[x_property])\n>>> end_node = EndNode(name=\"end\", outputs=[x_square_root_property])\n>>> tool_node = ToolNode(name=\"\", tool=square_root_tool)\n>>> flow = Flow(\n... name=\"Compute square root flow\",\n... start_node=start_node,\n... nodes=[start_node, tool_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_tool\", from_node=start_node, to_node=tool_node),\n... ControlFlowEdge(name=\"tool_to_end\", from_node=tool_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"x_edge\",\n... source_node=start_node,\n... source_output=\"x\",\n... destination_node=tool_node,\n... destination_input=\"x\",\n... ),\n... DataFlowEdge(\n... name=\"x_square_root_edge\",\n... source_node=tool_node,\n... source_output=\"x_square_root\",\n... destination_node=end_node,\n... destination_input=\"x_square_root\"\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "tool": { + "$ref": "#/$defs/Tool" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ToolNode" + } + }, + "required": [ + "name", + "tool" + ], + "title": "ToolNode", + "type": "object", + "x-abstract-component": false + }, + "BaseVllmConfig": { + "additionalProperties": false, + "description": "Class to configure a connection to a vLLM-hosted LLM.\n\nRequires to specify the url at which the instance is running.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "title": "Model Id", + "type": "string" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "api_provider": { + "const": "vllm", + "default": "vllm", + "title": "Api Provider", + "type": "string" + }, + "api_type": { + "$ref": "#/$defs/OpenAIAPIType", + "default": "chat_completions" + }, + "url": { + "title": "Url", + "type": "string" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "key_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key File" + }, + "cert_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cert File" + }, + "ca_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ca File" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "VllmConfig" + } + }, + "required": [ + "model_id", + "name", + "url" + ], + "title": "VllmConfig", + "type": "object", + "x-abstract-component": false + }, + "ReferencedComponents": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/BaseA2AAgent" + }, + { + "$ref": "#/$defs/BaseA2AConnectionConfig" + }, + { + "$ref": "#/$defs/BaseAgent" + }, + { + "$ref": "#/$defs/BaseAgentNode" + }, + { + "$ref": "#/$defs/BaseAgentSpecializationParameters" + }, + { + "$ref": "#/$defs/BaseAgenticComponent" + }, + { + "$ref": "#/$defs/BaseApiNode" + }, + { + "$ref": "#/$defs/BaseAuthConfig" + }, + { + "$ref": "#/$defs/BaseBranchingNode" + }, + { + "$ref": "#/$defs/BaseBuiltinTool" + }, + { + "$ref": "#/$defs/BaseCatchExceptionNode" + }, + { + "$ref": "#/$defs/BaseClientTool" + }, + { + "$ref": "#/$defs/BaseClientTransport" + }, + { + "$ref": "#/$defs/BaseComponentWithIO" + }, + { + "$ref": "#/$defs/BaseControlFlowEdge" + }, + { + "$ref": "#/$defs/BaseConversationSummarizationTransform" + }, + { + "$ref": "#/$defs/BaseDataFlowEdge" + }, + { + "$ref": "#/$defs/BaseDatastore" + }, + { + "$ref": "#/$defs/BaseDbmsVectorChainLlmConfig" + }, + { + "$ref": "#/$defs/BaseEndNode" + }, + { + "$ref": "#/$defs/BaseFlow" + }, + { + "$ref": "#/$defs/BaseFlowNode" + }, + { + "$ref": "#/$defs/BaseGeminiAIStudioAuthConfig" + }, + { + "$ref": "#/$defs/BaseGeminiAuthConfig" + }, + { + "$ref": "#/$defs/BaseGeminiConfig" + }, + { + "$ref": "#/$defs/BaseGeminiVertexAIAuthConfig" + }, + { + "$ref": "#/$defs/BaseInMemoryCollectionDatastore" + }, + { + "$ref": "#/$defs/BaseInputMessageNode" + }, + { + "$ref": "#/$defs/BaseLlmConfig" + }, + { + "$ref": "#/$defs/BaseLlmNode" + }, + { + "$ref": "#/$defs/BaseMCPTool" + }, + { + "$ref": "#/$defs/BaseMCPToolBox" + }, + { + "$ref": "#/$defs/BaseMCPToolSpec" + }, + { + "$ref": "#/$defs/BaseMTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/BaseManagerWorkers" + }, + { + "$ref": "#/$defs/BaseMapNode" + }, + { + "$ref": "#/$defs/BaseMessageSummarizationTransform" + }, + { + "$ref": "#/$defs/BaseMessageTransform" + }, + { + "$ref": "#/$defs/BaseNode" + }, + { + "$ref": "#/$defs/BaseOAuthClientConfig" + }, + { + "$ref": "#/$defs/BaseOAuthConfig" + }, + { + "$ref": "#/$defs/BaseOciAgent" + }, + { + "$ref": "#/$defs/BaseOciClientConfig" + }, + { + "$ref": "#/$defs/BaseOciClientConfigWithApiKey" + }, + { + "$ref": "#/$defs/BaseOciClientConfigWithInstancePrincipal" + }, + { + "$ref": "#/$defs/BaseOciClientConfigWithResourcePrincipal" + }, + { + "$ref": "#/$defs/BaseOciClientConfigWithSecurityToken" + }, + { + "$ref": "#/$defs/BaseOciGenAiConfig" + }, + { + "$ref": "#/$defs/BaseOllamaConfig" + }, + { + "$ref": "#/$defs/BaseOpenAiCompatibleConfig" + }, + { + "$ref": "#/$defs/BaseOpenAiConfig" + }, + { + "$ref": "#/$defs/BaseOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/BaseOracleDatabaseDatastore" + }, + { + "$ref": "#/$defs/BaseOutputMessageNode" + }, + { + "$ref": "#/$defs/BaseParallelFlowNode" + }, + { + "$ref": "#/$defs/BaseParallelMapNode" + }, + { + "$ref": "#/$defs/BasePostgresDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/BasePostgresDatabaseDatastore" + }, + { + "$ref": "#/$defs/BaseRemoteAgent" + }, + { + "$ref": "#/$defs/BaseRemoteTool" + }, + { + "$ref": "#/$defs/BaseRemoteTransport" + }, + { + "$ref": "#/$defs/BaseSSETransport" + }, + { + "$ref": "#/$defs/BaseSSEmTLSTransport" + }, + { + "$ref": "#/$defs/BaseServerTool" + }, + { + "$ref": "#/$defs/BaseSpecializedAgent" + }, + { + "$ref": "#/$defs/BaseStartNode" + }, + { + "$ref": "#/$defs/BaseStdioTransport" + }, + { + "$ref": "#/$defs/BaseStreamableHTTPTransport" + }, + { + "$ref": "#/$defs/BaseStreamableHTTPmTLSTransport" + }, + { + "$ref": "#/$defs/BaseSwarm" + }, + { + "$ref": "#/$defs/BaseTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/BaseTlsPostgresDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/BaseTool" + }, + { + "$ref": "#/$defs/BaseToolBox" + }, + { + "$ref": "#/$defs/BaseToolNode" + }, + { + "$ref": "#/$defs/BaseVllmConfig" + }, + { + "$ref": "#/$defs/ComponentReferenceWithNestedReferences" + } + ] + } + }, + "ComponentReference": { + "type": "object", + "properties": { + "$component_ref": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "$component_ref" + ] + }, + "ComponentReferenceWithNestedReferences": { + "type": "object", + "properties": { + "$component_ref": { + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + } + }, + "additionalProperties": false, + "required": [ + "$component_ref", + "$referenced_components" + ] + }, + "VersionedA2AAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseA2AAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedA2AConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseA2AConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedAgentNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgentNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedAgentSpecializationParameters": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgentSpecializationParameters" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedAgenticComponent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgenticComponent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedApiNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseApiNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedBranchingNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseBranchingNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedBuiltinTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseBuiltinTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedCatchExceptionNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseCatchExceptionNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedClientTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseClientTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedClientTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseClientTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedComponentWithIO": { + "anyOf": [ + { + "$ref": "#/$defs/BaseComponentWithIO" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedControlFlowEdge": { + "anyOf": [ + { + "$ref": "#/$defs/BaseControlFlowEdge" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedConversationSummarizationTransform": { + "anyOf": [ + { + "$ref": "#/$defs/BaseConversationSummarizationTransform" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedDataFlowEdge": { + "anyOf": [ + { + "$ref": "#/$defs/BaseDataFlowEdge" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/BaseDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedDbmsVectorChainLlmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseDbmsVectorChainLlmConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedEndNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseEndNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedFlow": { + "anyOf": [ + { + "$ref": "#/$defs/BaseFlow" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedFlowNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseFlowNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedGeminiAIStudioAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiAIStudioAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedGeminiAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedGeminiConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedGeminiVertexAIAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiVertexAIAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedInMemoryCollectionDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/BaseInMemoryCollectionDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedInputMessageNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseInputMessageNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedLlmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseLlmConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedLlmNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseLlmNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedMCPTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMCPTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedMCPToolBox": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMCPToolBox" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedMCPToolSpec": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMCPToolSpec" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedMTlsOracleDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedManagerWorkers": { + "anyOf": [ + { + "$ref": "#/$defs/BaseManagerWorkers" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedMapNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMapNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedMessageSummarizationTransform": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMessageSummarizationTransform" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOAuthClientConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOAuthClientConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOciAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOciClientConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOciClientConfigWithApiKey": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithApiKey" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOciClientConfigWithInstancePrincipal": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithInstancePrincipal" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOciClientConfigWithResourcePrincipal": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithResourcePrincipal" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOciClientConfigWithSecurityToken": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithSecurityToken" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOciGenAiConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciGenAiConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOllamaConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOllamaConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOpenAiCompatibleConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOpenAiCompatibleConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOpenAiConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOpenAiConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOracleDatabaseDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOracleDatabaseDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOutputMessageNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOutputMessageNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedParallelFlowNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseParallelFlowNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedParallelMapNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseParallelMapNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedPostgresDatabaseDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/BasePostgresDatabaseDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedRemoteAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseRemoteAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedRemoteTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseRemoteTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedRemoteTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseRemoteTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedSSETransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSSETransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedSSEmTLSTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSSEmTLSTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedServerTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseServerTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedSpecializedAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSpecializedAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedStartNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStartNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedStdioTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStdioTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedStreamableHTTPTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStreamableHTTPTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedStreamableHTTPmTLSTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStreamableHTTPmTLSTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedSwarm": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSwarm" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedTlsOracleDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedTlsPostgresDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseTlsPostgresDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedToolBox": { + "anyOf": [ + { + "$ref": "#/$defs/BaseToolBox" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedToolNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseToolNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedVllmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseVllmConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedComponentReferenceWithNestedReferences": { + "type": "object", + "properties": { + "$component_ref": { + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + }, + "additionalProperties": false, + "required": [ + "$component_ref", + "$referenced_components" + ] + } + }, + "anyOf": [ + { + "$ref": "#/$defs/VersionedA2AAgent" + }, + { + "$ref": "#/$defs/VersionedA2AConnectionConfig" + }, + { + "$ref": "#/$defs/VersionedAgent" + }, + { + "$ref": "#/$defs/VersionedAgentNode" + }, + { + "$ref": "#/$defs/VersionedAgentSpecializationParameters" + }, + { + "$ref": "#/$defs/VersionedAgenticComponent" + }, + { + "$ref": "#/$defs/VersionedApiNode" + }, + { + "$ref": "#/$defs/VersionedBranchingNode" + }, + { + "$ref": "#/$defs/VersionedBuiltinTool" + }, + { + "$ref": "#/$defs/VersionedCatchExceptionNode" + }, + { + "$ref": "#/$defs/VersionedClientTool" + }, + { + "$ref": "#/$defs/VersionedClientTransport" + }, + { + "$ref": "#/$defs/VersionedComponentReferenceWithNestedReferences" + }, + { + "$ref": "#/$defs/VersionedComponentWithIO" + }, + { + "$ref": "#/$defs/VersionedControlFlowEdge" + }, + { + "$ref": "#/$defs/VersionedConversationSummarizationTransform" + }, + { + "$ref": "#/$defs/VersionedDataFlowEdge" + }, + { + "$ref": "#/$defs/VersionedDatastore" + }, + { + "$ref": "#/$defs/VersionedDbmsVectorChainLlmConfig" + }, + { + "$ref": "#/$defs/VersionedEndNode" + }, + { + "$ref": "#/$defs/VersionedFlow" + }, + { + "$ref": "#/$defs/VersionedFlowNode" + }, + { + "$ref": "#/$defs/VersionedGeminiAIStudioAuthConfig" + }, + { + "$ref": "#/$defs/VersionedGeminiAuthConfig" + }, + { + "$ref": "#/$defs/VersionedGeminiConfig" + }, + { + "$ref": "#/$defs/VersionedGeminiVertexAIAuthConfig" + }, + { + "$ref": "#/$defs/VersionedInMemoryCollectionDatastore" + }, + { + "$ref": "#/$defs/VersionedInputMessageNode" + }, + { + "$ref": "#/$defs/VersionedLlmConfig" + }, + { + "$ref": "#/$defs/VersionedLlmNode" + }, + { + "$ref": "#/$defs/VersionedMCPTool" + }, + { + "$ref": "#/$defs/VersionedMCPToolBox" + }, + { + "$ref": "#/$defs/VersionedMCPToolSpec" + }, + { + "$ref": "#/$defs/VersionedMTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/VersionedManagerWorkers" + }, + { + "$ref": "#/$defs/VersionedMapNode" + }, + { + "$ref": "#/$defs/VersionedMessageSummarizationTransform" + }, + { + "$ref": "#/$defs/VersionedNode" + }, + { + "$ref": "#/$defs/VersionedOAuthClientConfig" + }, + { + "$ref": "#/$defs/VersionedOAuthConfig" + }, + { + "$ref": "#/$defs/VersionedOciAgent" + }, + { + "$ref": "#/$defs/VersionedOciClientConfig" + }, + { + "$ref": "#/$defs/VersionedOciClientConfigWithApiKey" + }, + { + "$ref": "#/$defs/VersionedOciClientConfigWithInstancePrincipal" + }, + { + "$ref": "#/$defs/VersionedOciClientConfigWithResourcePrincipal" + }, + { + "$ref": "#/$defs/VersionedOciClientConfigWithSecurityToken" + }, + { + "$ref": "#/$defs/VersionedOciGenAiConfig" + }, + { + "$ref": "#/$defs/VersionedOllamaConfig" + }, + { + "$ref": "#/$defs/VersionedOpenAiCompatibleConfig" + }, + { + "$ref": "#/$defs/VersionedOpenAiConfig" + }, + { + "$ref": "#/$defs/VersionedOracleDatabaseDatastore" + }, + { + "$ref": "#/$defs/VersionedOutputMessageNode" + }, + { + "$ref": "#/$defs/VersionedParallelFlowNode" + }, + { + "$ref": "#/$defs/VersionedParallelMapNode" + }, + { + "$ref": "#/$defs/VersionedPostgresDatabaseDatastore" + }, + { + "$ref": "#/$defs/VersionedRemoteAgent" + }, + { + "$ref": "#/$defs/VersionedRemoteTool" + }, + { + "$ref": "#/$defs/VersionedRemoteTransport" + }, + { + "$ref": "#/$defs/VersionedSSETransport" + }, + { + "$ref": "#/$defs/VersionedSSEmTLSTransport" + }, + { + "$ref": "#/$defs/VersionedServerTool" + }, + { + "$ref": "#/$defs/VersionedSpecializedAgent" + }, + { + "$ref": "#/$defs/VersionedStartNode" + }, + { + "$ref": "#/$defs/VersionedStdioTransport" + }, + { + "$ref": "#/$defs/VersionedStreamableHTTPTransport" + }, + { + "$ref": "#/$defs/VersionedStreamableHTTPmTLSTransport" + }, + { + "$ref": "#/$defs/VersionedSwarm" + }, + { + "$ref": "#/$defs/VersionedTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/VersionedTlsPostgresDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/VersionedTool" + }, + { + "$ref": "#/$defs/VersionedToolBox" + }, + { + "$ref": "#/$defs/VersionedToolNode" + }, + { + "$ref": "#/$defs/VersionedVllmConfig" + } + ] +} diff --git a/docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_4_0.json b/docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_4_0.json new file mode 100644 index 00000000..b8b2ff74 --- /dev/null +++ b/docs/pyagentspec/source/agentspec/json_spec/agentspec_json_spec_26_4_0.json @@ -0,0 +1,9418 @@ +{ + "$defs": { + "A2AAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseA2AAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "A2AConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseA2AConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "A2ASessionParameters": { + "description": "Class to specify parameters of the A2A session.", + "properties": { + "timeout": { + "default": 60.0, + "title": "Timeout", + "type": "number" + }, + "poll_interval": { + "default": 2.0, + "title": "Poll Interval", + "type": "number" + }, + "max_retries": { + "default": 5, + "title": "Max Retries", + "type": "integer" + } + }, + "title": "A2ASessionParameters", + "type": "object" + }, + "Agent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "AgentNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgentNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "AgentSpecVersionEnum": { + "description": "An Enumeration for different versions of Agent Spec.", + "enum": [ + "25.4.1", + "25.4.2", + "26.1.0", + "26.1.2", + "26.3.0", + "26.4.0" + ], + "title": "AgentSpecVersionEnum", + "type": "string" + }, + "AgentSpecializationParameters": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgentSpecializationParameters" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "AgenticComponent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgenticComponent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ApiNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseApiNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "AuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "BranchingNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseBranchingNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "BuiltinTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseBuiltinTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "CatchExceptionNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseCatchExceptionNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ClientTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseClientTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ClientTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseClientTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ComponentWithIO": { + "anyOf": [ + { + "$ref": "#/$defs/BaseComponentWithIO" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ControlFlowEdge": { + "anyOf": [ + { + "$ref": "#/$defs/BaseControlFlowEdge" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ConversationSummarizationTransform": { + "anyOf": [ + { + "$ref": "#/$defs/BaseConversationSummarizationTransform" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "DataFlowEdge": { + "anyOf": [ + { + "$ref": "#/$defs/BaseDataFlowEdge" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "Datastore": { + "anyOf": [ + { + "$ref": "#/$defs/BaseDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "DbmsVectorChainLlmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseDbmsVectorChainLlmConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "EndNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseEndNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "Flow": { + "anyOf": [ + { + "$ref": "#/$defs/BaseFlow" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "FlowNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseFlowNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "GeminiAIStudioAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiAIStudioAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "GeminiAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "GeminiConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "GeminiVertexAIAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiVertexAIAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "HandoffMode": { + "description": "Controls how agents in a Swarm may delegate work to one another.\n\nThis setting determines whether an agent is equipped with:\n\n * *send_message* \u2014 a tool for asking another agent to perform a sub-task and reply back.\n\n * *handoff_conversation* \u2014 a tool for transferring the full user\u2013agent conversation to another agent.\n\nDepending on the selected mode, agents have different capabilities for delegation and collaboration.", + "enum": [ + "always", + "never", + "optional" + ], + "title": "HandoffMode", + "type": "string" + }, + "InMemoryCollectionDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/BaseInMemoryCollectionDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "InputMessageNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseInputMessageNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "LlmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseLlmConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "LlmGenerationConfig": { + "additionalProperties": true, + "description": "A configuration object defining LLM generation parameters.\n\nParameters include number of tokens, sampling parameters, etc.", + "properties": { + "max_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Tokens" + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Temperature" + }, + "top_p": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Top P" + } + }, + "title": "LlmGenerationConfig", + "type": "object" + }, + "LlmNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseLlmNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "MCPTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMCPTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "MCPToolBox": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMCPToolBox" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "MCPToolSpec": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMCPToolSpec" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "MTlsOracleDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ManagerWorkers": { + "anyOf": [ + { + "$ref": "#/$defs/BaseManagerWorkers" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "MapNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMapNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "MessageSummarizationTransform": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMessageSummarizationTransform" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "MessageTransform": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMessageTransform" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ModelProvider": { + "description": "Provider of the model. It is used to ensure the requests to this model respect\nthe format expected by the provider.", + "enum": [ + "COHERE", + "GROK", + "META", + "OTHER", + "XAI" + ], + "title": "ModelProvider", + "type": "string" + }, + "Node": { + "anyOf": [ + { + "$ref": "#/$defs/BaseNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OAuthClientConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOAuthClientConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OAuthEndpoints": { + "description": "Explicit OAuth endpoint configuration.\n\nUse this component when endpoint discovery is not available or not desired.\nThis groups the relevant endpoints required to execute OAuth authorization\ncode flows and token refresh.", + "properties": { + "authorization_endpoint": { + "title": "Authorization Endpoint", + "type": "string" + }, + "token_endpoint": { + "title": "Token Endpoint", + "type": "string" + }, + "refresh_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Refresh Endpoint" + }, + "revocation_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Revocation Endpoint" + }, + "userinfo_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Userinfo Endpoint" + } + }, + "required": [ + "authorization_endpoint", + "token_endpoint" + ], + "title": "OAuthEndpoints", + "type": "object" + }, + "OciAPIType": { + "description": "Enumeration of API Types.", + "enum": [ + "oci", + "openai_chat_completions", + "openai_responses" + ], + "title": "OciAPIType", + "type": "string" + }, + "OciAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OciClientConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OciClientConfigWithApiKey": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithApiKey" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OciClientConfigWithInstancePrincipal": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithInstancePrincipal" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OciClientConfigWithResourcePrincipal": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithResourcePrincipal" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OciClientConfigWithSecurityToken": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithSecurityToken" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OciGenAiConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciGenAiConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OllamaConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOllamaConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OpenAIAPIType": { + "description": "Enumeration of OpenAI API Types.\n\nchat completions: Chat Completions API\nresponses: Responses API", + "enum": [ + "chat_completions", + "responses" + ], + "title": "OpenAIAPIType", + "type": "string" + }, + "OpenAiCompatibleConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOpenAiCompatibleConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OpenAiConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOpenAiConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OracleDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OracleDatabaseDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOracleDatabaseDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "OutputMessageNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOutputMessageNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "PKCEMethod": { + "enum": [ + "S256", + "plain" + ], + "title": "PKCEMethod", + "type": "string" + }, + "PKCEPolicy": { + "description": "Policy configuration for Proof Key for Code Exchange (PKCE).\n\nPKCE mitigates authorization code interception and injection attacks in\nauthorization code flows. Some protocols (such as MCP OAuth) require PKCE.", + "properties": { + "required": { + "default": true, + "title": "Required", + "type": "boolean" + }, + "method": { + "$ref": "#/$defs/PKCEMethod", + "default": "S256" + } + }, + "title": "PKCEPolicy", + "type": "object" + }, + "ParallelFlowNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseParallelFlowNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ParallelMapNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseParallelMapNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "PostgresDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BasePostgresDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "PostgresDatabaseDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/BasePostgresDatabaseDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "Property": { + "description": "This object must be a valid JSON Schema", + "type": "object" + }, + "ReductionMethod": { + "description": "Enumerator for the types of reduction available in the MapNode.", + "enum": [ + "append", + "average", + "max", + "min", + "sum" + ], + "title": "ReductionMethod", + "type": "string" + }, + "RemoteAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseRemoteAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "RemoteTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseRemoteTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "RemoteTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseRemoteTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "RetryPolicy": { + "additionalProperties": false, + "properties": { + "max_attempts": { + "default": 2, + "minimum": 0, + "title": "Max Attempts", + "type": "integer" + }, + "request_timeout": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Request Timeout" + }, + "initial_retry_delay": { + "default": 1.0, + "minimum": 0, + "title": "Initial Retry Delay", + "type": "number" + }, + "max_retry_delay": { + "default": 8.0, + "minimum": 0, + "title": "Max Retry Delay", + "type": "number" + }, + "backoff_factor": { + "default": 2.0, + "exclusiveMinimum": 0, + "title": "Backoff Factor", + "type": "number" + }, + "jitter": { + "anyOf": [ + { + "enum": [ + "decorrelated", + "equal", + "full", + "full_and_equal_for_throttle" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": "full_and_equal_for_throttle", + "title": "Jitter" + }, + "service_error_retry_on_any_5xx": { + "default": true, + "title": "Service Error Retry On Any 5Xx", + "type": "boolean" + }, + "recoverable_statuses": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": "Recoverable Statuses", + "type": "object" + } + }, + "title": "RetryPolicy", + "type": "object" + }, + "SSETransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSSETransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "SSEmTLSTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSSEmTLSTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ScopePolicy": { + "enum": [ + "fixed", + "use_challenge_or_supported" + ], + "title": "ScopePolicy", + "type": "string" + }, + "ServerTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseServerTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ServingMode": { + "description": "Serving mode to use for the GenAI service", + "enum": [ + "DEDICATED", + "ON_DEMAND" + ], + "title": "ServingMode", + "type": "string" + }, + "SessionParameters": { + "description": "Class to specify parameters of the MCP client session.", + "properties": { + "read_timeout_seconds": { + "default": 60.0, + "title": "Read Timeout Seconds", + "type": "number" + } + }, + "title": "SessionParameters", + "type": "object" + }, + "SpecializedAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSpecializedAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "StartNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStartNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "StdioTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStdioTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "StreamableHTTPTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStreamableHTTPTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "StreamableHTTPmTLSTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStreamableHTTPmTLSTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "Swarm": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSwarm" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "TlsOracleDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "TlsPostgresDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseTlsPostgresDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "Tool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ToolBox": { + "anyOf": [ + { + "$ref": "#/$defs/BaseToolBox" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "ToolNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseToolNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "VllmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseVllmConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, + "BaseA2AAgent": { + "additionalProperties": false, + "description": "Component which communicates with a remote server agent using the A2A Protocol.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "agent_url": { + "title": "Agent Url", + "type": "string" + }, + "connection_config": { + "$ref": "#/$defs/A2AConnectionConfig" + }, + "session_parameters": { + "$ref": "#/$defs/A2ASessionParameters", + "default": { + "timeout": 60.0, + "poll_interval": 2.0, + "max_retries": 5 + } + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "A2AAgent" + } + }, + "required": [ + "agent_url", + "connection_config", + "name" + ], + "title": "A2AAgent", + "type": "object", + "x-abstract-component": false + }, + "BaseA2AConnectionConfig": { + "additionalProperties": false, + "description": "Class to specify configuration settings for establishing a connection in A2A communication.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "timeout": { + "default": 600.0, + "title": "Timeout", + "type": "number" + }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Headers" + }, + "verify": { + "default": true, + "title": "Verify", + "type": "boolean" + }, + "key_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key File" + }, + "cert_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cert File" + }, + "ssl_ca_cert": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ssl Ca Cert" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "A2AConnectionConfig" + } + }, + "required": [ + "name" + ], + "title": "A2AConnectionConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseAgent": { + "additionalProperties": false, + "description": "An agent is a component that can do several rounds of conversation to solve a task.\n\nIt can be executed by itself, or be executed in a flow using an AgentNode.\n\n\nExamples\n--------\n>>> from pyagentspec.agent import Agent\n>>> from pyagentspec.property import Property\n>>> expertise_property=Property(\n... json_schema={\"title\": \"domain_of_expertise\", \"type\": \"string\"}\n... )\n>>> system_prompt = '''You are an expert in {{domain_of_expertise}}.\n... Please help the users with their requests.'''\n>>> agent = Agent(\n... name=\"Adaptive expert agent\",\n... system_prompt=system_prompt,\n... llm_config=llm_config,\n... inputs=[expertise_property],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "llm_config": { + "$ref": "#/$defs/LlmConfig" + }, + "system_prompt": { + "title": "System Prompt", + "type": "string" + }, + "tools": { + "items": { + "$ref": "#/$defs/Tool" + }, + "title": "Tools", + "type": "array" + }, + "toolboxes": { + "items": { + "$ref": "#/$defs/ToolBox" + }, + "title": "Toolboxes", + "type": "array" + }, + "human_in_the_loop": { + "default": true, + "title": "Human In The Loop", + "type": "boolean" + }, + "transforms": { + "items": { + "$ref": "#/$defs/MessageTransform" + }, + "title": "Transforms", + "type": "array" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "Agent" + } + }, + "required": [ + "llm_config", + "name", + "system_prompt" + ], + "title": "Agent", + "type": "object", + "x-abstract-component": false + }, + "BaseAgentNode": { + "additionalProperties": false, + "description": "The agent execution node is a node that will execute an agent as part of a flow.\n\nIf branches are configured, the agent will be prompted to select a branch before the agent node\ncompletes to transition to another node of the Flow.\n\n- **Inputs**\n Inferred from the definition of the agent to execute.\n- **Outputs**\n Inferred from the definition of the agent to execute.\n- **Branches**\n One, the default next.\n\nExamples\n--------\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.agent import Agent\n>>> from pyagentspec.property import Property\n>>> from pyagentspec.flows.nodes import AgentNode, StartNode, EndNode\n>>> from pyagentspec.tools import ServerTool\n>>> query_property = Property(json_schema={\"title\": \"query\", \"type\": \"string\"})\n>>> search_results_property = Property(\n... json_schema={\"title\": \"search_results\", \"type\": \"array\", \"items\": {\"type\": \"string\"}}\n... )\n>>> search_tool = ServerTool(\n... name=\"search_tool\",\n... description=(\n... \"This tool runs a web search with the given query \"\n... \"and returns the most relevant results\"\n... ),\n... inputs=[query_property],\n... outputs=[search_results_property],\n... )\n>>> agent = Agent(\n... name=\"Search agent\",\n... llm_config=llm_config,\n... system_prompt=(\n... \"Your task is to gather the required information for the user: {{query}}\"\n... ),\n... tools=[search_tool],\n... outputs=[search_results_property],\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[query_property])\n>>> end_node = EndNode(name=\"end\", outputs=[search_results_property])\n>>> agent_node = AgentNode(\n... name=\"Search agent node\",\n... agent=agent,\n... )\n>>> flow = Flow(\n... name=\"Search agent flow\",\n... start_node=start_node,\n... nodes=[start_node, agent_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_agent\", from_node=start_node, to_node=agent_node),\n... ControlFlowEdge(name=\"agent_to_end\", from_node=agent_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"query_edge\",\n... source_node=start_node,\n... source_output=\"query\",\n... destination_node=agent_node,\n... destination_input=\"query\",\n... ),\n... DataFlowEdge(\n... name=\"search_results_edge\",\n... source_node=agent_node,\n... source_output=\"search_results\",\n... destination_node=end_node,\n... destination_input=\"search_results\"\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "agent": { + "$ref": "#/$defs/AgenticComponent" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "AgentNode" + } + }, + "required": [ + "agent", + "name" + ], + "title": "AgentNode", + "type": "object", + "x-abstract-component": false + }, + "BaseAgentSpecializationParameters": { + "additionalProperties": false, + "description": "Parameters used to specialize an agent for a certain goal or task.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "additional_instructions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Additional Instructions" + }, + "additional_tools": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Tool" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Additional Tools" + }, + "human_in_the_loop": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Human In The Loop" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "AgentSpecializationParameters" + } + }, + "required": [ + "name" + ], + "title": "AgentSpecializationParameters", + "type": "object", + "x-abstract-component": false + }, + "BaseAgenticComponent": { + "anyOf": [ + { + "$ref": "#/$defs/A2AAgent" + }, + { + "$ref": "#/$defs/Agent" + }, + { + "$ref": "#/$defs/Flow" + }, + { + "$ref": "#/$defs/ManagerWorkers" + }, + { + "$ref": "#/$defs/OciAgent" + }, + { + "$ref": "#/$defs/RemoteAgent" + }, + { + "$ref": "#/$defs/SpecializedAgent" + }, + { + "$ref": "#/$defs/Swarm" + } + ], + "x-abstract-component": true + }, + "BaseApiNode": { + "additionalProperties": false, + "description": "Make an API call.\n\nThis node is intended to be a part of a Flow.\n\n- **Inputs**\n Inferred from the json spec retrieved from API Spec URI, if available and reachable.\n Otherwise, users have to manually specify them.\n- **Outputs**\n Inferred from the json spec retrieved from API Spec URI, if available and reachable.\n Otherwise, users should manually specify them.\n\n If None is given, ``pyagentspec`` infers a generic property of any type named ``response``.\n- **Branches**\n One, the default next.\n\n\nExamples\n--------\n>>> from pyagentspec.flows.nodes import ApiNode\n>>> from pyagentspec.property import Property\n>>> weather_result_property = Property(\n... json_schema={\n... \"title\": \"zurich_weather\",\n... \"type\": \"object\",\n... \"properties\": {\n... \"temperature\": {\n... \"type\": \"number\",\n... \"description\": \"Temperature in celsius degrees\",\n... },\n... \"weather\": {\"type\": \"string\"}\n... },\n... }\n... )\n>>> call_current_weather_step = ApiNode(\n... name=\"Weather API call node\",\n... url=\"https://example.com/weather\",\n... http_method = \"GET\",\n... query_params={\n... \"location\": \"zurich\",\n... },\n... outputs=[weather_result_property]\n... )\n>>>\n>>> item_id_property = Property(\n... json_schema={\"title\": \"item_id\", \"type\": \"string\"}\n... )\n>>> order_id_property = Property(\n... json_schema={\"title\": \"order_id\", \"type\": \"string\"}\n... )\n>>> store_id_property = Property(\n... json_schema={\"title\": \"store_id\", \"type\": \"string\"}\n... )\n>>> session_id_property = Property(\n... json_schema={\"title\": \"session_id\", \"type\": \"string\"}\n... )\n>>> create_order_step = ApiNode(\n... name=\"Orders api call node\",\n... url=\"https://example.com/orders/{{ order_id }}\",\n... http_method=\"POST\",\n... # sending an object which will automatically be transformed into JSON\n... data={\n... # define a static body parameter\n... \"topic_id\": 12345,\n... # define a templated body parameter.\n... # The value for {{ item_id }} will be taken from the IO system at runtime\n... \"item_id\": \"{{ item_id }}\",\n... },\n... query_params={\n... # provide one templated query parameter called \"store_id\"\n... # which will take its value from the IO system from key \"store_id\"\n... \"store_id\": \"{{ store_id }}\",\n... },\n... headers={\n... # set header session_id. the value is coming from the IO system\n... \"session_id\": \"{{ session_id }}\",\n... },\n... inputs=[item_id_property, order_id_property, store_id_property, session_id_property],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "url": { + "title": "Url", + "type": "string" + }, + "http_method": { + "title": "Http Method", + "type": "string" + }, + "api_spec_uri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Spec Uri" + }, + "data": { + "anyOf": [ + { + "type": "string" + }, + { + "format": "binary", + "type": "string" + }, + {} + ], + "title": "Data" + }, + "query_params": { + "additionalProperties": true, + "title": "Query Params", + "type": "object" + }, + "headers": { + "additionalProperties": true, + "title": "Headers", + "type": "object" + }, + "sensitive_headers": { + "additionalProperties": true, + "title": "Sensitive Headers", + "type": "object" + }, + "url_allow_list": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url Allow List" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ApiNode" + } + }, + "required": [ + "http_method", + "name", + "url" + ], + "title": "ApiNode", + "type": "object", + "x-abstract-component": false + }, + "BaseAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/OAuthConfig" + } + ], + "x-abstract-component": true + }, + "BaseBranchingNode": { + "additionalProperties": false, + "description": "Select the next node to transition to based on a mapping.\n\nThe input is used as key for the mapping. If the input does not correspond to any of the keys\nof the mapping the branch 'default' will be selected. This node is intended to be a part of a\nFlow.\n\n- **Inputs**\n The input value that should be used as key for the mapping.\n\n If None is given, ``pyagentspec`` infers a string property named ``branching_mapping_key``.\n- **Outputs**\n None.\n- **Branches**\n One for each value in the mapping, plus a branch called ``default``,\n which is the branch taken by the flow when mapping fails\n (i.e., the input does not match any key in the mapping).\n\nExamples\n--------\n>>> from pyagentspec.agent import Agent\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import AgentNode, BranchingNode, StartNode, EndNode\n>>> from pyagentspec.property import Property\n>>> CORRECT_PASSWORD_BRANCH = \"PASSWORD_OK\"\n>>> password_property = Property(\n... json_schema={\"title\": \"password\", \"type\": \"string\"}\n... )\n>>> agent = Agent(\n... name=\"User input agent\",\n... llm_config=llm_config,\n... system_prompt=(\n... \"Your task is to ask the password to the user. \"\n... \"Once you get it, submit it and end.\"\n... ),\n... outputs=[password_property],\n... )\n>>> start_node = StartNode(name=\"start\")\n>>> access_granted_end_node = EndNode(\n... name=\"access granted end\", branch_name=\"ACCESS_GRANTED\"\n... )\n>>> access_denied_end_node = EndNode(\n... name=\"access denied end\", branch_name=\"ACCESS_DENIED\"\n... )\n>>> branching_node = BranchingNode(\n... name=\"password check\",\n... mapping={\"123456\": CORRECT_PASSWORD_BRANCH},\n... inputs=[password_property]\n... )\n>>> agent_node = AgentNode(\n... name=\"User input agent node\",\n... agent=agent,\n... )\n>>> assistant = Flow(\n... name=\"Check access flow\",\n... start_node=start_node,\n... nodes=[\n... start_node,\n... agent_node,\n... branching_node,\n... access_granted_end_node,\n... access_denied_end_node\n... ],\n... control_flow_connections=[\n... ControlFlowEdge(\n... name=\"start_to_agent\",\n... from_node=start_node,\n... to_node=agent_node\n... ),\n... ControlFlowEdge(\n... name=\"agent_to_branching\",\n... from_node=agent_node,\n... to_node=branching_node\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_access_granted\",\n... from_node=branching_node,\n... from_branch=CORRECT_PASSWORD_BRANCH,\n... to_node=access_granted_end_node,\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_access_denied\",\n... from_node=branching_node,\n... from_branch=BranchingNode.DEFAULT_BRANCH,\n... to_node=access_denied_end_node,\n... ),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"password_edge\",\n... source_node=agent_node,\n... source_output=\"password\",\n... destination_node=branching_node,\n... destination_input=\"password\",\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "mapping": { + "additionalProperties": { + "type": "string" + }, + "title": "Mapping", + "type": "object" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "BranchingNode" + } + }, + "required": [ + "mapping", + "name" + ], + "title": "BranchingNode", + "type": "object", + "x-abstract-component": false + }, + "BaseBuiltinTool": { + "additionalProperties": false, + "description": "A tool that is built into and executed by the orchestrator", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "requires_confirmation": { + "default": false, + "title": "Requires Confirmation", + "type": "boolean" + }, + "tool_type": { + "title": "Tool Type", + "type": "string" + }, + "configuration": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Configuration" + }, + "executor_name": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Executor Name" + }, + "tool_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Version" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "BuiltinTool" + } + }, + "required": [ + "name", + "tool_type" + ], + "title": "BuiltinTool", + "type": "object", + "x-abstract-component": false + }, + "BaseCatchExceptionNode": { + "additionalProperties": false, + "description": "Node to execute a Flow and catch exceptions.\n\n- If no exception is caught, the node will transition to the branches of its subflow.\n- If an exception is caught, it will transition to an exception branch.\n\nInputs\n------\nSame as the inputs from the ``subflow``.\n\nOutputs\n-------\n\n- The outputs of the ``subflow``.\n If an exception is raised, the default values of each output property are used.\n- An additional output named ``caught_exception_info`` with type ``string | null``\n and default value ``null``. Executors may populate it with a non-sensitive\n error description when an exception is caught.\n\nBranches\n--------\n\n- The branches of the ``subflow``\n- One additional branch named ``caught_exception_branch``\n\nSecurity Considerations\n-----------------------\n\nSee security considerations regarding exception catching in\nthe :ref:`Security Considerations `", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "subflow": { + "$ref": "#/$defs/Flow" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "CatchExceptionNode" + } + }, + "required": [ + "name", + "subflow" + ], + "title": "CatchExceptionNode", + "type": "object", + "x-abstract-component": false + }, + "BaseClientTool": { + "additionalProperties": false, + "description": "A tool that needs to be run by the client application.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "requires_confirmation": { + "default": false, + "title": "Requires Confirmation", + "type": "boolean" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ClientTool" + } + }, + "required": [ + "name" + ], + "title": "ClientTool", + "type": "object", + "x-abstract-component": false + }, + "BaseClientTransport": { + "anyOf": [ + { + "$ref": "#/$defs/RemoteTransport" + }, + { + "$ref": "#/$defs/SSETransport" + }, + { + "$ref": "#/$defs/SSEmTLSTransport" + }, + { + "$ref": "#/$defs/StdioTransport" + }, + { + "$ref": "#/$defs/StreamableHTTPTransport" + }, + { + "$ref": "#/$defs/StreamableHTTPmTLSTransport" + } + ], + "x-abstract-component": true + }, + "BaseComponentWithIO": { + "anyOf": [ + { + "$ref": "#/$defs/A2AAgent" + }, + { + "$ref": "#/$defs/Agent" + }, + { + "$ref": "#/$defs/AgentNode" + }, + { + "$ref": "#/$defs/AgentSpecializationParameters" + }, + { + "$ref": "#/$defs/AgenticComponent" + }, + { + "$ref": "#/$defs/ApiNode" + }, + { + "$ref": "#/$defs/BranchingNode" + }, + { + "$ref": "#/$defs/BuiltinTool" + }, + { + "$ref": "#/$defs/CatchExceptionNode" + }, + { + "$ref": "#/$defs/ClientTool" + }, + { + "$ref": "#/$defs/EndNode" + }, + { + "$ref": "#/$defs/Flow" + }, + { + "$ref": "#/$defs/FlowNode" + }, + { + "$ref": "#/$defs/InputMessageNode" + }, + { + "$ref": "#/$defs/LlmNode" + }, + { + "$ref": "#/$defs/MCPTool" + }, + { + "$ref": "#/$defs/MCPToolSpec" + }, + { + "$ref": "#/$defs/ManagerWorkers" + }, + { + "$ref": "#/$defs/MapNode" + }, + { + "$ref": "#/$defs/Node" + }, + { + "$ref": "#/$defs/OciAgent" + }, + { + "$ref": "#/$defs/OutputMessageNode" + }, + { + "$ref": "#/$defs/ParallelFlowNode" + }, + { + "$ref": "#/$defs/ParallelMapNode" + }, + { + "$ref": "#/$defs/RemoteAgent" + }, + { + "$ref": "#/$defs/RemoteTool" + }, + { + "$ref": "#/$defs/ServerTool" + }, + { + "$ref": "#/$defs/SpecializedAgent" + }, + { + "$ref": "#/$defs/StartNode" + }, + { + "$ref": "#/$defs/Swarm" + }, + { + "$ref": "#/$defs/Tool" + }, + { + "$ref": "#/$defs/ToolNode" + } + ], + "x-abstract-component": true + }, + "BaseControlFlowEdge": { + "additionalProperties": false, + "description": "A control flow edge specifies a possible transition from a node to another in a flow.\n\nA single node can have several potential next nodes, in which case several control flow edges\nshould be present in the control flow connections of that flow.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "from_node": { + "$ref": "#/$defs/Node" + }, + "from_branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "From Branch" + }, + "to_node": { + "$ref": "#/$defs/Node" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ControlFlowEdge" + } + }, + "required": [ + "from_node", + "name", + "to_node" + ], + "title": "ControlFlowEdge", + "type": "object", + "x-abstract-component": false + }, + "BaseConversationSummarizationTransform": { + "additionalProperties": false, + "description": "Summarizes conversations exceeding a configured size threshold using an LLM and caches\nconversation summaries in a ``Datastore``.\n\nThis is useful to reduce long conversation history into a concise context for downstream LLM calls.\n\nExamples\n--------\n>>> from pyagentspec.transforms import ConversationSummarizationTransform\n>>> summarization_transform = ConversationSummarizationTransform(\n... name=\"conversation-summarizer\",\n... llm=llm_config,\n... max_num_messages=30,\n... min_num_messages=10\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "llm": { + "$ref": "#/$defs/LlmConfig" + }, + "max_num_messages": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 50, + "title": "Max Num Messages" + }, + "max_num_characters": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Num Characters" + }, + "min_num_messages": { + "default": 10, + "exclusiveMinimum": 0, + "title": "Min Num Messages", + "type": "integer" + }, + "summarization_instructions": { + "default": "Please make a summary of this conversation. Include relevant information and keep it short. Your response will replace the messages, so just output the summary directly, no introduction needed.", + "title": "Summarization Instructions", + "type": "string" + }, + "summarized_conversation_template": { + "default": "Summarized conversation: {{summary}}", + "title": "Summarized Conversation Template", + "type": "string" + }, + "max_cache_size": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 10000, + "title": "Max Cache Size" + }, + "max_cache_lifetime": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 14400, + "title": "Max Cache Lifetime" + }, + "cache_collection_name": { + "default": "summarized_conversations_cache", + "title": "Cache Collection Name", + "type": "string" + }, + "datastore": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/InMemoryCollectionDatastore" + }, + { + "$ref": "#/$defs/OracleDatabaseDatastore" + }, + { + "$ref": "#/$defs/PostgresDatabaseDatastore" + } + ], + "title": "Datastore" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ConversationSummarizationTransform" + } + }, + "required": [ + "llm", + "name" + ], + "title": "ConversationSummarizationTransform", + "type": "object", + "x-abstract-component": false + }, + "BaseDataFlowEdge": { + "additionalProperties": false, + "description": "A data flow edge specifies how the output of a node propagates as input of another node.\n\nAn outputs can be propagated as input of several nodes.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "source_node": { + "$ref": "#/$defs/Node" + }, + "source_output": { + "title": "Source Output", + "type": "string" + }, + "destination_node": { + "$ref": "#/$defs/Node" + }, + "destination_input": { + "title": "Destination Input", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "DataFlowEdge" + } + }, + "required": [ + "destination_input", + "destination_node", + "name", + "source_node", + "source_output" + ], + "title": "DataFlowEdge", + "type": "object", + "x-abstract-component": false + }, + "BaseDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/InMemoryCollectionDatastore" + }, + { + "$ref": "#/$defs/OracleDatabaseDatastore" + }, + { + "$ref": "#/$defs/PostgresDatabaseDatastore" + } + ], + "x-abstract-component": true + }, + "BaseDbmsVectorChainLlmConfig": { + "additionalProperties": false, + "description": "Configure an LLM executed by Oracle Database through DBMS_VECTOR_CHAIN.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "minLength": 1, + "title": "Model Id", + "type": "string" + }, + "provider": { + "minLength": 1, + "title": "Provider", + "type": "string" + }, + "url": { + "minLength": 1, + "title": "Url", + "type": "string" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "host": { + "anyOf": [ + { + "const": "local", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Host" + }, + "credential_name": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Credential Name" + }, + "transfer_timeout": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Transfer Timeout" + }, + "connection_config": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/OracleDatabaseConnectionConfig" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "DbmsVectorChainLlmConfig" + } + }, + "required": [ + "model_id", + "name", + "provider", + "url" + ], + "title": "DbmsVectorChainLlmConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseEndNode": { + "additionalProperties": false, + "description": "End nodes denote the end of the execution of a flow.\n\nThere might be several end nodes in a flow, in which case the executor of the flow\nshould be able to track which one was reached and pass that back to the caller.\n\n- **Inputs**\n The list of inputs of the step. If both input and output properties are specified they\n must be an exact match\n\n If None is given, ``pyagentspec`` copies the outputs provided, if any. Otherwise, no input is exposed.\n- **Outputs**\n The list of outputs that should be exposed by the flow. If both input and output properties\n are specified they must be an exact match\n\n If None is given, ``pyagentspec`` copies the inputs provided, if any. Otherwise, no output is exposed.\n- **Branches**\n None.\n\nExamples\n--------\n>>> from pyagentspec.agent import Agent\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import AgentNode, BranchingNode, StartNode, EndNode\n>>> from pyagentspec.property import Property\n>>> languages_to_branch_name = {\n... \"english\": \"ENGLISH\",\n... \"spanish\": \"SPANISH\",\n... \"italian\": \"ITALIAN\",\n... }\n>>> language_property = Property(\n... json_schema={\"title\": \"language\", \"type\": \"string\"}\n... )\n>>> agent = Agent(\n... name=\"Language detector agent\",\n... llm_config=llm_config,\n... system_prompt=(\n... \"Your task is to understand the language spoken by the user.\"\n... \"Please output only the language in lowercase and submit.\"\n... ),\n... outputs=[language_property],\n... )\n>>> start_node = StartNode(name=\"start\")\n>>> english_end_node = EndNode(\n... name=\"english end\", branch_name=languages_to_branch_name[\"english\"]\n... )\n>>> spanish_end_node = EndNode(\n... name=\"spanish end\", branch_name=languages_to_branch_name[\"spanish\"]\n... )\n>>> italian_end_node = EndNode(\n... name=\"italian end\", branch_name=languages_to_branch_name[\"italian\"]\n... )\n>>> unknown_end_node = EndNode(name=\"unknown language end\", branch_name=\"unknown\")\n>>> branching_node = BranchingNode(\n... name=\"language check\",\n... mapping=languages_to_branch_name,\n... inputs=[language_property]\n... )\n>>> agent_node = AgentNode(\n... name=\"User input agent node\",\n... agent=agent,\n... )\n>>> assistant = Flow(\n... name=\"Check access flow\",\n... start_node=start_node,\n... nodes=[\n... start_node,\n... agent_node,\n... branching_node,\n... english_end_node,\n... spanish_end_node,\n... italian_end_node,\n... unknown_end_node,\n... ],\n... control_flow_connections=[\n... ControlFlowEdge(\n... name=\"start_to_agent\", from_node=start_node, to_node=agent_node\n... ),\n... ControlFlowEdge(\n... name=\"agent_to_branching\", from_node=agent_node, to_node=branching_node\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_english_end\",\n... from_node=branching_node,\n... from_branch=languages_to_branch_name[\"english\"],\n... to_node=english_end_node,\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_spanish_end\",\n... from_node=branching_node,\n... from_branch=languages_to_branch_name[\"spanish\"],\n... to_node=spanish_end_node,\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_italian_end\",\n... from_node=branching_node,\n... from_branch=languages_to_branch_name[\"italian\"],\n... to_node=italian_end_node,\n... ),\n... ControlFlowEdge(\n... name=\"branching_to_unknown_end\",\n... from_node=branching_node,\n... from_branch=BranchingNode.DEFAULT_BRANCH,\n... to_node=unknown_end_node,\n... ),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"language_edge\",\n... source_node=agent_node,\n... source_output=\"language\",\n... destination_node=branching_node,\n... destination_input=\"language\",\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "branch_name": { + "default": "next", + "title": "Branch Name", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "EndNode" + } + }, + "required": [ + "name" + ], + "title": "EndNode", + "type": "object", + "x-abstract-component": false + }, + "BaseFlow": { + "additionalProperties": false, + "description": "A flow is a component to model sequences of operations to do in a precised order.\n\nThe operations and sequence is defined by the nodes and transitions associated to the flow.\nSteps can be deterministic, or for some use LLMs.\n\nExample\n-------\n>>> from pyagentspec.property import Property\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.nodes import LlmNode, StartNode, EndNode\n>>> prompt_property = Property(\n... json_schema={\"title\": \"prompt\", \"type\": \"string\"}\n... )\n>>> llm_output_property = Property(\n... json_schema={\"title\": \"llm_output\", \"type\": \"string\"}\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[prompt_property])\n>>> end_node = EndNode(name=\"end\", outputs=[llm_output_property])\n>>> llm_node = LlmNode(\n... name=\"simple llm node\",\n... llm_config=llm_config,\n... prompt_template=\"{{prompt}}\",\n... inputs=[prompt_property],\n... outputs=[llm_output_property],\n... )\n>>> flow = Flow(\n... name=\"Simple prompting flow\",\n... start_node=start_node,\n... nodes=[start_node, llm_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_llm\", from_node=start_node, to_node=llm_node),\n... ControlFlowEdge(name=\"llm_to_end\", from_node=llm_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"prompt_edge\",\n... source_node=start_node,\n... source_output=\"prompt\",\n... destination_node=llm_node,\n... destination_input=\"prompt\",\n... ),\n... DataFlowEdge(\n... name=\"llm_output_edge\",\n... source_node=llm_node,\n... source_output=\"llm_output\",\n... destination_node=end_node,\n... destination_input=\"llm_output\"\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "start_node": { + "$ref": "#/$defs/Node" + }, + "nodes": { + "items": { + "$ref": "#/$defs/Node" + }, + "title": "Nodes", + "type": "array" + }, + "control_flow_connections": { + "items": { + "$ref": "#/$defs/ControlFlowEdge" + }, + "title": "Control Flow Connections", + "type": "array" + }, + "data_flow_connections": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/DataFlowEdge" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Data Flow Connections" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "Flow" + } + }, + "required": [ + "control_flow_connections", + "name", + "nodes", + "start_node" + ], + "title": "Flow", + "type": "object", + "x-abstract-component": false + }, + "BaseFlowNode": { + "additionalProperties": false, + "description": "The flow node executes a subflow as part of a flow.\n\n- **Inputs**\n Inferred from the inner structure. It's the sets of inputs\n required by the StartNode of the inner flow.\n- **Outputs**\n Inferred from the inner structure. It's the union of the\n sets of outputs exposed by the EndNodes of the inner flow.\n- **Branches**\n Inferred from the inner flow, one per each different value of the attribute\n ``branch_name`` of the nodes of type EndNode in the inner flow.\n\nExample\n-------\nThe ``FlowNode`` is particularly suitable when subflows can be reused inside a project.\nLet's see an example with a flow that estimates numerical value\nusing the \"wisdowm of the crowd\" effect:\n\n>>> from pyagentspec.property import Property\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.nodes import MapNode, LlmNode, ToolNode, StartNode, EndNode\n>>> from pyagentspec.tools import ServerTool\n>>> duplication_tool = ServerTool(\n... name=\"duplication_tool\",\n... description=\"\",\n... inputs=[\n... Property(\n... json_schema={\"title\": \"element\", \"description\": \"\", \"type\": \"string\"}\n... ),\n... Property(\n... json_schema={\"title\": \"n\", \"description\": \"\", \"type\": \"integer\"}\n... ),\n... ],\n... outputs=[\n... Property(\n... json_schema={\n... \"title\": \"flow_iterable_queries\",\n... \"type\": \"array\",\n... \"items\": {\"type\": \"string\"}\n... },\n... )\n... ],\n... )\n>>> reduce_tool = ServerTool(\n... name=\"reduce_tool\",\n... description=\"\",\n... inputs=[\n... Property(\n... json_schema={\"title\": \"elements\", \"type\": \"array\", \"items\": {\"type\": \"string\"}}\n... ),\n... ],\n... outputs=[Property(json_schema={\"title\": \"flow_processed_query\", \"type\": \"string\"})],\n... )\n>>> # Defining a simple prompt\n>>> REASONING_PROMPT_TEMPLATE = '''Provide your best numerical estimate for: {{user_input}}\n... Your answer should be a single number.\n... Do not include any units, reasoning, or extra text.'''\n>>> # Defining the subflow for the map step\n>>> user_input_property = Property(\n... json_schema={\"title\": \"user_input\", \"type\": \"string\"}\n... )\n>>> flow_processed_query_property = Property(\n... json_schema={\"title\": \"flow_processed_query\", \"type\": \"string\"}\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[user_input_property])\n>>> end_node = EndNode(name=\"end\", outputs=[flow_processed_query_property])\n>>> llm_node = LlmNode(\n... name=\"reasoning llm node\",\n... llm_config=llm_config,\n... prompt_template=REASONING_PROMPT_TEMPLATE,\n... inputs=[user_input_property],\n... outputs=[flow_processed_query_property],\n... )\n>>> inner_map_flow = Flow(\n... name=\"Map flow\",\n... start_node=start_node,\n... nodes=[start_node, llm_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_llm\", from_node=start_node, to_node=llm_node),\n... ControlFlowEdge(name=\"llm_to_end\", from_node=llm_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"query_edge\",\n... source_node=start_node,\n... source_output=\"user_input\",\n... destination_node=llm_node,\n... destination_input=\"user_input\",\n... ),\n... DataFlowEdge(\n... name=\"search_results_edge\",\n... source_node=llm_node,\n... source_output=\"flow_processed_query\",\n... destination_node=end_node,\n... destination_input=\"flow_processed_query\"\n... ),\n... ],\n... )\n>>> user_query_property = Property(\n... json_schema={\"title\": \"user_query\", \"type\": \"string\"}\n... )\n>>> n_repeat_property = Property(\n... json_schema={\"title\": \"n_repeat\", \"type\": \"integer\"}\n... )\n>>> flow_iterable_queries_property = Property(\n... json_schema={\n... \"title\": \"iterated_user_input\",\n... \"type\": \"array\",\n... \"items\": {\"type\": \"string\"},\n... }\n... )\n>>> flow_processed_queries_property = Property(\n... json_schema={\n... \"title\": \"collected_flow_processed_query\",\n... \"type\": \"array\",\n... \"items\": {\"type\": \"string\"},\n... }\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[user_query_property, n_repeat_property])\n>>> end_node = EndNode(name=\"end\", outputs=[flow_processed_query_property])\n>>> duplication_node = ToolNode(\n... name=\"duplication_tool node\",\n... tool=duplication_tool,\n... )\n>>> reduce_node = ToolNode(\n... name=\"reduce_tool node\",\n... tool=reduce_tool,\n... )\n>>> map_node = MapNode(\n... name=\"map node\",\n... subflow=inner_map_flow,\n... inputs=[flow_iterable_queries_property],\n... outputs=[flow_processed_queries_property],\n... )\n>>> mapreduce_flow = Flow(\n... name=\"Map-reduce flow\",\n... start_node=start_node,\n... nodes=[start_node, duplication_node, map_node, reduce_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(\n... name=\"start_to_duplication\", from_node=start_node, to_node=duplication_node\n... ),\n... ControlFlowEdge(\n... name=\"duplication_to_map\", from_node=duplication_node, to_node=map_node\n... ),\n... ControlFlowEdge(name=\"map_to_reduce\", from_node=map_node, to_node=reduce_node),\n... ControlFlowEdge(name=\"reduce_to_end\", from_node=reduce_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"query_edge\",\n... source_node=start_node,\n... source_output=\"user_query\",\n... destination_node=duplication_node,\n... destination_input=\"element\",\n... ),\n... DataFlowEdge(\n... name=\"n_repeat_edge\",\n... source_node=start_node,\n... source_output=\"n_repeat\",\n... destination_node=duplication_node,\n... destination_input=\"n\",\n... ),\n... DataFlowEdge(\n... name=\"flow_iterables_edge\",\n... source_node=duplication_node,\n... source_output=\"flow_iterable_queries\",\n... destination_node=map_node,\n... destination_input=\"iterated_user_input\",\n... ),\n... DataFlowEdge(\n... name=\"flow_processed_queries_edge\",\n... source_node=map_node,\n... source_output=\"collected_flow_processed_query\",\n... destination_node=reduce_node,\n... destination_input=\"elements\",\n... ),\n... DataFlowEdge(\n... name=\"flow_processed_query_edge\",\n... source_node=reduce_node,\n... source_output=\"flow_processed_query\",\n... destination_node=end_node,\n... destination_input=\"flow_processed_query\",\n... ),\n... ],\n... )\n\nOnce the subflow is created we can simply integrate it with the ``FlowNode``:\n\n>>> from pyagentspec.flows.nodes import FlowNode, AgentNode\n>>> from pyagentspec.agent import Agent\n>>> start_node = StartNode(name=\"start\")\n>>> end_node = EndNode(name=\"end\", outputs=[flow_processed_query_property])\n>>> flow_node = FlowNode(name=\"flow node\", subflow=mapreduce_flow)\n>>> agent = Agent(\n... name=\"User interaction agent\",\n... llm_config=llm_config,\n... system_prompt=(\n... \"Your task is to gather from the user the query and the number of times \"\n... \"it should be asked to an LLM. Once you have this information, submit and exit.\"\n... ),\n... outputs=[user_query_property, n_repeat_property],\n... )\n>>> agent_node = AgentNode(name=\"flow node\", agent=agent)\n>>> flow = Flow(\n... name=\"Map flow\",\n... start_node=start_node,\n... nodes=[start_node, agent_node, flow_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_agent\", from_node=start_node, to_node=agent_node),\n... ControlFlowEdge(name=\"agent_to_flow\", from_node=agent_node, to_node=flow_node),\n... ControlFlowEdge(name=\"flow_to_end\", from_node=flow_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"query_edge\",\n... source_node=agent_node,\n... source_output=\"user_query\",\n... destination_node=flow_node,\n... destination_input=\"user_query\",\n... ),\n... DataFlowEdge(\n... name=\"n_rep_edge\",\n... source_node=agent_node,\n... source_output=\"n_repeat\",\n... destination_node=flow_node,\n... destination_input=\"n_repeat\"\n... ),\n... DataFlowEdge(\n... name=\"n_rep_edge\",\n... source_node=flow_node,\n... source_output=\"flow_processed_query\",\n... destination_node=end_node,\n... destination_input=\"flow_processed_query\"\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "subflow": { + "$ref": "#/$defs/Flow" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "FlowNode" + } + }, + "required": [ + "name", + "subflow" + ], + "title": "FlowNode", + "type": "object", + "x-abstract-component": false + }, + "BaseGeminiAIStudioAuthConfig": { + "additionalProperties": false, + "description": "Authentication settings for Gemini via Google AI Studio.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "GeminiAIStudioAuthConfig" + } + }, + "required": [ + "name" + ], + "title": "GeminiAIStudioAuthConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseGeminiAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/GeminiAIStudioAuthConfig" + }, + { + "$ref": "#/$defs/GeminiVertexAIAuthConfig" + } + ], + "x-abstract-component": true + }, + "BaseGeminiConfig": { + "additionalProperties": false, + "description": "Configure a connection to a Gemini LLM (AI Studio or Vertex AI).", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "title": "Model Id", + "type": "string" + }, + "provider": { + "const": "google", + "default": "google", + "title": "Provider", + "type": "string" + }, + "api_provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Provider" + }, + "api_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Type" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "auth": { + "$ref": "#/$defs/GeminiAuthConfig" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "GeminiConfig" + } + }, + "required": [ + "auth", + "model_id", + "name" + ], + "title": "GeminiConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseGeminiVertexAIAuthConfig": { + "additionalProperties": false, + "description": "Authentication settings for Gemini via Google Vertex AI.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "project_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Project Id" + }, + "location": { + "default": "global", + "title": "Location", + "type": "string" + }, + "credentials": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Credentials" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "GeminiVertexAIAuthConfig" + } + }, + "required": [ + "name" + ], + "title": "GeminiVertexAIAuthConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseInMemoryCollectionDatastore": { + "additionalProperties": false, + "description": "In-memory datastore for testing and development purposes.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "datastore_schema": { + "additionalProperties": { + "$ref": "#/$defs/Property" + }, + "title": "Datastore Schema", + "type": "object" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "InMemoryCollectionDatastore" + } + }, + "required": [ + "datastore_schema", + "name" + ], + "title": "InMemoryCollectionDatastore", + "type": "object", + "x-abstract-component": false + }, + "BaseInputMessageNode": { + "additionalProperties": false, + "description": "This node interrupts the execution of the flow in order to wait for a user input, and restarts after receiving it.\nAn agent message, if given, is appended to the conversation before waiting for input.\nUser input is appended to the conversation as a user message, and it is returned as a string property from the node.\n\n- **Inputs**\n One per variable in the message\n- **Outputs**\n One string property that represents the content of the input user message.\n\n If None is given, ``pyagentspec`` infers a string property named ``user_input``.\n- **Branches**\n One, the default next.\n\nExamples\n--------\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import StartNode, EndNode, InputMessageNode, OutputMessageNode, LlmNode\n>>> from pyagentspec.property import StringProperty\n>>> start_node = StartNode(name=\"start\")\n>>> prompt_node = OutputMessageNode(name=\"ask_input\", message=\"What is the paragraph you want to rephrase?\")\n>>> input_node = InputMessageNode(name=\"user_input\", outputs=[StringProperty(title=\"user_input\")])\n>>> llm_node = LlmNode(\n... name=\"rephrase\",\n... llm_config=llm_config,\n... prompt_template=\"Rephrase {{user_input}}\",\n... outputs=[StringProperty(title=\"rephrased_user_input\")],\n... )\n>>> output_node = OutputMessageNode(name=\"ask_input\", message=\"{{rephrased_user_input}}\")\n>>> end_node = EndNode(name=\"end\")\n>>> flow = Flow(\n... name=\"rephrase_paragraph_flow\",\n... start_node=start_node,\n... nodes=[start_node, prompt_node, input_node, llm_node, output_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"ce1\", from_node=start_node, to_node=prompt_node),\n... ControlFlowEdge(name=\"ce2\", from_node=prompt_node, to_node=input_node),\n... ControlFlowEdge(name=\"ce3\", from_node=input_node, to_node=llm_node),\n... ControlFlowEdge(name=\"ce4\", from_node=llm_node, to_node=output_node),\n... ControlFlowEdge(name=\"ce5\", from_node=output_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"de1\",\n... source_node=input_node,\n... source_output=\"user_input\",\n... destination_node=llm_node,\n... destination_input=\"user_input\",\n... ),\n... DataFlowEdge(\n... name=\"de2\",\n... source_node=llm_node,\n... source_output=\"rephrased_user_input\",\n... destination_node=output_node,\n... destination_input=\"rephrased_user_input\",\n... ),\n... ]\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Message" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "InputMessageNode" + } + }, + "required": [ + "name" + ], + "title": "InputMessageNode", + "type": "object", + "x-abstract-component": false + }, + "BaseLlmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/DbmsVectorChainLlmConfig" + }, + { + "$ref": "#/$defs/GeminiConfig" + }, + { + "$ref": "#/$defs/OciGenAiConfig" + }, + { + "$ref": "#/$defs/OllamaConfig" + }, + { + "$ref": "#/$defs/OpenAiCompatibleConfig" + }, + { + "$ref": "#/$defs/OpenAiConfig" + }, + { + "$ref": "#/$defs/VllmConfig" + }, + { + "additionalProperties": false, + "description": "A LLM configuration defines how to connect to a LLM to do generation requests.\n\nThis class can be used directly with the ``provider``, ``api_provider``, and ``api_type``\nfields to describe any LLM without a dedicated subclass. Concrete subclasses provide\nadditional configuration for specific LLM providers.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "title": "Model Id", + "type": "string" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "api_provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Provider" + }, + "api_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Type" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "LlmConfig" + } + }, + "required": [ + "model_id", + "name" + ], + "title": "LlmConfig", + "type": "object" + } + ], + "x-abstract-component": false + }, + "BaseLlmNode": { + "additionalProperties": false, + "description": "Execute a prompt template with a given LLM.\n\nThis node is intended to be a part of a Flow.\n\n- **Inputs**\n One per placeholder in the prompt template.\n- **Outputs**\n The output text generated by the LLM.\n\n If None is given, ``pyagentspec`` infers a string property named ``generated_text``.\n- **Branches**\n One, the default next.\n\nExample\n-------\n>>> from pyagentspec.property import Property\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.nodes import LlmNode, StartNode, EndNode\n>>> country_property = Property(\n... json_schema={\"title\": \"country\", \"type\": \"string\"}\n... )\n>>> capital_property = Property(\n... json_schema={\"title\": \"capital\", \"type\": \"string\"}\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[country_property])\n>>> end_node = EndNode(name=\"end\", outputs=[capital_property])\n>>> llm_node = LlmNode(\n... name=\"simple llm node\",\n... llm_config=llm_config,\n... prompt_template=\"What is the capital of {{ country }}?\",\n... inputs=[country_property],\n... outputs=[capital_property],\n... )\n>>> flow = Flow(\n... name=\"Get the country's capital flow\",\n... start_node=start_node,\n... nodes=[start_node, llm_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_llm\", from_node=start_node, to_node=llm_node),\n... ControlFlowEdge(name=\"llm_to_end\", from_node=llm_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"country_edge\",\n... source_node=start_node,\n... source_output=\"country\",\n... destination_node=llm_node,\n... destination_input=\"country\",\n... ),\n... DataFlowEdge(\n... name=\"capital_edge\",\n... source_node=llm_node,\n... source_output=\"capital\",\n... destination_node=end_node,\n... destination_input=\"capital\"\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "llm_config": { + "$ref": "#/$defs/LlmConfig" + }, + "prompt_template": { + "title": "Prompt Template", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "LlmNode" + } + }, + "required": [ + "llm_config", + "name", + "prompt_template" + ], + "title": "LlmNode", + "type": "object", + "x-abstract-component": false + }, + "BaseMCPTool": { + "additionalProperties": false, + "description": "Class for tools exposed by MCP servers", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "requires_confirmation": { + "default": false, + "title": "Requires Confirmation", + "type": "boolean" + }, + "client_transport": { + "$ref": "#/$defs/ClientTransport" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "MCPTool" + } + }, + "required": [ + "client_transport", + "name" + ], + "title": "MCPTool", + "type": "object", + "x-abstract-component": false + }, + "BaseMCPToolBox": { + "additionalProperties": false, + "description": "Class to dynamically expose a list of tools from a MCP Server.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "requires_confirmation": { + "default": false, + "title": "Requires Confirmation", + "type": "boolean" + }, + "client_transport": { + "$ref": "#/$defs/ClientTransport" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "tool_filter": { + "anyOf": [ + { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/MCPToolSpec" + } + ] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tool Filter" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "MCPToolBox" + } + }, + "required": [ + "client_transport", + "name" + ], + "title": "MCPToolBox", + "type": "object", + "x-abstract-component": false + }, + "BaseMCPToolSpec": { + "additionalProperties": false, + "description": "Specification of MCP tool", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "requires_confirmation": { + "default": false, + "title": "Requires Confirmation", + "type": "boolean" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "MCPToolSpec" + } + }, + "required": [ + "name" + ], + "title": "MCPToolSpec", + "type": "object", + "x-abstract-component": false + }, + "BaseMTlsOracleDatabaseConnectionConfig": { + "additionalProperties": false, + "description": "Mutual-TLS Connection Configuration to Oracle Database.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "user": { + "title": "User", + "type": "string" + }, + "password": { + "title": "Password", + "type": "string" + }, + "dsn": { + "title": "Dsn", + "type": "string" + }, + "config_dir": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Config Dir" + }, + "protocol": { + "default": "tcps", + "enum": [ + "tcp", + "tcps" + ], + "title": "Protocol", + "type": "string" + }, + "wallet_location": { + "title": "Wallet Location", + "type": "string" + }, + "wallet_password": { + "title": "Wallet Password", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "MTlsOracleDatabaseConnectionConfig" + } + }, + "required": [ + "dsn", + "name", + "password", + "user", + "wallet_location", + "wallet_password" + ], + "title": "MTlsOracleDatabaseConnectionConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseManagerWorkers": { + "additionalProperties": false, + "description": "Defines a ``ManagerWorkers`` conversational component.\n\nA ``ManagerWorkers`` is a multi-agent conversational component in which a group manager\nassigns tasks to the workers. The group manager and workers can be instantiated from\nany ``AgenticComponent`` type.\n\nExamples\n--------\n>>> from pyagentspec.agent import Agent\n>>> from pyagentspec.managerworkers import ManagerWorkers\n>>> manager_agent = Agent(\n... name=\"manager_agent\",\n... description=\"Agent that manages a group of math agents\",\n... llm_config=llm_config,\n... system_prompt=\"You are the manager of a group of math agents\"\n... )\n>>> multiplication_agent = Agent(\n... name=\"multiplication_agent\",\n... description=\"Agent that can do multiplication\",\n... llm_config=llm_config,\n... system_prompt=\"You can do multiplication.\"\n... )\n>>> division_agent = Agent(\n... name=\"division_agent\",\n... description=\"Agent that can do division\",\n... llm_config=llm_config,\n... system_prompt=\"You can do division.\"\n... )\n>>> group = ManagerWorkers(\n... name=\"managerworkers\",\n... group_manager=manager_agent,\n... workers=[multiplication_agent, division_agent],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "group_manager": { + "$ref": "#/$defs/AgenticComponent" + }, + "workers": { + "items": { + "$ref": "#/$defs/AgenticComponent" + }, + "title": "Workers", + "type": "array" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ManagerWorkers" + } + }, + "required": [ + "group_manager", + "name", + "workers" + ], + "title": "ManagerWorkers", + "type": "object", + "x-abstract-component": false + }, + "BaseMapNode": { + "additionalProperties": false, + "description": "The map node executes a subflow on each element of a given input as part of a flow.\n\n- **Inputs**\n Inferred from the inner structure. It's the sets of inputs\n required by the StartNode of the inner flow.\n The names of the inputs will be the ones of the inner flow,\n complemented with the ``iterated_`` prefix. Their type is\n ``Union[inner_type, List[inner_type]]``, where ``inner_type``\n is the type of the respective input in the inner flow.\n\n If None is given, ``pyagentspec`` infers input properties directly from the inner flow,\n specifying title and type according to the rules defined above.\n\n- **Outputs**\n Inferred from the inner structure. It's the union of the\n sets of outputs exposed by the EndNodes of the inner flow,\n combined with the reducer method of each output.\n The names of the outputs will be the ones of the inner flow,\n complemented with the ``collected_`` prefix. Their type depends\n on the ``reduce`` method specified for that output:\n\n - ``List`` of the respective output type in case of ``append``\n - same type of the respective output type in case of ``sum``, ``avg``\n\n If None is given, ``pyagentspec`` infers outputs by exposing\n an output property for each entry in the ``reducers`` dictionary, specifying title\n and type according to the rules defined above.\n\n- **Branches**\n One, the default next.\n\nExamples\n--------\nIn this example we will create a flow that returns\nan L2-normalized version of a given list of numbers.\n\n>>> from pyagentspec.property import Property\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import EndNode, StartNode, MapNode, ToolNode\n>>> from pyagentspec.tools import ServerTool\n\nFirst, we define a MapNode that returns the square of all the elements in a list.\nIt will be used to compute the L2-norm.\n\n>>> x_property = Property(json_schema={\"title\": \"x\", \"type\": \"number\"})\n>>> x_square_property = Property(\n... json_schema={\"title\": \"x_square\", \"type\": \"number\"}\n... )\n>>> square_tool = ServerTool(\n... name=\"compute_square_tool\",\n... description=\"Computes the square of a number\",\n... inputs=[x_property],\n... outputs=[x_square_property],\n... )\n>>> list_of_x_property = Property(\n... json_schema={\"title\": \"x_list\", \"type\": \"array\", \"items\": {\"type\": \"number\"}}\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[x_property])\n>>> end_node = EndNode(name=\"end\", outputs=[x_square_property])\n>>> square_tool_node = ToolNode(name=\"square tool node\", tool=square_tool)\n>>> square_number_flow = Flow(\n... name=\"Square number flow\",\n... start_node=start_node,\n... nodes=[start_node, square_tool_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(\n... name=\"start_to_tool\", from_node=start_node, to_node=square_tool_node\n... ),\n... ControlFlowEdge(\n... name=\"tool_to_end\", from_node=square_tool_node, to_node=end_node\n... ),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"x_edge\",\n... source_node=start_node,\n... source_output=\"x\",\n... destination_node=square_tool_node,\n... destination_input=\"x\",\n... ),\n... DataFlowEdge(\n... name=\"x_square_edge\",\n... source_node=square_tool_node,\n... source_output=\"x_square\",\n... destination_node=end_node,\n... destination_input=\"x_square\",\n... ),\n... ],\n... )\n>>> list_of_x_square_property = Property(\n... json_schema={\"title\": \"x_square_list\", \"type\": \"array\", \"items\": {\"type\": \"number\"}}\n... )\n>>> square_numbers_map_node = MapNode(\n... name=\"square number map node\",\n... subflow=square_number_flow,\n... )\n\nNow we define the MapNode responsible for normalizing the given list of input numbers.\nThe denominator is the same for all of the numbers,\nwe are going to map only the numerators (i.e., the input numbers).\n\n>>> numerator_property = Property(\n... json_schema={\"title\": \"numerator\", \"type\": \"number\"}\n... )\n>>> denominator_property = Property(\n... json_schema={\"title\": \"denominator\", \"type\": \"number\"}\n... )\n>>> result_property = Property(\n... json_schema={\"title\": \"result\", \"type\": \"number\"}\n... )\n>>> division_tool = ServerTool(\n... name=\"division_tool\",\n... description=\"Computes the ratio between two numbers\",\n... inputs=[numerator_property, denominator_property],\n... outputs=[result_property],\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[numerator_property, denominator_property])\n>>> end_node = EndNode(name=\"end\", outputs=[result_property])\n>>> divide_node = ToolNode(name=\"divide node\", tool=division_tool)\n>>> normalize_flow = Flow(\n... name=\"Normalize flow\",\n... start_node=start_node,\n... nodes=[start_node, divide_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_tool\", from_node=start_node, to_node=divide_node),\n... ControlFlowEdge(name=\"tool_to_end\", from_node=divide_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"numerator_edge\",\n... source_node=start_node,\n... source_output=\"numerator\",\n... destination_node=divide_node,\n... destination_input=\"numerator\",\n... ),\n... DataFlowEdge(\n... name=\"denominator_edge\",\n... source_node=start_node,\n... source_output=\"denominator\",\n... destination_node=divide_node,\n... destination_input=\"denominator\",\n... ),\n... DataFlowEdge(\n... name=\"result_edge\",\n... source_node=divide_node,\n... source_output=\"result\",\n... destination_node=end_node,\n... destination_input=\"result\",\n... ),\n... ],\n... )\n\nFinally, we define the overall flow:\n\n- The list of inputs is squared\n- The squared list is summed and root squared\n- The list of inputs is normalized based on the outcomes of the previous 2 steps\n\n>>> squared_sum_property = Property(\n... json_schema={\"title\": \"squared_sum\", \"type\": \"number\"}\n... )\n>>> normalized_list_of_x_property = Property(\n... json_schema={\n... \"title\": \"x_list_normalized\",\n... \"type\": \"array\",\n... \"items\": {\"type\": \"number\"},\n... }\n... )\n>>> normalize_map_node = MapNode(\n... name=\"normalize map node\",\n... subflow=normalize_flow,\n... )\n>>> squared_sum_tool = ServerTool(\n... name=\"squared_sum_tool\",\n... description=\"Computes the squared sum of a list of numbers\",\n... inputs=[list_of_x_property],\n... outputs=[squared_sum_property],\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[list_of_x_property])\n>>> end_node = EndNode(name=\"end\", outputs=[normalized_list_of_x_property])\n>>> squared_sum_tool_node = ToolNode(name=\"squared sum tool node\", tool=squared_sum_tool)\n>>> flow = Flow(\n... name=\"L2 normalize flow\",\n... start_node=start_node,\n... nodes=[\n... start_node,\n... square_numbers_map_node,\n... squared_sum_tool_node,\n... normalize_map_node,\n... end_node,\n... ],\n... control_flow_connections=[\n... ControlFlowEdge(\n... name=\"start_to_square_numbers\",\n... from_node=start_node,\n... to_node=square_numbers_map_node\n... ),\n... ControlFlowEdge(\n... name=\"square_numbers_to_squared_sum_tool\",\n... from_node=square_numbers_map_node,\n... to_node=squared_sum_tool_node\n... ),\n... ControlFlowEdge(\n... name=\"squared_sum_tool_to_normalize\",\n... from_node=squared_sum_tool_node,\n... to_node=normalize_map_node\n... ),\n... ControlFlowEdge(\n... name=\"normalize_to_end\",\n... from_node=normalize_map_node,\n... to_node=end_node\n... ),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"list_of_x_edge\",\n... source_node=start_node,\n... source_output=\"x_list\",\n... destination_node=square_numbers_map_node,\n... destination_input=\"iterated_x\",\n... ),\n... DataFlowEdge(\n... name=\"x_square_list_edge\",\n... source_node=square_numbers_map_node,\n... source_output=\"collected_x_square\",\n... destination_node=squared_sum_tool_node,\n... destination_input=\"x_list\",\n... ),\n... DataFlowEdge(\n... name=\"numerator_edge\",\n... source_node=start_node,\n... source_output=\"x_list\",\n... destination_node=normalize_map_node,\n... destination_input=\"iterated_numerator\",\n... ),\n... DataFlowEdge(\n... name=\"denominator_edge\",\n... source_node=squared_sum_tool_node,\n... source_output=\"squared_sum\",\n... destination_node=normalize_map_node,\n... destination_input=\"iterated_denominator\",\n... ),\n... DataFlowEdge(\n... name=\"x_list_normalized_edge\",\n... source_node=normalize_map_node,\n... source_output=\"collected_result\",\n... destination_node=end_node,\n... destination_input=\"x_list_normalized\",\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "subflow": { + "$ref": "#/$defs/Flow" + }, + "reducers": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/$defs/ReductionMethod" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reducers" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "MapNode" + } + }, + "required": [ + "name", + "subflow" + ], + "title": "MapNode", + "type": "object", + "x-abstract-component": false + }, + "BaseMessageSummarizationTransform": { + "additionalProperties": false, + "description": "Summarizes oversized messages using an LLM and optionally caches summaries.\n\nThis is useful for long conversations where the context can become too large for the LLM to handle.\n\nExamples\n--------\n>>> from pyagentspec.transforms import MessageSummarizationTransform\n>>> summarization_transform = MessageSummarizationTransform(\n... name=\"message-summarizer\",\n... llm=llm_config,\n... max_message_size=30_000\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "llm": { + "$ref": "#/$defs/LlmConfig" + }, + "max_message_size": { + "default": 20000, + "title": "Max Message Size", + "type": "integer" + }, + "summarization_instructions": { + "default": "Please make a summary of this message. Include relevant information and keep it short. Your response will replace the message, so just output the summary directly, no introduction needed.", + "title": "Summarization Instructions", + "type": "string" + }, + "summarized_message_template": { + "default": "Summarized message: {{summary}}", + "title": "Summarized Message Template", + "type": "string" + }, + "max_cache_size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 10000, + "title": "Max Cache Size" + }, + "max_cache_lifetime": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 14400, + "title": "Max Cache Lifetime" + }, + "cache_collection_name": { + "default": "summarized_messages_cache", + "title": "Cache Collection Name", + "type": "string" + }, + "datastore": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/InMemoryCollectionDatastore" + }, + { + "$ref": "#/$defs/OracleDatabaseDatastore" + }, + { + "$ref": "#/$defs/PostgresDatabaseDatastore" + } + ], + "title": "Datastore" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "MessageSummarizationTransform" + } + }, + "required": [ + "llm", + "name" + ], + "title": "MessageSummarizationTransform", + "type": "object", + "x-abstract-component": false + }, + "BaseMessageTransform": { + "anyOf": [ + { + "$ref": "#/$defs/ConversationSummarizationTransform" + }, + { + "$ref": "#/$defs/MessageSummarizationTransform" + } + ], + "x-abstract-component": true + }, + "BaseNode": { + "anyOf": [ + { + "$ref": "#/$defs/AgentNode" + }, + { + "$ref": "#/$defs/ApiNode" + }, + { + "$ref": "#/$defs/BranchingNode" + }, + { + "$ref": "#/$defs/CatchExceptionNode" + }, + { + "$ref": "#/$defs/EndNode" + }, + { + "$ref": "#/$defs/FlowNode" + }, + { + "$ref": "#/$defs/InputMessageNode" + }, + { + "$ref": "#/$defs/LlmNode" + }, + { + "$ref": "#/$defs/MapNode" + }, + { + "$ref": "#/$defs/OutputMessageNode" + }, + { + "$ref": "#/$defs/ParallelFlowNode" + }, + { + "$ref": "#/$defs/ParallelMapNode" + }, + { + "$ref": "#/$defs/StartNode" + }, + { + "$ref": "#/$defs/ToolNode" + } + ], + "x-abstract-component": true + }, + "BaseOAuthClientConfig": { + "additionalProperties": false, + "description": "OAuth client identity / registration configuration.\n\nThis configuration describes how the runtime establishes the OAuth client\nidentity to use with the authorization server. It supports:\n- Pre-registered clients (static client_id/client_secret)\n- Client ID Metadata Documents (URL-formatted client_id)\n- Dynamic client registration (RFC 7591)", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "min_agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum", + "default": "26.1.2" + }, + "type": { + "enum": [ + "client_id_metadata_document", + "dynamic_registration", + "pre_registered" + ], + "title": "Type", + "type": "string" + }, + "client_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Client Id" + }, + "client_secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Client Secret" + }, + "token_endpoint_auth_method": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Token Endpoint Auth Method" + }, + "client_id_metadata_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Client Id Metadata Url" + }, + "registration_endpoint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Registration Endpoint" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OAuthClientConfig" + } + }, + "required": [ + "name", + "type" + ], + "title": "OAuthClientConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseOAuthConfig": { + "additionalProperties": false, + "description": "Configure OAuth-based authentication for a tool or transport.\n\nOAuthConfig is a generic configuration that can be used for both MCP servers\nand non-MCP remote API tools. It supports discovery-based configuration (via\n``issuer``) and explicit endpoints (via ``endpoints``).", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "min_agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum", + "default": "26.1.2" + }, + "issuer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Issuer" + }, + "endpoints": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/OAuthEndpoints" + } + ], + "default": null + }, + "client": { + "$ref": "#/$defs/OAuthClientConfig" + }, + "redirect_uri": { + "title": "Redirect Uri", + "type": "string" + }, + "scopes": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Scopes" + }, + "scope_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/ScopePolicy" + } + ], + "default": null + }, + "pkce": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/PKCEPolicy" + } + ], + "default": null + }, + "resource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Resource" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OAuthConfig" + } + }, + "required": [ + "client", + "name", + "redirect_uri" + ], + "title": "OAuthConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseOciAgent": { + "additionalProperties": false, + "description": "An agent is a component that can do several rounds of conversation to solve a task.\n\nThe agent is defined on the OCI console and this is only a wrapper to connect to it.\nIt can be executed by itself, or be executed in a flow using an AgentNode.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "agent_endpoint_id": { + "title": "Agent Endpoint Id", + "type": "string" + }, + "client_config": { + "$ref": "#/$defs/OciClientConfig" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OciAgent" + } + }, + "required": [ + "agent_endpoint_id", + "client_config", + "name" + ], + "title": "OciAgent", + "type": "object", + "x-abstract-component": false + }, + "BaseOciClientConfig": { + "anyOf": [ + { + "$ref": "#/$defs/OciClientConfigWithApiKey" + }, + { + "$ref": "#/$defs/OciClientConfigWithInstancePrincipal" + }, + { + "$ref": "#/$defs/OciClientConfigWithResourcePrincipal" + }, + { + "$ref": "#/$defs/OciClientConfigWithSecurityToken" + } + ], + "x-abstract-component": true + }, + "BaseOciClientConfigWithApiKey": { + "additionalProperties": false, + "description": "OCI client config class for authentication using API_KEY and a config file.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "service_endpoint": { + "title": "Service Endpoint", + "type": "string" + }, + "auth_type": { + "const": "API_KEY", + "default": "API_KEY", + "title": "Auth Type", + "type": "string" + }, + "auth_profile": { + "title": "Auth Profile", + "type": "string" + }, + "auth_file_location": { + "title": "Auth File Location", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OciClientConfigWithApiKey" + } + }, + "required": [ + "auth_file_location", + "auth_profile", + "name", + "service_endpoint" + ], + "title": "OciClientConfigWithApiKey", + "type": "object", + "x-abstract-component": false + }, + "BaseOciClientConfigWithInstancePrincipal": { + "additionalProperties": false, + "description": "OCI client config class for authentication using INSTANCE_PRINCIPAL.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "service_endpoint": { + "title": "Service Endpoint", + "type": "string" + }, + "auth_type": { + "const": "INSTANCE_PRINCIPAL", + "default": "INSTANCE_PRINCIPAL", + "title": "Auth Type", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OciClientConfigWithInstancePrincipal" + } + }, + "required": [ + "name", + "service_endpoint" + ], + "title": "OciClientConfigWithInstancePrincipal", + "type": "object", + "x-abstract-component": false + }, + "BaseOciClientConfigWithResourcePrincipal": { + "additionalProperties": false, + "description": "OCI client config class for authentication using RESOURCE_PRINCIPAL.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "service_endpoint": { + "title": "Service Endpoint", + "type": "string" + }, + "auth_type": { + "const": "RESOURCE_PRINCIPAL", + "default": "RESOURCE_PRINCIPAL", + "title": "Auth Type", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OciClientConfigWithResourcePrincipal" + } + }, + "required": [ + "name", + "service_endpoint" + ], + "title": "OciClientConfigWithResourcePrincipal", + "type": "object", + "x-abstract-component": false + }, + "BaseOciClientConfigWithSecurityToken": { + "additionalProperties": false, + "description": "OCI client config class for authentication using SECURITY_TOKEN.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "service_endpoint": { + "title": "Service Endpoint", + "type": "string" + }, + "auth_type": { + "const": "SECURITY_TOKEN", + "default": "SECURITY_TOKEN", + "title": "Auth Type", + "type": "string" + }, + "auth_profile": { + "title": "Auth Profile", + "type": "string" + }, + "auth_file_location": { + "title": "Auth File Location", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OciClientConfigWithSecurityToken" + } + }, + "required": [ + "auth_file_location", + "auth_profile", + "name", + "service_endpoint" + ], + "title": "OciClientConfigWithSecurityToken", + "type": "object", + "x-abstract-component": false + }, + "BaseOciGenAiConfig": { + "additionalProperties": false, + "description": "Class to configure a connection to a OCI GenAI hosted model.\n\nRequires to specify the model id and the client configuration to the OCI GenAI service.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "title": "Model Id", + "type": "string" + }, + "provider": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/ModelProvider" + } + ], + "default": null + }, + "api_provider": { + "const": "oci", + "default": "oci", + "title": "Api Provider", + "type": "string" + }, + "api_type": { + "$ref": "#/$defs/OciAPIType", + "default": "oci" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "compartment_id": { + "title": "Compartment Id", + "type": "string" + }, + "serving_mode": { + "$ref": "#/$defs/ServingMode", + "default": "ON_DEMAND" + }, + "client_config": { + "$ref": "#/$defs/OciClientConfig" + }, + "conversation_store_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Conversation Store Id" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OciGenAiConfig" + } + }, + "required": [ + "client_config", + "compartment_id", + "model_id", + "name" + ], + "title": "OciGenAiConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseOllamaConfig": { + "additionalProperties": false, + "description": "Class to configure a connection to a local model ran with Ollama.\n\nRequires to specify the url and port at which the model is running.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "title": "Model Id", + "type": "string" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "api_provider": { + "const": "ollama", + "default": "ollama", + "title": "Api Provider", + "type": "string" + }, + "api_type": { + "$ref": "#/$defs/OpenAIAPIType", + "default": "chat_completions" + }, + "url": { + "title": "Url", + "type": "string" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "key_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key File" + }, + "cert_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cert File" + }, + "ca_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ca File" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OllamaConfig" + } + }, + "required": [ + "model_id", + "name", + "url" + ], + "title": "OllamaConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseOpenAiCompatibleConfig": { + "anyOf": [ + { + "$ref": "#/$defs/OllamaConfig" + }, + { + "$ref": "#/$defs/VllmConfig" + }, + { + "additionalProperties": false, + "description": "Class to configure a connection to an LLM that is compatible with OpenAI completions APIs.\n\nRequires to specify the url of the APIs to contact.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "title": "Model Id", + "type": "string" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "api_provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Provider" + }, + "api_type": { + "$ref": "#/$defs/OpenAIAPIType", + "default": "chat_completions" + }, + "url": { + "title": "Url", + "type": "string" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "key_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key File" + }, + "cert_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cert File" + }, + "ca_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ca File" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OpenAiCompatibleConfig" + } + }, + "required": [ + "model_id", + "name", + "url" + ], + "title": "OpenAiCompatibleConfig", + "type": "object" + } + ], + "x-abstract-component": false + }, + "BaseOpenAiConfig": { + "additionalProperties": false, + "description": "Class to configure a connection to a OpenAI LLM.\n\nRequires to specify the identity of the model to use.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "title": "Model Id", + "type": "string" + }, + "provider": { + "const": "openai", + "default": "openai", + "title": "Provider", + "type": "string" + }, + "api_provider": { + "const": "openai", + "default": "openai", + "title": "Api Provider", + "type": "string" + }, + "api_type": { + "$ref": "#/$defs/OpenAIAPIType", + "default": "chat_completions" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OpenAiConfig" + } + }, + "required": [ + "model_id", + "name" + ], + "title": "OpenAiConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseOracleDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/MTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/TlsOracleDatabaseConnectionConfig" + } + ], + "x-abstract-component": true + }, + "BaseOracleDatabaseDatastore": { + "additionalProperties": false, + "description": "Datastore that uses Oracle Database as the storage mechanism.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "datastore_schema": { + "additionalProperties": { + "$ref": "#/$defs/Property" + }, + "title": "Datastore Schema", + "type": "object" + }, + "connection_config": { + "$ref": "#/$defs/OracleDatabaseConnectionConfig" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OracleDatabaseDatastore" + } + }, + "required": [ + "connection_config", + "datastore_schema", + "name" + ], + "title": "OracleDatabaseDatastore", + "type": "object", + "x-abstract-component": false + }, + "BaseOutputMessageNode": { + "additionalProperties": false, + "description": "This node appends an agent message to the ongoing flow conversation.\n\n- **Inputs**\n One per variable in the message.\n- **Outputs**\n No outputs.\n- **Branches**\n One, the default next.\n\nExamples\n--------\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import StartNode, EndNode, InputMessageNode, OutputMessageNode, LlmNode\n>>> from pyagentspec.property import StringProperty\n>>> start_node = StartNode(name=\"start\")\n>>> prompt_node = OutputMessageNode(name=\"ask_input\", message=\"What is the paragraph you want to rephrase?\")\n>>> input_node = InputMessageNode(name=\"user_input\", outputs=[StringProperty(title=\"user_input\")])\n>>> llm_node = LlmNode(\n... name=\"rephrase\",\n... llm_config=llm_config,\n... prompt_template=\"Rephrase {{user_input}}\",\n... outputs=[StringProperty(title=\"rephrased_user_input\")],\n... )\n>>> output_node = OutputMessageNode(name=\"ask_input\", message=\"{{rephrased_user_input}}\")\n>>> end_node = EndNode(name=\"end\")\n>>> flow = Flow(\n... name=\"rephrase_paragraph_flow\",\n... start_node=start_node,\n... nodes=[start_node, prompt_node, input_node, llm_node, output_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"ce1\", from_node=start_node, to_node=prompt_node),\n... ControlFlowEdge(name=\"ce2\", from_node=prompt_node, to_node=input_node),\n... ControlFlowEdge(name=\"ce3\", from_node=input_node, to_node=llm_node),\n... ControlFlowEdge(name=\"ce4\", from_node=llm_node, to_node=output_node),\n... ControlFlowEdge(name=\"ce5\", from_node=output_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"de1\",\n... source_node=input_node,\n... source_output=\"user_input\",\n... destination_node=llm_node,\n... destination_input=\"user_input\",\n... ),\n... DataFlowEdge(\n... name=\"de2\",\n... source_node=llm_node,\n... source_output=\"rephrased_user_input\",\n... destination_node=output_node,\n... destination_input=\"rephrased_user_input\",\n... ),\n... ]\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "message": { + "title": "Message", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "OutputMessageNode" + } + }, + "required": [ + "message", + "name" + ], + "title": "OutputMessageNode", + "type": "object", + "x-abstract-component": false + }, + "BaseParallelFlowNode": { + "additionalProperties": false, + "description": "The parallel flow node executes multiple subflows in parallel.\n\n- **Inputs**\n Inferred from the inner structure. It's the union of the sets of inputs of the inner flows.\n Inputs of different inner flows that have the same name are merged if they have the same type.\n\n- **Outputs**\n Inferred from the inner structure. It's the union of the outputs of the inner flows.\n Outputs of different inner flows that have the same name are not allowed.\n\n- **Branches**\n One, the default next.\n\nExamples\n--------", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "subflows": { + "items": { + "$ref": "#/$defs/Flow" + }, + "title": "Subflows", + "type": "array" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ParallelFlowNode" + } + }, + "required": [ + "name" + ], + "title": "ParallelFlowNode", + "type": "object", + "x-abstract-component": false + }, + "BaseParallelMapNode": { + "additionalProperties": false, + "description": "The parallel map node executes a subflow on each element of a given input in a parallel manner.\n\n- **Inputs**\n Inferred from the inner structure. It's the sets of inputs\n required by the StartNode of the inner flow.\n The names of the inputs will be the ones of the inner flow,\n complemented with the ``iterated_`` prefix. Their type is\n ``Union[inner_type, List[inner_type]]``, where ``inner_type``\n is the type of the respective input in the inner flow.\n\n If None is given, ``pyagentspec`` infers input properties directly from the inner flow,\n specifying title and type according to the rules defined above.\n\n- **Outputs**\n Inferred from the inner structure. It's the union of the\n sets of outputs exposed by the EndNodes of the inner flow,\n combined with the reducer method of each output.\n The names of the outputs will be the ones of the inner flow,\n complemented with the ``collected_`` prefix. Their type depends\n on the ``reduce`` method specified for that output:\n\n - ``List`` of the respective output type in case of ``append``\n - same type of the respective output type in case of ``sum``, ``avg``\n\n If None is given, ``pyagentspec`` infers outputs by exposing\n an output property for each entry in the ``reducers`` dictionary, specifying title\n and type according to the rules defined above.\n\n- **Branches**\n One, the default next.\n\nExamples\n--------\nIn this example we create a flow that generates a summary of the given articles that talk about LLMs.\n\n>>> from pyagentspec.property import BooleanProperty, StringProperty, ListProperty\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import EndNode, StartNode, ParallelMapNode, LlmNode, BranchingNode\n>>> from pyagentspec.flows.node import Node\n\nFirst we define the flow that determines if an article talks about LLMs or not:\n\n- if it does, we return it, so that we will use it for our summary\n- if it does not, we don't return its text\n\n>>> def create_data_flow_edge(source_node: Node, destination_node: Node, property_name: str) -> DataFlowEdge:\n... return DataFlowEdge(\n... name=f\"{source_node.name}_{destination_node.name}_{property_name}_edge\",\n... source_node=source_node,\n... source_output=property_name,\n... destination_node=destination_node,\n... destination_input=property_name,\n... )\n>>>\n>>> article_property = StringProperty(title=\"article\")\n>>> is_llm_article_property = StringProperty(title=\"is_article\")\n>>> llm_node = LlmNode(\n... name=\"check_if_article_talks_about_llms_node\",\n... prompt_template=\"Look at this article: {{article}}. Does it talk about LLMs? Answer `yes` or `no`.\",\n... llm_config=llm_config,\n... inputs=[article_property],\n... outputs=[is_llm_article_property],\n... )\n>>>\n>>> branching_node = BranchingNode(\n... name=\"decide_if_we_should_return_the_article\",\n... mapping={\"yes\": \"yes\"},\n... inputs=[is_llm_article_property],\n... )\n>>>\n>>> start_node = StartNode(name=\"start\", inputs=[article_property])\n>>> end_node_with_output = EndNode(name=\"end_with_output\", outputs=[article_property])\n>>> end_node_without_output = EndNode(name=\"end_without_output\", outputs=[])\n>>>\n>>> check_if_article_is_about_llm_flow = Flow(\n... name=\"is_article_about_llm_flow\",\n... start_node=start_node,\n... nodes=[start_node, llm_node, end_node_with_output, end_node_without_output, branching_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_llm\", from_node=start_node, to_node=llm_node),\n... ControlFlowEdge(name=\"llm_to_branching\", from_node=llm_node, to_node=branching_node),\n... ControlFlowEdge(name=\"branching_to_end_with\", from_node=branching_node, to_node=end_node_with_output),\n... ControlFlowEdge(name=\"branching_to_end_without\", from_node=branching_node, to_node=end_node_without_output),\n... ],\n... data_flow_connections=[\n... create_data_flow_edge(start_node, llm_node, article_property.title),\n... create_data_flow_edge(llm_node, branching_node, is_llm_article_property.title),\n... create_data_flow_edge(start_node, end_node_with_output, article_property.title),\n... ],\n... outputs=[StringProperty(title=article_property.title, default=\"\")],\n... )\n\nWe put this flow into a ``ParallelMapNode``, so that we can perform the check\nin parallel on multiple articles at the same time.\n\n>>> list_of_articles_property = ListProperty(title=\"iterated_article\", item_type=article_property)\n>>> list_of_articles_about_llm_property = ListProperty(title=\"collected_article\", item_type=article_property)\n>>> parallel_article_check_node = ParallelMapNode(\n... name=\"parallel_check_if_articles_talk_about_llms_node\",\n... subflow=check_if_article_is_about_llm_flow,\n... inputs=[list_of_articles_property],\n... outputs=[list_of_articles_about_llm_property],\n... )\n\nFinally, we create the flow that takes the list of articles as input, filters them through the\n``ParallelMapNode`` we have just created, and we generate the summary with the remaining articles.\n\n>>> summary_property = StringProperty(title=\"summary\")\n>>> summary_llm_node = LlmNode(\n... name=\"generate_summary_of_llm_articles_node\",\n... prompt_template=\"Summarize the following articles that talk about LLMs: {{collected_article}}\",\n... llm_config=llm_config,\n... inputs=[list_of_articles_about_llm_property],\n... outputs=[summary_property],\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[list_of_articles_property])\n>>> end_node = EndNode(name=\"end\", outputs=[summary_property])\n>>> generate_summary_of_llm_articles_flow = Flow(\n... name=\"Generate summary of articles about LLMs flow\",\n... start_node=start_node,\n... nodes=[start_node, parallel_article_check_node, summary_llm_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_parallelmap\", from_node=start_node, to_node=parallel_article_check_node),\n... ControlFlowEdge(name=\"parallelmap_to_llm\", from_node=parallel_article_check_node, to_node=summary_llm_node),\n... ControlFlowEdge(name=\"llm_to_end\", from_node=summary_llm_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... create_data_flow_edge(start_node, parallel_article_check_node, list_of_articles_property.title),\n... create_data_flow_edge(parallel_article_check_node, summary_llm_node, list_of_articles_about_llm_property.title),\n... create_data_flow_edge(summary_llm_node, end_node, summary_property.title),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "subflow": { + "$ref": "#/$defs/Flow" + }, + "reducers": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/$defs/ReductionMethod" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Reducers" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ParallelMapNode" + } + }, + "required": [ + "name", + "subflow" + ], + "title": "ParallelMapNode", + "type": "object", + "x-abstract-component": false + }, + "BasePostgresDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/TlsPostgresDatabaseConnectionConfig" + } + ], + "x-abstract-component": true + }, + "BasePostgresDatabaseDatastore": { + "additionalProperties": false, + "description": "Datastore that uses PostgreSQL Database as the storage mechanism.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "datastore_schema": { + "additionalProperties": { + "$ref": "#/$defs/Property" + }, + "title": "Datastore Schema", + "type": "object" + }, + "connection_config": { + "$ref": "#/$defs/PostgresDatabaseConnectionConfig" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "PostgresDatabaseDatastore" + } + }, + "required": [ + "connection_config", + "datastore_schema", + "name" + ], + "title": "PostgresDatabaseDatastore", + "type": "object", + "x-abstract-component": false + }, + "BaseRemoteAgent": { + "anyOf": [ + { + "$ref": "#/$defs/A2AAgent" + }, + { + "$ref": "#/$defs/OciAgent" + } + ], + "x-abstract-component": true + }, + "BaseRemoteTool": { + "additionalProperties": false, + "description": "A tool that is run remotely and called through REST.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "requires_confirmation": { + "default": false, + "title": "Requires Confirmation", + "type": "boolean" + }, + "url": { + "title": "Url", + "type": "string" + }, + "http_method": { + "title": "Http Method", + "type": "string" + }, + "api_spec_uri": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Spec Uri" + }, + "data": { + "anyOf": [ + { + "type": "string" + }, + { + "format": "binary", + "type": "string" + }, + {} + ], + "title": "Data" + }, + "query_params": { + "additionalProperties": true, + "title": "Query Params", + "type": "object" + }, + "headers": { + "additionalProperties": true, + "title": "Headers", + "type": "object" + }, + "sensitive_headers": { + "additionalProperties": true, + "title": "Sensitive Headers", + "type": "object" + }, + "url_allow_list": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Url Allow List" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "RemoteTool" + } + }, + "required": [ + "http_method", + "name", + "url" + ], + "title": "RemoteTool", + "type": "object", + "x-abstract-component": false + }, + "BaseRemoteTransport": { + "anyOf": [ + { + "$ref": "#/$defs/SSETransport" + }, + { + "$ref": "#/$defs/SSEmTLSTransport" + }, + { + "$ref": "#/$defs/StreamableHTTPTransport" + }, + { + "$ref": "#/$defs/StreamableHTTPmTLSTransport" + } + ], + "x-abstract-component": true + }, + "BaseSSETransport": { + "anyOf": [ + { + "$ref": "#/$defs/SSEmTLSTransport" + }, + { + "additionalProperties": false, + "description": "Transport implementation that connects to an MCP server via Server-Sent Events.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "session_parameters": { + "$ref": "#/$defs/SessionParameters" + }, + "url": { + "title": "Url", + "type": "string" + }, + "auth": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/AuthConfig" + } + ], + "default": null + }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Headers" + }, + "sensitive_headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sensitive Headers" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "SSETransport" + } + }, + "required": [ + "name", + "url" + ], + "title": "SSETransport", + "type": "object" + } + ], + "x-abstract-component": false + }, + "BaseSSEmTLSTransport": { + "additionalProperties": false, + "description": "Transport layer for SSE with mTLS (mutual Transport Layer Security).\n\nThis transport establishes a secure, mutually authenticated TLS connection to the MCP server using client\ncertificates. Production deployments MUST use this transport to ensure both client and server identities\nare verified.\n\nNotes\n-----\n- Users MUST provide a valid client certificate (PEM format) and private key.\n- Users MUST provide (or trust) the correct certificate authority (CA) for the server they're connecting to.\n- The client certificate/key and CA certificate paths can be managed via secrets, config files, or secure\n environment variables in any production system.\n- Executors should ensure that these files are rotated and managed securely.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "session_parameters": { + "$ref": "#/$defs/SessionParameters" + }, + "url": { + "title": "Url", + "type": "string" + }, + "auth": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/AuthConfig" + } + ], + "default": null + }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Headers" + }, + "sensitive_headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sensitive Headers" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "key_file": { + "title": "Key File", + "type": "string" + }, + "cert_file": { + "title": "Cert File", + "type": "string" + }, + "ca_file": { + "title": "Ca File", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "SSEmTLSTransport" + } + }, + "required": [ + "ca_file", + "cert_file", + "key_file", + "name", + "url" + ], + "title": "SSEmTLSTransport", + "type": "object", + "x-abstract-component": false + }, + "BaseServerTool": { + "additionalProperties": false, + "description": "A tool that is registered to and executed by the orchestrator.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "requires_confirmation": { + "default": false, + "title": "Requires Confirmation", + "type": "boolean" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ServerTool" + } + }, + "required": [ + "name" + ], + "title": "ServerTool", + "type": "object", + "x-abstract-component": false + }, + "BaseSpecializedAgent": { + "additionalProperties": false, + "description": "A specialized agent is an agent that uses an existing generic agent and\nspecializes it to solve a given task.\n\nIt can be executed anywhere an Agent can be executed.\n\nExamples\n--------\n>>> from pyagentspec.agent import Agent\n>>> from pyagentspec.property import StringProperty\n>>> from pyagentspec.specialized_agent import AgentSpecializationParameters, SpecializedAgent\n>>> from pyagentspec.tools import ServerTool\n>>> expertise_property = StringProperty(title=\"domain_of_expertise\")\n>>> system_prompt = '''You are an expert in {{domain_of_expertise}}.\n... Please help the users with their requests.'''\n>>> agent = Agent(\n... name=\"Adaptive expert agent\",\n... system_prompt=system_prompt,\n... llm_config=llm_config,\n... inputs=[expertise_property],\n... )\n>>> websearch_tool = ServerTool(\n... name=\"websearch_tool\",\n... description=\"Search the web for information\",\n... inputs=[StringProperty(title=\"query\")],\n... outputs=[StringProperty(title=\"search_result\")],\n... )\n>>> agent_specialization_parameters = AgentSpecializationParameters(\n... name=\"essay_agent\",\n... additional_instructions=\"Your goal is to help the user write an essay around the domain of expertise.\",\n... additional_tools=[websearch_tool]\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "agent": { + "$ref": "#/$defs/Agent" + }, + "agent_specialization_parameters": { + "$ref": "#/$defs/AgentSpecializationParameters" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "SpecializedAgent" + } + }, + "required": [ + "agent", + "agent_specialization_parameters", + "name" + ], + "title": "SpecializedAgent", + "type": "object", + "x-abstract-component": false + }, + "BaseStartNode": { + "additionalProperties": false, + "description": "Start nodes denote the start of the execution of a flow.\n\n- **Inputs**\n The list of inputs that should be the inputs of the flow. If both input and output\n properties are specified they must be an exact match\n\n If None is given, ``pyagentspec`` copies the outputs provided, if any. Otherwise, no input is exposed.\n- **Outputs**\n The list of outputs of the step. If both input and output properties are specified they\n must be an exact match\n\n If None is given, ``pyagentspec`` copies the inputs provided, if any. Otherwise, no output is exposed.\n- **Branches**\n One, the default next.\n\nExamples\n--------\n>>> from pyagentspec.property import Property\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import EndNode, LlmNode, StartNode\n>>> user_question_property = Property(\n... json_schema=dict(\n... title=\"user_question\",\n... description=\"The user question.\",\n... type=\"string\",\n... )\n... )\n>>> answer_property = Property(json_schema=dict(title=\"answer\", type=\"string\"))\n>>> start_node = StartNode(name=\"start\", inputs=[user_question_property])\n>>> end_node = EndNode(name=\"end\", outputs=[answer_property])\n>>> llm_node = LlmNode(\n... name=\"llm node\",\n... prompt_template=\"Answer the user question: {{user_question}}\",\n... llm_config=llm_config,\n... )\n>>> flow = Flow(\n... name=\"flow\",\n... start_node=start_node,\n... nodes=[start_node, llm_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_llm\", from_node=start_node, to_node=llm_node),\n... ControlFlowEdge(name=\"llm_to_end\", from_node=llm_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"query_edge\",\n... source_node=start_node,\n... source_output=\"user_question\",\n... destination_node=llm_node,\n... destination_input=\"user_question\",\n... ),\n... DataFlowEdge(\n... name=\"answer_edge\",\n... source_node=llm_node,\n... source_output=\"generated_text\",\n... destination_node=end_node,\n... destination_input=\"answer\"\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "StartNode" + } + }, + "required": [ + "name" + ], + "title": "StartNode", + "type": "object", + "x-abstract-component": false + }, + "BaseStdioTransport": { + "additionalProperties": false, + "description": "Base transport for connecting to an MCP server via subprocess with stdio.\n\nThis is a base class that can be subclassed for specific command-based\ntransports like Python, Node, Uvx, etc.\n\n.. warning::\n Stdio should be used for local prototyping only.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "session_parameters": { + "$ref": "#/$defs/SessionParameters" + }, + "command": { + "title": "Command", + "type": "string" + }, + "args": { + "items": { + "type": "string" + }, + "title": "Args", + "type": "array" + }, + "env": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Env" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cwd" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "StdioTransport" + } + }, + "required": [ + "command", + "name" + ], + "title": "StdioTransport", + "type": "object", + "x-abstract-component": false + }, + "BaseStreamableHTTPTransport": { + "anyOf": [ + { + "$ref": "#/$defs/StreamableHTTPmTLSTransport" + }, + { + "additionalProperties": false, + "description": "Transport implementation that connects to an MCP server via Streamable HTTP.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "session_parameters": { + "$ref": "#/$defs/SessionParameters" + }, + "url": { + "title": "Url", + "type": "string" + }, + "auth": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/AuthConfig" + } + ], + "default": null + }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Headers" + }, + "sensitive_headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sensitive Headers" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "StreamableHTTPTransport" + } + }, + "required": [ + "name", + "url" + ], + "title": "StreamableHTTPTransport", + "type": "object" + } + ], + "x-abstract-component": false + }, + "BaseStreamableHTTPmTLSTransport": { + "additionalProperties": false, + "description": "Transport layer for streamable HTTP with mTLS (mutual Transport Layer Security).\n\nThis transport establishes a secure, mutually authenticated TLS connection to the MCP server using client\ncertificates. Production deployments MUST use this transport to ensure both client and server identities\nare verified.\n\nNotes\n-----\n- Users MUST provide a valid client certificate (PEM format) and private key.\n- Users MUST provide (or trust) the correct certificate authority (CA) for the server they're connecting to.\n- The client certificate/key and CA certificate paths can be managed via secrets, config files, or secure\n environment variables in any production system.\n- Executors should ensure that these files are rotated and managed securely.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "session_parameters": { + "$ref": "#/$defs/SessionParameters" + }, + "url": { + "title": "Url", + "type": "string" + }, + "auth": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/AuthConfig" + } + ], + "default": null + }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Headers" + }, + "sensitive_headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sensitive Headers" + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "key_file": { + "title": "Key File", + "type": "string" + }, + "cert_file": { + "title": "Cert File", + "type": "string" + }, + "ca_file": { + "title": "Ca File", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "StreamableHTTPmTLSTransport" + } + }, + "required": [ + "ca_file", + "cert_file", + "key_file", + "name", + "url" + ], + "title": "StreamableHTTPmTLSTransport", + "type": "object", + "x-abstract-component": false + }, + "BaseSwarm": { + "additionalProperties": false, + "description": "Defines a ``Swarm`` conversational component.\n\nA ``Swarm`` is a multi-agent conversational component in which each agent determines\nthe next agent to be executed, based on a list of pre-defined relationships.\nAgents in Swarm can be any ``AgenticComponent``.\n\nExamples\n--------\n>>> from pyagentspec.agent import Agent\n>>> from pyagentspec.swarm import Swarm\n>>> addition_agent = Agent(name=\"addition_agent\", description=\"Agent that can do additions\", llm_config=llm_config, system_prompt=\"You can do additions.\")\n>>> multiplication_agent = Agent(name=\"multiplication_agent\", description=\"Agent that can do multiplication\", llm_config=llm_config, system_prompt=\"You can do multiplication.\")\n>>> division_agent = Agent(name=\"division_agent\", description=\"Agent that can do division\", llm_config=llm_config, system_prompt=\"You can do division.\")\n>>>\n>>> swarm = Swarm(\n... name=\"swarm\",\n... first_agent=addition_agent,\n... relationships=[\n... (addition_agent, multiplication_agent),\n... (addition_agent, division_agent),\n... (multiplication_agent, division_agent),\n... ]\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "first_agent": { + "$ref": "#/$defs/AgenticComponent" + }, + "relationships": { + "items": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "$ref": "#/$defs/AgenticComponent" + }, + { + "$ref": "#/$defs/AgenticComponent" + } + ], + "type": "array" + }, + "title": "Relationships", + "type": "array" + }, + "handoff": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/$defs/HandoffMode" + } + ], + "default": "optional", + "title": "Handoff" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "Swarm" + } + }, + "required": [ + "first_agent", + "name", + "relationships" + ], + "title": "Swarm", + "type": "object", + "x-abstract-component": false + }, + "BaseTlsOracleDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/MTlsOracleDatabaseConnectionConfig" + }, + { + "additionalProperties": false, + "description": "TLS Connection Configuration to Oracle Database.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "user": { + "title": "User", + "type": "string" + }, + "password": { + "title": "Password", + "type": "string" + }, + "dsn": { + "title": "Dsn", + "type": "string" + }, + "config_dir": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Config Dir" + }, + "protocol": { + "default": "tcps", + "enum": [ + "tcp", + "tcps" + ], + "title": "Protocol", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "TlsOracleDatabaseConnectionConfig" + } + }, + "required": [ + "dsn", + "name", + "password", + "user" + ], + "title": "TlsOracleDatabaseConnectionConfig", + "type": "object" + } + ], + "x-abstract-component": false + }, + "BaseTlsPostgresDatabaseConnectionConfig": { + "additionalProperties": false, + "description": "Configuration for a PostgreSQL connection with TLS/SSL support.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "user": { + "title": "User", + "type": "string" + }, + "password": { + "title": "Password", + "type": "string" + }, + "url": { + "title": "Url", + "type": "string" + }, + "sslmode": { + "default": "require", + "enum": [ + "allow", + "disable", + "prefer", + "require", + "verify-ca", + "verify-full" + ], + "title": "Sslmode", + "type": "string" + }, + "sslcert": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sslcert" + }, + "sslkey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sslkey" + }, + "sslrootcert": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sslrootcert" + }, + "sslcrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Sslcrl" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "TlsPostgresDatabaseConnectionConfig" + } + }, + "required": [ + "name", + "password", + "url", + "user" + ], + "title": "TlsPostgresDatabaseConnectionConfig", + "type": "object", + "x-abstract-component": false + }, + "BaseTool": { + "anyOf": [ + { + "$ref": "#/$defs/BuiltinTool" + }, + { + "$ref": "#/$defs/ClientTool" + }, + { + "$ref": "#/$defs/MCPTool" + }, + { + "$ref": "#/$defs/RemoteTool" + }, + { + "$ref": "#/$defs/ServerTool" + } + ], + "x-abstract-component": true + }, + "BaseToolBox": { + "anyOf": [ + { + "$ref": "#/$defs/MCPToolBox" + } + ], + "x-abstract-component": true + }, + "BaseToolNode": { + "additionalProperties": false, + "description": "The tool execution node is a node that will execute a tool as part of a flow.\n\n- **Inputs**\n Inferred from the definition of the tool to execute.\n- **Outputs**\n Inferred from the definition of the tool to execute.\n- **Branches**\n One, the default next.\n\nExamples\n--------\n>>> from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge\n>>> from pyagentspec.flows.flow import Flow\n>>> from pyagentspec.flows.nodes import ToolNode, StartNode, EndNode\n>>> from pyagentspec.tools import ServerTool\n>>> from pyagentspec.property import Property\n>>>\n>>> x_property = Property(json_schema={\"title\": \"x\", \"type\": \"number\"})\n>>> x_square_root_property = Property(\n... json_schema={\"title\": \"x_square_root\", \"type\": \"number\"}\n... )\n>>> square_root_tool = ServerTool(\n... name=\"compute_square_root\",\n... description=\"Computes the square root of a number\",\n... inputs=[x_property],\n... outputs=[x_square_root_property],\n... )\n>>> start_node = StartNode(name=\"start\", inputs=[x_property])\n>>> end_node = EndNode(name=\"end\", outputs=[x_square_root_property])\n>>> tool_node = ToolNode(name=\"\", tool=square_root_tool)\n>>> flow = Flow(\n... name=\"Compute square root flow\",\n... start_node=start_node,\n... nodes=[start_node, tool_node, end_node],\n... control_flow_connections=[\n... ControlFlowEdge(name=\"start_to_tool\", from_node=start_node, to_node=tool_node),\n... ControlFlowEdge(name=\"tool_to_end\", from_node=tool_node, to_node=end_node),\n... ],\n... data_flow_connections=[\n... DataFlowEdge(\n... name=\"x_edge\",\n... source_node=start_node,\n... source_output=\"x\",\n... destination_node=tool_node,\n... destination_input=\"x\",\n... ),\n... DataFlowEdge(\n... name=\"x_square_root_edge\",\n... source_node=tool_node,\n... source_output=\"x_square_root\",\n... destination_node=end_node,\n... destination_input=\"x_square_root\"\n... ),\n... ],\n... )", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "inputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Inputs" + }, + "outputs": { + "anyOf": [ + { + "items": { + "$ref": "#/$defs/Property" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Outputs" + }, + "branches": { + "items": { + "type": "string" + }, + "title": "Branches", + "type": "array" + }, + "tool": { + "$ref": "#/$defs/Tool" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "ToolNode" + } + }, + "required": [ + "name", + "tool" + ], + "title": "ToolNode", + "type": "object", + "x-abstract-component": false + }, + "BaseVllmConfig": { + "additionalProperties": false, + "description": "Class to configure a connection to a vLLM-hosted LLM.\n\nRequires to specify the url at which the instance is running.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "metadata": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Metadata" + }, + "model_id": { + "title": "Model Id", + "type": "string" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" + }, + "api_provider": { + "const": "vllm", + "default": "vllm", + "title": "Api Provider", + "type": "string" + }, + "api_type": { + "$ref": "#/$defs/OpenAIAPIType", + "default": "chat_completions" + }, + "url": { + "title": "Url", + "type": "string" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "default_generation_parameters": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/LlmGenerationConfig" + } + ], + "default": null + }, + "retry_policy": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/RetryPolicy" + } + ], + "default": null + }, + "key_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Key File" + }, + "cert_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Cert File" + }, + "ca_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ca File" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "VllmConfig" + } + }, + "required": [ + "model_id", + "name", + "url" + ], + "title": "VllmConfig", + "type": "object", + "x-abstract-component": false + }, + "ReferencedComponents": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/BaseA2AAgent" + }, + { + "$ref": "#/$defs/BaseA2AConnectionConfig" + }, + { + "$ref": "#/$defs/BaseAgent" + }, + { + "$ref": "#/$defs/BaseAgentNode" + }, + { + "$ref": "#/$defs/BaseAgentSpecializationParameters" + }, + { + "$ref": "#/$defs/BaseAgenticComponent" + }, + { + "$ref": "#/$defs/BaseApiNode" + }, + { + "$ref": "#/$defs/BaseAuthConfig" + }, + { + "$ref": "#/$defs/BaseBranchingNode" + }, + { + "$ref": "#/$defs/BaseBuiltinTool" + }, + { + "$ref": "#/$defs/BaseCatchExceptionNode" + }, + { + "$ref": "#/$defs/BaseClientTool" + }, + { + "$ref": "#/$defs/BaseClientTransport" + }, + { + "$ref": "#/$defs/BaseComponentWithIO" + }, + { + "$ref": "#/$defs/BaseControlFlowEdge" + }, + { + "$ref": "#/$defs/BaseConversationSummarizationTransform" + }, + { + "$ref": "#/$defs/BaseDataFlowEdge" + }, + { + "$ref": "#/$defs/BaseDatastore" + }, + { + "$ref": "#/$defs/BaseDbmsVectorChainLlmConfig" + }, + { + "$ref": "#/$defs/BaseEndNode" + }, + { + "$ref": "#/$defs/BaseFlow" + }, + { + "$ref": "#/$defs/BaseFlowNode" + }, + { + "$ref": "#/$defs/BaseGeminiAIStudioAuthConfig" + }, + { + "$ref": "#/$defs/BaseGeminiAuthConfig" + }, + { + "$ref": "#/$defs/BaseGeminiConfig" + }, + { + "$ref": "#/$defs/BaseGeminiVertexAIAuthConfig" + }, + { + "$ref": "#/$defs/BaseInMemoryCollectionDatastore" + }, + { + "$ref": "#/$defs/BaseInputMessageNode" + }, + { + "$ref": "#/$defs/BaseLlmConfig" + }, + { + "$ref": "#/$defs/BaseLlmNode" + }, + { + "$ref": "#/$defs/BaseMCPTool" + }, + { + "$ref": "#/$defs/BaseMCPToolBox" + }, + { + "$ref": "#/$defs/BaseMCPToolSpec" + }, + { + "$ref": "#/$defs/BaseMTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/BaseManagerWorkers" + }, + { + "$ref": "#/$defs/BaseMapNode" + }, + { + "$ref": "#/$defs/BaseMessageSummarizationTransform" + }, + { + "$ref": "#/$defs/BaseMessageTransform" + }, + { + "$ref": "#/$defs/BaseNode" + }, + { + "$ref": "#/$defs/BaseOAuthClientConfig" + }, + { + "$ref": "#/$defs/BaseOAuthConfig" + }, + { + "$ref": "#/$defs/BaseOciAgent" + }, + { + "$ref": "#/$defs/BaseOciClientConfig" + }, + { + "$ref": "#/$defs/BaseOciClientConfigWithApiKey" + }, + { + "$ref": "#/$defs/BaseOciClientConfigWithInstancePrincipal" + }, + { + "$ref": "#/$defs/BaseOciClientConfigWithResourcePrincipal" + }, + { + "$ref": "#/$defs/BaseOciClientConfigWithSecurityToken" + }, + { + "$ref": "#/$defs/BaseOciGenAiConfig" + }, + { + "$ref": "#/$defs/BaseOllamaConfig" + }, + { + "$ref": "#/$defs/BaseOpenAiCompatibleConfig" + }, + { + "$ref": "#/$defs/BaseOpenAiConfig" + }, + { + "$ref": "#/$defs/BaseOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/BaseOracleDatabaseDatastore" + }, + { + "$ref": "#/$defs/BaseOutputMessageNode" + }, + { + "$ref": "#/$defs/BaseParallelFlowNode" + }, + { + "$ref": "#/$defs/BaseParallelMapNode" + }, + { + "$ref": "#/$defs/BasePostgresDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/BasePostgresDatabaseDatastore" + }, + { + "$ref": "#/$defs/BaseRemoteAgent" + }, + { + "$ref": "#/$defs/BaseRemoteTool" + }, + { + "$ref": "#/$defs/BaseRemoteTransport" + }, + { + "$ref": "#/$defs/BaseSSETransport" + }, + { + "$ref": "#/$defs/BaseSSEmTLSTransport" + }, + { + "$ref": "#/$defs/BaseServerTool" + }, + { + "$ref": "#/$defs/BaseSpecializedAgent" + }, + { + "$ref": "#/$defs/BaseStartNode" + }, + { + "$ref": "#/$defs/BaseStdioTransport" + }, + { + "$ref": "#/$defs/BaseStreamableHTTPTransport" + }, + { + "$ref": "#/$defs/BaseStreamableHTTPmTLSTransport" + }, + { + "$ref": "#/$defs/BaseSwarm" + }, + { + "$ref": "#/$defs/BaseTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/BaseTlsPostgresDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/BaseTool" + }, + { + "$ref": "#/$defs/BaseToolBox" + }, + { + "$ref": "#/$defs/BaseToolNode" + }, + { + "$ref": "#/$defs/BaseVllmConfig" + }, + { + "$ref": "#/$defs/ComponentReferenceWithNestedReferences" + } + ] + } + }, + "ComponentReference": { + "type": "object", + "properties": { + "$component_ref": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "$component_ref" + ] + }, + "ComponentReferenceWithNestedReferences": { + "type": "object", + "properties": { + "$component_ref": { + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + } + }, + "additionalProperties": false, + "required": [ + "$component_ref", + "$referenced_components" + ] + }, + "VersionedA2AAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseA2AAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedA2AConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseA2AConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedAgentNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgentNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedAgentSpecializationParameters": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgentSpecializationParameters" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedAgenticComponent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseAgenticComponent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedApiNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseApiNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedBranchingNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseBranchingNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedBuiltinTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseBuiltinTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedCatchExceptionNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseCatchExceptionNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedClientTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseClientTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedClientTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseClientTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedComponentWithIO": { + "anyOf": [ + { + "$ref": "#/$defs/BaseComponentWithIO" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedControlFlowEdge": { + "anyOf": [ + { + "$ref": "#/$defs/BaseControlFlowEdge" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedConversationSummarizationTransform": { + "anyOf": [ + { + "$ref": "#/$defs/BaseConversationSummarizationTransform" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedDataFlowEdge": { + "anyOf": [ + { + "$ref": "#/$defs/BaseDataFlowEdge" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/BaseDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedDbmsVectorChainLlmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseDbmsVectorChainLlmConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedEndNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseEndNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedFlow": { + "anyOf": [ + { + "$ref": "#/$defs/BaseFlow" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedFlowNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseFlowNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedGeminiAIStudioAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiAIStudioAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedGeminiAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedGeminiConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedGeminiVertexAIAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseGeminiVertexAIAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedInMemoryCollectionDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/BaseInMemoryCollectionDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedInputMessageNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseInputMessageNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedLlmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseLlmConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedLlmNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseLlmNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedMCPTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMCPTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedMCPToolBox": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMCPToolBox" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedMCPToolSpec": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMCPToolSpec" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedMTlsOracleDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedManagerWorkers": { + "anyOf": [ + { + "$ref": "#/$defs/BaseManagerWorkers" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedMapNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMapNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedMessageSummarizationTransform": { + "anyOf": [ + { + "$ref": "#/$defs/BaseMessageSummarizationTransform" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOAuthClientConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOAuthClientConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOAuthConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOAuthConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOciAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOciClientConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOciClientConfigWithApiKey": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithApiKey" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOciClientConfigWithInstancePrincipal": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithInstancePrincipal" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOciClientConfigWithResourcePrincipal": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithResourcePrincipal" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOciClientConfigWithSecurityToken": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciClientConfigWithSecurityToken" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOciGenAiConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOciGenAiConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOllamaConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOllamaConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOpenAiCompatibleConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOpenAiCompatibleConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOpenAiConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOpenAiConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOracleDatabaseDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOracleDatabaseDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedOutputMessageNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseOutputMessageNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedParallelFlowNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseParallelFlowNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedParallelMapNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseParallelMapNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedPostgresDatabaseDatastore": { + "anyOf": [ + { + "$ref": "#/$defs/BasePostgresDatabaseDatastore" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedRemoteAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseRemoteAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedRemoteTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseRemoteTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedRemoteTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseRemoteTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedSSETransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSSETransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedSSEmTLSTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSSEmTLSTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedServerTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseServerTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedSpecializedAgent": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSpecializedAgent" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedStartNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStartNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedStdioTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStdioTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedStreamableHTTPTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStreamableHTTPTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedStreamableHTTPmTLSTransport": { + "anyOf": [ + { + "$ref": "#/$defs/BaseStreamableHTTPmTLSTransport" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedSwarm": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSwarm" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedTlsOracleDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedTlsPostgresDatabaseConnectionConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseTlsPostgresDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedTool": { + "anyOf": [ + { + "$ref": "#/$defs/BaseTool" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedToolBox": { + "anyOf": [ + { + "$ref": "#/$defs/BaseToolBox" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedToolNode": { + "anyOf": [ + { + "$ref": "#/$defs/BaseToolNode" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedVllmConfig": { + "anyOf": [ + { + "$ref": "#/$defs/BaseVllmConfig" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, + "VersionedComponentReferenceWithNestedReferences": { + "type": "object", + "properties": { + "$component_ref": { + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + }, + "additionalProperties": false, + "required": [ + "$component_ref", + "$referenced_components" + ] + } + }, + "anyOf": [ + { + "$ref": "#/$defs/VersionedA2AAgent" + }, + { + "$ref": "#/$defs/VersionedA2AConnectionConfig" + }, + { + "$ref": "#/$defs/VersionedAgent" + }, + { + "$ref": "#/$defs/VersionedAgentNode" + }, + { + "$ref": "#/$defs/VersionedAgentSpecializationParameters" + }, + { + "$ref": "#/$defs/VersionedAgenticComponent" + }, + { + "$ref": "#/$defs/VersionedApiNode" + }, + { + "$ref": "#/$defs/VersionedBranchingNode" + }, + { + "$ref": "#/$defs/VersionedBuiltinTool" + }, + { + "$ref": "#/$defs/VersionedCatchExceptionNode" + }, + { + "$ref": "#/$defs/VersionedClientTool" + }, + { + "$ref": "#/$defs/VersionedClientTransport" + }, + { + "$ref": "#/$defs/VersionedComponentReferenceWithNestedReferences" + }, + { + "$ref": "#/$defs/VersionedComponentWithIO" + }, + { + "$ref": "#/$defs/VersionedControlFlowEdge" + }, + { + "$ref": "#/$defs/VersionedConversationSummarizationTransform" + }, + { + "$ref": "#/$defs/VersionedDataFlowEdge" + }, + { + "$ref": "#/$defs/VersionedDatastore" + }, + { + "$ref": "#/$defs/VersionedDbmsVectorChainLlmConfig" + }, + { + "$ref": "#/$defs/VersionedEndNode" + }, + { + "$ref": "#/$defs/VersionedFlow" + }, + { + "$ref": "#/$defs/VersionedFlowNode" + }, + { + "$ref": "#/$defs/VersionedGeminiAIStudioAuthConfig" + }, + { + "$ref": "#/$defs/VersionedGeminiAuthConfig" + }, + { + "$ref": "#/$defs/VersionedGeminiConfig" + }, + { + "$ref": "#/$defs/VersionedGeminiVertexAIAuthConfig" + }, + { + "$ref": "#/$defs/VersionedInMemoryCollectionDatastore" + }, + { + "$ref": "#/$defs/VersionedInputMessageNode" + }, + { + "$ref": "#/$defs/VersionedLlmConfig" + }, + { + "$ref": "#/$defs/VersionedLlmNode" + }, + { + "$ref": "#/$defs/VersionedMCPTool" + }, + { + "$ref": "#/$defs/VersionedMCPToolBox" + }, + { + "$ref": "#/$defs/VersionedMCPToolSpec" + }, + { + "$ref": "#/$defs/VersionedMTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/VersionedManagerWorkers" + }, + { + "$ref": "#/$defs/VersionedMapNode" + }, + { + "$ref": "#/$defs/VersionedMessageSummarizationTransform" + }, + { + "$ref": "#/$defs/VersionedNode" + }, + { + "$ref": "#/$defs/VersionedOAuthClientConfig" + }, + { + "$ref": "#/$defs/VersionedOAuthConfig" + }, + { + "$ref": "#/$defs/VersionedOciAgent" + }, + { + "$ref": "#/$defs/VersionedOciClientConfig" + }, + { + "$ref": "#/$defs/VersionedOciClientConfigWithApiKey" + }, + { + "$ref": "#/$defs/VersionedOciClientConfigWithInstancePrincipal" + }, + { + "$ref": "#/$defs/VersionedOciClientConfigWithResourcePrincipal" + }, + { + "$ref": "#/$defs/VersionedOciClientConfigWithSecurityToken" + }, + { + "$ref": "#/$defs/VersionedOciGenAiConfig" + }, + { + "$ref": "#/$defs/VersionedOllamaConfig" + }, + { + "$ref": "#/$defs/VersionedOpenAiCompatibleConfig" + }, + { + "$ref": "#/$defs/VersionedOpenAiConfig" + }, + { + "$ref": "#/$defs/VersionedOracleDatabaseDatastore" + }, + { + "$ref": "#/$defs/VersionedOutputMessageNode" + }, + { + "$ref": "#/$defs/VersionedParallelFlowNode" + }, + { + "$ref": "#/$defs/VersionedParallelMapNode" + }, + { + "$ref": "#/$defs/VersionedPostgresDatabaseDatastore" + }, + { + "$ref": "#/$defs/VersionedRemoteAgent" + }, + { + "$ref": "#/$defs/VersionedRemoteTool" + }, + { + "$ref": "#/$defs/VersionedRemoteTransport" + }, + { + "$ref": "#/$defs/VersionedSSETransport" + }, + { + "$ref": "#/$defs/VersionedSSEmTLSTransport" + }, + { + "$ref": "#/$defs/VersionedServerTool" + }, + { + "$ref": "#/$defs/VersionedSpecializedAgent" + }, + { + "$ref": "#/$defs/VersionedStartNode" + }, + { + "$ref": "#/$defs/VersionedStdioTransport" + }, + { + "$ref": "#/$defs/VersionedStreamableHTTPTransport" + }, + { + "$ref": "#/$defs/VersionedStreamableHTTPmTLSTransport" + }, + { + "$ref": "#/$defs/VersionedSwarm" + }, + { + "$ref": "#/$defs/VersionedTlsOracleDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/VersionedTlsPostgresDatabaseConnectionConfig" + }, + { + "$ref": "#/$defs/VersionedTool" + }, + { + "$ref": "#/$defs/VersionedToolBox" + }, + { + "$ref": "#/$defs/VersionedToolNode" + }, + { + "$ref": "#/$defs/VersionedVllmConfig" + } + ] +} diff --git a/docs/pyagentspec/source/agentspec/language_spec_26_3_0.rst b/docs/pyagentspec/source/agentspec/language_spec_26_3_0.rst new file mode 100644 index 00000000..71ee6a1a --- /dev/null +++ b/docs/pyagentspec/source/agentspec/language_spec_26_3_0.rst @@ -0,0 +1,3477 @@ +.. _agentspecspec_v26.3.0: + +========================================= +Agent Spec specification (version 26.3.0) +========================================= + +Language specification +====================== + +This document outlines the language specification for the Open Agent Specification, abbreviated as Agent Spec. + +This specification defines the foundational parts that compose the language, including the expected format +and the respective semantic. + +The class definition in the following sections is outlined via Python below for ease of comprehension. +This structure can be serialized into JSON, YAML, or other formats. +The respective serialization is also called Agent Spec configuration (or representation). + +.. warning:: + Agent Spec serialized specifications are not supposed to contain executable code. + Users should adopt security measures in their serialization and deserialization code when using formats + that support such a functionality (e.g., using safe loading and dumping with YAML). + + +Base ``Component`` +------------------ + +The base of Agent Spec is a ``Component``, which by itself can be used to +describe any instance of any type, guaranteeing flexibility to adapt to future changes. + +Note that Agent Spec does not need to encapsulate the implementation for code it describes; +it simply needs to be able to express enough information to instantiate a uniquely-identifiable +object of a specific type with a specific set of property values. + +.. code-block:: python + + class Component: + # All components have a set of fixed common properties + id: str # Unique identifier for the component. Typically generated from component type plus a unique token. + type: str # Concrete type specifier for this component. Can be (but is not required to be) a class name. + name: str # Name of the component, if applicable + description: Optional[str] # Description of the component, if applicable + metadata: Optional[Dictionary[str, Any]] # Additional metadata that could be used for extra information + + +The ``metadata`` field contains all the additional information that +could be useful in different usages of the component. For example, GUIs will +require to include some information about the components (e.g., position +coordinates, size, color, notes, ...), so that they will be able to +visualise them properly even after an export-import process. Metadata is +optional, and empty by default. + +Not all the building blocks of Agent Spec are necessarily ``Components``. +Some other convenient classes can be defined in order to more easily +describe the configurations of the agents (e.g., ``JSONSchemaValue``, ``LlmGenerationConfig``). +Those classes do not have to align to the interface of a Component. + +.. _symbolic_reference_nightly: + +Symbolic references (configuration components) +---------------------------------------------- + +When a component wishes to reference another component entity (e.g., as a property value), +there is a simple symbolic syntax to accomplish this: an object with a single property +"$component_ref" whose value is the id of the referenced component. +This type of relationship is applicable to any component. + +.. code-block:: JSON + + {"$component_ref": "{COMPONENT_ID}"} + +Note that a subcomponent reused in multiple parts of a complex nested component +(for example, a ClientTool used in both an AgentNode and a ToolNode as part of +a flow) must be defined using a component reference. If two components in an +Agent Spec configuration use the same `id`, the configuration will be considered +invalid. + + +Input/output schemas +-------------------- + +Components might need some input data in order to perform their task, +and expose some output as a result of their work. + +These inputs and outputs must be declared and described in the exported configuration, so +that users, developers, and other components of the Agent as well, are +aware of what is exposed by component that require inputs, or provide +outputs. We call these input and output descriptions as input/output +schemas, and we add a subclass of Component called ComponentWithIO that +adds them to the base Component class. + +.. code-block:: python + + class ComponentWithIO(Component): + inputs: List[JSONSchemaValue] + outputs: List[JSONSchemaValue] + +Note that we do not add input/output schemas directly to the base +Component class, as there are a few cases where they do not really apply +(e.g., LlmConfig, edges, see their definition in the next sections). + +Input and output properties +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Input and output schemas are used to define which values the different +components accept as input, or provide as output. We call these values +"properties". + +The term "properties" comes from `JSON +schema `__: indeed, in order to specify all the +inputs and outputs, we rely on this widely adopted and consolidated +standard. + +In particular, we ask to specify a list of JSON schemas definitions, one +for each input and output property. For more information about JSON +schema definition, please check the official website at +https://json-schema.org/understanding-json-schema. + +In order for the schema to be valid, we require to specify some attributes: + +- title: the name of the property +- type: the type of the property, following the type system described + below + +Additionally, users can specify: + +- default: the default value of this property +- description: a textual description for the property + +These are the minimal attributes (or *annotations*, in JSON Schema terminology) +that must be supported to ensure compatibility with Agent Spec. + +Type system +~~~~~~~~~~~ + +We rely on the typing system defined by the JSON schema standard +(see https://json-schema.org/understanding-json-schema/reference/type). + +At its core, JSON Schema defines the following basic types: + +- `string `__ +- `number `__ +- `integer `__ +- `object `__ +- `array `__ +- `boolean `__ +- `null `__ + +These types have analogs in most programming languages, though they may +go by different names. + +Note that some types (array, object) require additional annotations with +respect to the ones listed in the previous section. Please refer to +their JSON schema definition for more information. + +Type compatibility rules +~~~~~~~~~~~~~~~~~~~~~~~~ + +To connect a component's output to another component's input, the output type must be compatible with the input type. +Specifically, the output type should be a subtype of the input type. +For instance, an output of type ``number`` can connect to an input of type ``[number, null]``, +but an output of type ``string`` cannot connect to an input of type ``number``. + +In order to further simplify input-output connections, we define some simple type compatibility rules that are accepted by Agent Spec. + +- Every type can be converted to string. +- Numeric types (i.e., ``integer`` and ``number``) can be converted to each other. + Note that this conversion could cause the loss of decimals. +- The Boolean type can be converted to numeric ones and vice-versa. + The convention for the conversion is the one adopted by most programming languages: + 0 refers to ``false``, while any other number refers to ``true``. + +These rules apply recursively in complex types: + +- To the types of the elements of an ``array``; +- To the types of the ``properties`` of an ``object``. + + +Inputs and outputs of nested components +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In case of nested components (e.g., using an agent inside a flow, or a +flow inside another flow, or even just a step in a flow), the wrapping +component is supposed to expose a (sub)set of the inputs/outputs +provided by the inner components, united to, potentially, additional +inputs/outputs it generates itself. + +We require to replicate the JSON schema of each value in the +inputs/outputs lists of every component that exposes it, i.e., if a +wrapper component exposes some inputs and outputs of some of its +internal components, it will have to include them in its inputs/outputs +lists as well. + +This makes the representation of the components a bit more verbose, but +more clear and readable. Parsers of the representation are required to +ensure the consistency of the input/output schemas defined in it. + +|image6| + +In the example above we have that the wrapper Flow Component (see +sections below for more details about flows) requires two inputs that +coincide with two of the inputs exposed by the Node components in it (see +sections below for more details about nodes), and exposes one output among +those exposed by the inner nodes, plus an additional one it computes +itself. This corresponds to the following code definition. + +**Input output schema example** + +.. code-block:: python + + # String with "input_1_default" as default value + input_1 = StringType(title="Input_1", default="input_1_default") + + # Dictionary with string keys and integers as values, default set to empty dictionary + input_2 = ObjectType(title="Input_2", properties={}, additional_properties=NumberType(), default={}) + + # Nullable string, default value set to null + input_3 = StringType(title="Input_3", nullable=True, default=None) + + output_1 = StringType(title="Output_1") + output_2 = BooleanType(title="Output_2", default=True) + output_3 = ArrayType(title="Output_3", items=ArrayType(items=NumberType()), default=[[1], [2], [3]]) + + # Note that we envision a fluent-classes way of defining inputs and outputs + # These classes get serialized to their respective JSON schema equivalent + + # VLlmConfig is a subclass of Component, we will define it more in detail later + # This Component does not have input/output schemas, the default should be an empty list + vllm = VllmConfig(name="LLama 3.1 8b", model_id="llama3.1-8b-instruct", url="url.to.my.llm.com/hostedllm:12345") + + # Node is a subclass of Component, we will define it more in detail later + node_1 = StartNode(name="Node 1", input_values=[input_1, input_2, input_3]) + node_2 = LLMNode(name="Node 2", llm=vllm, ...) + node_3 = EndNode(name="Node 3", output_values=[output_1, output_2, output_3]) + + # Flow is a subclass of Component, we will define it more in detail later + flow = Flow(nodes=[node_1, node_2, node_3], ..., inputs=[input_1, input_2], outputs=[output_2, output_3]) + +The presence of the inputs and outputs in the representation of every +component does not imply that they always have to be explicitly defined +by users. + +Indeed, they could be automatically and dynamically generated by the +components (e.g., inputs and outputs inferred from a prompt of an LLMNode). + +Validation of specified inputs/outputs +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Some inputs and outputs schema are automatically generated by components +based on their configuration. However, to improve the clarity and readability +of Agent Spec, descriptions of all inputs and outputs should always be explicitly +included in the representation. + +As a consequence, when a representation is read and imported by a runtime, or an +SDK, the inputs and outputs configuration of each component must be +validated against the one generated by its configuration (if any). + +Note that the input/output schema could be a specialization of the one +generated automatically by the component (e.g., an additional +description is added, a more precise type is defined, ...). + +For this reason, we do not enforce the generated I/O schemas to be +perfectly equal to the one reported in the configuration, but we require that: + +- The set of input and output property names are equivalent +- The type of each property in the generated I/O schemas can be casted + to the type of the corresponding property reported in the configuration. + +If these two requirements are not met, the Agent Spec configuration should be considered invalid. + +Specifying inputs through placeholders +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Some Components might infer inputs from special parts of their +attributes. For example, some nodes (e.g., the LLMNode) extract inputs +from a property called ``prompt_template``, so that its value can be +adapted based on previous computations. + +We let users define placeholders in some attributes, by simply wrapping +the name of the property among a double set of curly brackets. The node +will automatically infer the inputs by interpreting those +configurations, and the actual value of the input/output will be +replaced at execution time. Whether an attribute accepts placeholders +depends on the definition of the component itself. + +For example, setting the prompt_template configuration of an LLMNode to +"You are a great assistant. Please help the user with this request: +{{request}}" will generate an input called "request" of type string. + +We do not require the support for complex types and collections like +lists (array) and dictionaries (object) in placeholders: placeholders +are typically transformed in inputs of type string, unless differently +specified by the definition of the Component. In general, dictionaries +and lists should be used directly in inputs and outputs with a matching +type definition. The latter could also be used as input to the MapNode +(see definition below), which applies the same flow to all the elements +in the list and collects the output. + +In the future, we will consider adding support for complex types (e.g., +for loops over lists, dictionary access) through templating, for example +by adopting a subset of the functionalities available in a common standard like +`jinja `__. + +Component families +------------------ + +The base ``Component`` type is extended by several component families +which represent groups of elements that are commonly used in agentic +systems, such as (but not limited to) LLMs and Tools. + +Describing these component families with specific types achieves the +following goals : + +- Type safety + Usage hints for GUIs + + - *Example: if a component's property is expecting an LLM, only allow + LLM's to be connected to it.* + +- Static analysis + validation + + - *Examples: if a component is a ``Flow``, it must have a start node. + If a component is an Agent, it must have an LLM.* + +- Ease of programmatic use + + - Component families each have corresponding class definitions in the + Agent Spec SDK. This allows consumers (agent execution environments, + editing GUIs) to utilise concrete classes, rather than having to + implicitly understand which type has which properties. + + +.. list-table:: Component families + :header-rows: 1 + + * - Component Family + - Notes + - Example + * - Agentic Component + - A top-level, interactive entity that can **receive messages** and **produce messages**. + ``Flow``, ``Agent``, ``RemoteAgent``, ``Swarm``, ``ManagerWorkers`` and ``SpecializedAgent`` are + all specialisations of this family. + - * A multi-step approval flow + * A ReACT Agent + * A remotely-hosted OCI Generative AI Agent + * - Agent + - An agent is an interactive entity that can converse and use tools. + Generic Agents can be specialized for specific tasks and use-cases. + - * A ReACT agent with tools + * - Remote Agent + - An ``AgenticComponent`` whose implementation is **defined and executed + outside** the current runtime (e.g. via REST/RPC). Provides an easy way + to orchestrate SaaS or micro-service based agents. + - * A Slackbot backed by an external service + * - Flow + - An execution graph with a fixed layout, potentially containing branches and loops. + - A workflow with multiple, well known and well defined approval actions to be pursued based on some condition + * - Node (flow) + - A Flow consists of a series of Node instances. There is a "standard library" of nodes + for things such as executing a prompt with a LLM, branching, etc. + - * A step in the execution of a flow, it corresponds to a specific action + * - Relations / edges (flow) + - Flow of control and I/O (data) are defined by explicit relationships in Agent Spec. + - * Define which is the sequence of nodes that should be executed + * Define which outputs should be used to fill which inputs + * - LLM (config) + - The configuration needed to reach out to an LLM + - * URL and id of a model deployed remotely through vLLM + * - Tool + - A procedural function that can be made available to an Agent + - * A tool to calculate the Levenshtein distance between two strings + * A tool to execute a remote API on OCI + * - ToolBox + - A component that exposes a set of Tools to Agentic Components. ToolBoxes are + discovery/aggregation constructs, not executable tools. MCPToolBox exposes tools + discovered from an MCP server. + - * A ToolBox connecting to an MCP server and exposing its tools to an Agent + + * - Datastore + - A component that provides storage capabilities for agentic systems, enabling them to store and access data. + - * An in-memory datastore for testing + * An Oracle Database datastore and a Postgres Database datastore for production use + + + +Agentic Components +~~~~~~~~~~~~~~~~~~ + +An **Agentic Component** represents anything you can "talk" to – i.e. an entity +that consumes a conversation (or other structured context), performs some work, +and returns a result that can be rendered as messages or structured data. It also represents +the entry point for interactions with the agentic system. It extends from ``ComponentWithIO`` and can have inputs +and outputs. + +Three concrete kinds are currently defined: + +1. ``Flow`` – a self-contained execution graph (described later + in the document). +2. ``Agent`` – an in-process, conversational entity that can reason, call + tools, and maintain state. +3. ``RemoteAgent`` – a conversational entity that is defined **remotely** and + invoked through an RPC or REST call. +4. ``Swarm`` – a multi-agent conversational component in which each agent can call to other agents based on a list of pre-defined relationships. +5. ``ManagerWorkers`` – a multi-agent conversational component in which a manager agent can assign tasks to worker agents. + +Agent +~~~~~ + +The ``Agent`` is the top-level construct that holds shared resources such +as conversation memory and tools. It also represents the entry point +for interactions with the agentic system. + +.. code-block:: python + + class Agent(ComponentWithIO): + system_prompt: str + llm_configuration: LlmConfig + tools: List[Tool] + toolboxes: List[ToolBox] + human_in_the_loop: bool + transforms: List[MessageTransform] + +Its main goal is to accomplish any task assigned in the ``system_prompt`` and terminate, +with or without any interaction with the user. +If outputs are defined, structured generation is enabled, and the Agent should also fill the values for +all the properties defined in the outputs attribute (or at least those that do not have a default value +defined) before terminating. + +It's important to have a separate definition for Agents as components, +so that the same Agent can be reused several times, e.g., in different flows, +without replicating its definition multiple times. + +Note that we do not require to define any output parser for an Agent. +Outputs (including their types, descriptions, ...) are defined at representation +level, and the Agent will fill their values either directly, or through tools. +If a special output format is required, users can specify a tool to fit the requirement. + +It is also not required to specify the presence of tools in the ``system_prompt``. +Agent Spec assumes that the list of available tools, including their description, parameters, etc. +is handled by the runtime implementation, that should inform the agent's LLM of their existence. + +Toolboxes, when present, augment the set of tools available to the Agent at runtime through +discovery/aggregation. + +When an Agent consumes multiple ToolBoxes and tools, runtimes MAY merge them into a single tool +registry for the Agent. In case of tool name collisions across different sources: + +- The specification recommends optional namespacing (e.g., toolbox_name.tool_name) and leaves + the exact behavior to the runtime. Runtimes MAY error on collisions. +- Implementations SHOULD provide clear diagnostics when collisions occur. + +Transforms, when present, apply transformations to the messages before they are passed to the agent's LLM, +allowing for message summarization, conversation summarization, or other modifications to optimize the agent's context. + +This Component will be expanded as more functionalities will be added to +Agent Spec (e.g., memory, planners, ...). + +Human-in-the-Loop in Agents +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The concept of human-in-the-loop in agents refers to integrating user +oversight or intervention points into automated processes. +When an agent is designed with a human in the loop, it pauses at defined +moments, such as (but not limited to) before taking key actions or making +critical decisions, to allow a human user to review, approve, or modify the outcome. +There are many possible ways to implement this concept, +such as (but not limited to) introducing special +tools, intercepting large language model (LLM) outputs, or pausing +execution before certain actions. The specific implementation details +are left to the runtime; the essential requirement is +that user control is provided at the appropriate time when requested by +the agent. In contrast, agents without human-in-the-loop capability +operate fully autonomously, without waiting for user input. + +Agent Specialization for Reusability and Flexibility +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In complex automation and orchestration tasks, agent reusability is +vital for maintainability and scalability. The Open Agent Specification +introduces an extension mechanism where a base agent can be tailored for +specialized tasks, enhancing its core capabilities with refined +instructions and additional toolsets. This approach promotes building +simple, generic agents that can be flexibly adapted to diverse use cases +within a flow or as standalone agents. + +A ``SpecializedAgent`` is created using the ``AgentSpecializationParameters``. +The base ``agent``'s instructions are refined by merging them with the +``additional_instructions`` in the specialization parameters, and its tools +can be extended with the ``additional_tools``. Specialization can optionally +override the behaviour of the agent from human-in-the-loop to fully autonomous, +or vice versa (``human_in_the_loop=None`` in the specialization parameters +preserves the base agent's behavior). + +.. code-block:: python + + class AgentSpecializationParameters(ComponentWithIO): + additional_instructions: Optional[str] = None + additional_tools: Optional[List[Tool]] = None + human_in_the_loop: Optional[bool] = None + + class SpecializedAgent(AgenticComponent): + agent: Agent + agent_specialization_parameters: AgentSpecializationParameters + + +``AgentSpecializationParameters`` can use placeholders (as introduced in the previous sections) +in the ``additional_instructions``, which will define the inputs to the +``AgentSpecializationParameters`` component. +The inputs of the ``SpecializiedAgent`` will be the union of the specialization parameters' inputs +and the ones of the base agent. +If the same input or output property is defined in both the agent and the +specialization parameters, the respective instances must have the same type. +``AgentSpecializationParameters`` do not define any outputs, therefore the outptus of the +``SpecializiedAgent`` will be the same as the ones of the underlying base ``Agent``. + + +LLM +~~~ + +LLMs are used in agents, as well as flow nodes that need one. + +In order to make them usable, we need to let users specify all the +details needed to configure the LLM, like the connection details, the +generation parameters, etc. + +We define a Component called LlmConfig that contains all the details: + +.. code-block:: python + + class LlmConfig(Component): + model_id: str + provider: Optional[str] + api_provider: Optional[str] + api_type: Optional[str] + url: Optional[str] + api_key: SensitiveField[Optional[str]] + default_generation_parameters: Optional[Dict[str, Any]] + retry_policy: Optional[RetryPolicy] + +The ``model_id`` field is required and identifies the model to use, as expected by the selected API provider. +The ``provider`` field is optional and identifies the model provider (e.g. ``"openai"``, ``"meta"``, ``"anthropic"``, ``"cohere"``). +The ``api_provider`` field is optional and identifies the API provider serving the model (e.g. ``"openai"``, ``"oci"``, ``"vllm"``, ``"ollama"``, ``"aws_bedrock"``, ``"vertex_ai"``). +The ``api_type`` field is optional and identifies the API format to use (e.g. ``"chat_completions"``, ``"responses"``). +The ``url`` field is optional and specifies the URL of the API endpoint (e.g. ``"https://api.openai.com/v1"``). If not specified, the default API URL of the API provider (if any) is used. +The ``api_key`` field is optional and specifies an API key for the remote LLM. When the configuration is exported, the value is replaced by a reference. +The ``default_generation_parameters`` field specifies the default generation parameters that should be used when prompting the LLM. +These parameters are specified as a dictionary of parameter names and respective values. +The names are strings, while values can be of any type compatible with the JSON schema standard. +Additionally, an optional Retry policy can be specified to configure the how remote LLM calls are retried +in case of network failures. + +Retry Policy +^^^^^^^^^^^^ + +Users can provide an optional retry policy configuration for remote LLM calls. +This policy is provider-agnostic and can be interpreted by runtimes to implement +consistent retry behavior across different LLM providers. + +.. code-block:: python + + class RetryPolicy: + max_attempts: int + request_timeout: Optional[float] + initial_retry_delay: float + max_retry_delay: float + backoff_factor: float + jitter: Optional[Literal["equal", "full", "full_and_equal_for_throttle", "decorrelated"]] + service_error_retry_on_any_5xx: bool + recoverable_statuses: Dict[str, List[str]] + + +* ``max_attempts``: maximum number of retries for a request that fails with a recoverable status. +* ``request_timeout``: timeout parameter to pass to underlying HTTP clients for slow endpoints such + as transcription or inference services, in seconds. +* ``initial_retry_delay``: minimum amount of time to wait between retries in seconds. +* ``max_retry_delay``: maximum amount of time to wait between retries in seconds. +* ``backoff_factor``: for exponential backoff with jitter, the exponent which we will raise to the power + of the number of attempts. +* ``jitter``: type of backoff we want to do. Defaults to ``"full_and_equal_for_throttle"``. + + * ``None``: No jitter. ``t = min(min_wait * (backoff_factor ** attempts), max_wait)`` + * ``"full"``: ``t = min(random(0, min_wait * (backoff_factor ** attempts)), max_wait)`` + * ``"equal"``: ``t = min(min_wait * (backoff_factor ** attempts), max_wait) * (1 + random(0, 1)) / 2`` + * ``"full_and_equal_for_throttle"``: full for 5xx errors and equal for 4xx errors + * ``"decorrelated"``: ``t = min(min_wait * (backoff_factor ** attempts) + random(0, 1), max_wait)`` + +* ``service_error_retry_on_any_5xx``: whether to retry on all 5xx errors, except 501 (network errors). +* ``recoverable_statuses``: configure what HTTP statuses (e.g. 429) to retry on and, optionally, whether + the textual code (e.g. TooManyRequests) matches a given value. The expected format is a dictionary where + the key is a string representing the HTTP status, and the value is a ``List[str]`` where we will test + if the textual code in the service error is a member of the list. If an empty list is provided, then + only the numeric status is checked for retry purposes. + +When all retries fail, an error is raised and the execution is interrupted. + +.. note:: + Runtimes MUST NOT treat TLS/certificate verification errors as retryable. + Runtimes SHOULD NOT retry on authentication/authorization errors (e.g., HTTP 401/403) + or validation errors (e.g., HTTP 400/422). + +.. note:: + + The extra parameters should never include sensitive information. + Make sure the extra parameter values are validated either by the runtime if supported, + or explicitly before importing the configuration into the runtime. + +Null value is equivalent to an empty dictionary, i.e., no default generation parameter is specified. + +``LlmConfig`` can be used directly for any LLM provider by setting the appropriate ``provider``, ``api_provider``, +and ``api_type`` values. Specific extensions of ``LlmConfig`` for the most common providers are also provided +for convenience, offering additional provider-specific configuration options. + +DBMS Vector Chain +^^^^^^^^^^^^^^^^^ + +``DbmsVectorChainLlmConfig`` configures an LLM request executed by Oracle +Database through ``DBMS_VECTOR_CHAIN``. + +.. code-block:: python + + class DbmsVectorChainLlmConfig(LlmConfig): + model_id: str + provider: str + url: str + credential_name: Optional[str] + host: Optional[Literal["local"]] + transfer_timeout: Optional[int] + connection_config: Optional[OracleDatabaseConnectionConfig] + +The ``connection_config`` field is an optional Oracle Database connection +configuration. If ``connection_config`` is not specified, the runtime must use +an existing Oracle Database connection from its execution context. If no +connection is available, the runtime must raise an error. + +The ``host`` field can only be set to ``"local"`` and is supported only when +``provider`` is ``"openai"`` or ``"ollama"``. It indicates that the provider +is running locally and disables database credential authentication. When +``host`` is specified, ``credential_name`` must be omitted. + +The ``transfer_timeout`` field specifies the maximum number of seconds to wait +for the provider request to complete. If it is not specified, +``DBMS_VECTOR_CHAIN`` uses its default timeout of 60 seconds. + +The runtime maps these fields to the corresponding ``DBMS_VECTOR_CHAIN`` +parameters: ``model_id`` to ``model``, and ``provider``, ``url``, +``credential_name``, ``host``, and ``transfer_timeout`` to parameters with the +same names. ``connection_config`` configures the runtime's database connection +and is not a ``DBMS_VECTOR_CHAIN`` parameter. + +For a remote provider, ``credential_name`` identifies a credential created in +Oracle Database with ``DBMS_VECTOR_CHAIN.CREATE_CREDENTIAL``. For a local +OpenAI or Ollama provider, set ``host`` to ``"local"`` and omit +``credential_name``. Refer to the `DBMS_VECTOR_CHAIN documentation `_ +for supported providers and provider-specific parameters. + +Structured Generation +^^^^^^^^^^^^^^^^^^^^^ + +Structured generation in LLMs refers to the process of producing outputs that adhere to specific, +predefined formats or structures. This approach is valuable for use cases requiring machine-readable responses, +data extraction, program synthesis, or integrating LLM outputs with downstream systems. + +Structured generation in LLMs typically works by guiding the model through tailored prompts or instructions +that specify the required format. +In Agent Spec we allow users to define the expected output format by defining output properties in Components. +In other words, users must define the JSON schema of the different properties that the LLM is supposed to generate. +Each component defines if structured generation is supported, and how it is enabled. + +In the current version of Agent Spec, the components that support structured generation are: + +- LlmNode +- Agent + +More information is provided in their respective definitions. + +.. note:: + Note that values, despite well-structured, will be LLM-generated. + Therefore users should consider them untrusted, and they should perform proper validation before usage. + +Agent Spec Runtimes can implement structured generation by generating all the properties in the same request, +but they should expose them as separate outputs. +For example, assuming to have the following component that supports structured generation: + +.. code-block:: json5 + + { + "component_type": "ComponentWithStructuredGeneration", + // ... + "llm_config": { + // ... + }, + "prompt": "What is the fastest italian car?", + "outputs": [ + {"title": "brand", "type": "string", "description": "The brand of the car"}, + {"title": "model", "type": "string", "description": "The name of the car's model"}, + {"title": "hp", "type": "integer", "description": "The horsepower amount, which expresses the car's power"} + ] + } + +Can generate an object as follows: + +.. code-block:: json + + {"brand": "Pininfarina", "model": "Battista", "hp": 1400} + +But it should then expose each property separately. Note that the descriptions of properties could be forwarded +to LLMs to improve the quality of the generation. +Therefore, providing a proper description might improve the quality of the final outcome. + +.. note:: + The current version Agent Spec does not define how structured generation is enforced on the LLM. + In case structured generation is requested to an LLM that does not natively support it, it's up to + the Agent Spec Runtime implementation to raise an exception, or to implement it in a different form. + +.. _openaicompatiblellms: + +OpenAI Compatible LLMs +^^^^^^^^^^^^^^^^^^^^^^ + +This class of LLMs groups all the LLMs that are compatible with either the +`OpenAI chat completions APIs `_ or the `OpenAI Responses APIs `_. +The API type can be configured by using the ``api_type`` parameter, which takes one of 2 string values, namely +``chat_completions`` or ``responses``. By default, the API type is set to chat completions. Additionally, an optional +``api_key`` can be set for the remote LLM model. OpenAI-compatible LLMs also support optional certificate +configuration for HTTPS and mTLS connections to private endpoints. + +.. code-block:: python + + class OpenAiCompatibleConfig(LlmConfig): + model_id: str + url: str + api_type: Literal["chat_completions", "responses"] = "chat_completions" + api_key: SensitiveField[Optional[str]] = None + key_file: SensitiveField[Optional[str]] = None + cert_file: SensitiveField[Optional[str]] = None + ca_file: SensitiveField[Optional[str]] = None + +* ``key_file`` is the path to an optional client private key file (PEM format). +* ``cert_file`` is the path to an optional client certificate chain file (PEM format). +* ``ca_file`` is the path to an optional trusted CA certificate file (PEM format) used to verify the server. + +Based on this class of LLMs, we provide two main implementations. + +vLLM +'''' + +Used to indicate all the LLMs deployed through `vLLM `_. + +.. code-block:: python + + class VllmConfig(OpenAiCompatibleConfig): + pass + +Ollama +'''''' + +Used to indicate all the LLMs deployed through `Ollama `_. +Note that as of November 2025, Ollama does not support the `OpenAI Responses APIs `_, +so using this API type may lead to unexpected behavior. + +.. code-block:: python + + class OllamaConfig(OpenAiCompatibleConfig): + pass + + +OpenAI +^^^^^^ + +This class of LLMs refers to the models offered by `OpenAI `_. +Similar to :ref:`OpenAI Compatible LLMs `, you can also configure the ``api_type`` parameter, +which takes one of 2 string values, namely ``chat_completions`` or ``responses``. +By default, the API type is set to chat completions. Additionally, an optional +``api_key`` can be set for the remote LLM model. + +.. code-block:: python + + class OpenAiConfig(LlmConfig): + model_id: str + api_type: Literal["chat_completions", "responses"] = "chat_completions" + api_key: SensitiveField[Optional[str]] = None + +OCI GenAI +^^^^^^^^^ + +This class of LLMs refers to the models offered by +`Oracle GenAI `_. + +OCI models require to specify the authentication method to be used when accessing the models. +Therefore, besides the attributes required to select the model to use, the ``OciGenAiConfig`` contains +the authentication information as part of the ``LlmConfig``. + +The ``serving_mode`` parameter determines whether the model is served on-demand or in a dedicated capacity. +On-demand serving is suitable for variable workloads, while dedicated serving provides consistent performance +for high-throughput applications. + +The ``provider`` parameter specifies the underlying model provider, META for Llama models or COHERE for cohere +models. When ``None``, it is automatically detected based on the ``model_id``. When the ``serving_mode`` is ``DEDICATED`` +the ``model_id`` does not indicate what ``provider`` should be used so the user has to specify it manually, +knowing what model it is. + +The API type can be configured by using the ``api_type`` parameter, which takes one of 3 string values, namely ``oci``, +``openai_chat_completions`` and ``openai_responses``. By default, the API type is set to ``oci``. +The ``conversation_store_id`` parameter allows to specify the conversation store OCI resource to use top persist +conversations when using the openai responses API. + +.. code-block:: python + + class OciGenAiConfig(LlmConfig): + model_id: str + compartment_id: str + serving_mode: Literal["ON_DEMAND", "DEDICATED"] = "ON_DEMAND" + provider: Optional[Literal["META", "XAI", "GROK", "COHERE", "OTHER"]] = None + client_config: OciClientConfig + api_type: Literal["chat_completions", "responses"] = "chat_completions" + conversation_store_id: Optional[str] = None + +.. note:: + While both ``GROK`` and ``XAI`` values for ``provider`` are supported and refer to xAI models, we recommend + to use the value ``XAI``. + +.. note:: + The authentication components must not contain any sensitive information about the authentication, + like secrets, API keys, passwords, etc. Users must not include this information anywhere, as exported + Agent Spec configurations should never include sensitive data. + +OCI Client Configuration +'''''''''''''''''''''''' + +The ``OciClientConfig`` contains all the settings needed to perform the authentication to use OCI services. +This object is used by the ``OciGenAiConfig`` in order to access the Gen AI services of OCI and perform LLM calls. +This client configuration is also used by other components that require access to the OCI services (e.g., the +``OciAgent`` presented later in this document). +More information about how to perform authentication in OCI is available on the +`Oracle website `_. + +.. code-block:: python + + class OciClientConfig(Component): + service_endpoint: str + auth_type: Literal["SECURITY_TOKEN", "INSTANCE_PRINCIPAL", "RESOURCE_PRINCIPAL", "API_KEY"] + +Based on the type of authentication the user wants to adopt, different specifications of the ``OciClientConfig`` +are defined. In the following sections we show what client extensions are available and their specific parameters. + + +OciClientConfigWithSecurityToken +'''''''''''''''''''''''''''''''' + +Client configuration that should be used if users want to use authentication through security token. + +.. code-block:: python + + class OciClientConfigWithSecurityToken(OciClientConfig): + auth_profile: str + auth_file_location: SensitiveField[str] + auth_type: Literal["SECURITY_TOKEN"] = "SECURITY_TOKEN" + +OciClientConfigWithApiKey +''''''''''''''''''''''''' + +Client configuration that should be used if users want to use authentication with API key. + +.. code-block:: python + + class OciClientConfigWithApiKey(OciClientConfig): + auth_profile: str + auth_file_location: SensitiveField[str] + auth_type: Literal["API_KEY"] = "API_KEY" + +OciClientConfigWithInstancePrincipal +'''''''''''''''''''''''''''''''''''' + +Client configuration that should be used if users want to use instance principal authentication. + +.. code-block:: python + + class OciClientConfigWithInstancePrincipal(OciClientConfig): + auth_type: Literal["INSTANCE_PRINCIPAL"] = "INSTANCE_PRINCIPAL" + +OciClientConfigWithResourcePrincipal +'''''''''''''''''''''''''''''''''''' + +Client configuration that should be used if users want to use resource principal authentication. + +.. code-block:: python + + class OciClientConfigWithResourcePrincipal(OciClientConfig): + auth_type: Literal["RESOURCE_PRINCIPAL"] = "RESOURCE_PRINCIPAL" + +Gemini +^^^^^^ + +This class of LLMs refers to the Gemini family of models offered by `Google `_. +Google offers Gemini through two services: `AI Studio `_ and `Vertex AI `_. + +.. code-block:: python + + class GeminiConfig(LlmConfig): + model_id: str + auth: SerializeAsAny[GeminiAuthConfig] + +.. code-block:: python + + class GeminiAuthConfig(Component, abstract=True): + pass + +AI Studio uses API key-based authentication: + +.. code-block:: python + + class GeminiAIStudioAuthConfig(GeminiAuthConfig): + api_key: SensitiveField[Optional[str]] = None + +When ``api_key`` is not specified, runtimes may try to load it from the ``GEMINI_API_KEY`` +environment variable. + +Meanwhile, the Vertex AI service can be authenticated with Google Cloud credentials. These credentials can be provided with a `service account JSON key `_ +either inline or through a local file path. When omitted, runtimes may rely on Google Application Default Credentials (ADC), such as +the ``GOOGLE_APPLICATION_CREDENTIALS`` environment variable, credentials made available through the local Google Cloud environment, +or an attached service account. Even with ADC, the ``project_id`` may still need to be provided explicitly when it cannot be +resolved from the local Google Cloud configuration: + +.. code-block:: python + + class GeminiVertexAIAuthConfig(GeminiAuthConfig): + project_id: Optional[str] = None + location: str = "global" + credentials: SensitiveField[Optional[Union[str, Dict[str, Any]]]] = None + +Here, ``credentials`` accepts either a local file path (``str``) to a Google Cloud JSON +credential file, such as a service-account key file, or an inline ``dict`` containing the +parsed JSON contents of that file. + +See `Using Gemini API keys `_, +`Application Default Credentials `_, +and `Create and delete service account keys `_ +for more details. +``GeminiConfig.auth`` stays inline when serialized. If ``api_key`` or ``credentials`` of the auth object is +specified, only that sensitive field is externalized. Otherwise, if ``api_key`` or ``credentials`` is +unset, that field serializes as ``null``. + +Tools +~~~~~ + +A tool is a procedural function or a flow that can be made available to +an Agent to execute. In a flexible Agent context, the Agent can decide +to call a tool based on its signature and description. In a flow +context, a node would need to be configured to call a specific tool. + +Where the actual tool functionality is actually executed or run depends +on the type of the tool: + +- **ServerTools** are executed in the same runtime environment that the + Agent is being executed in. The definitions of these tools therefore + must be available to the Agent's environment. It is expected that + this type of tool will be very limited in number, and relatively + general in functionality (e.g., human-in-the-loop). +- **BuiltinTools** are defined and implemented by the execution engine. + Which built-in tools are available, their semantics and how to configure + them depends on, and should be verified by, the execution engine. + Built-in tools are expected to be executed in the same runtime + environment that the Agent is being executed in. +- **ClientTools** are not executed by the executor, the client must + execute the tool and provide the results back to the executor (similar + to OpenAI's function calling model) +- **RemoteTools** are run in an external environment, but triggered by + an RPC or REST call from the Agent executor. +- **MCPTools** interact with Model Context Protocol (MCP) servers to + support remote tool execution. + +Agent Spec is not supposed to provide an implementation of how these types of +tools should work, but provide their representation, so that they can be +correctly interpreted and ported across platforms and languages. + +That said, as previously argued, a tool is a function that is called +providing some parameters (i.e., inputs), performs some transformation, +and returns some values (i.e, outputs). Of course the function has a +name, and a description that can be used by an LLM in order to +understand when it's relevant to perform a task. Therefore, we can +define a Tool as a simple extension of ComponentWithIO. + +We let tools specify **multiple outputs**. In this case, the expected return value +of the tool is a dictionary where each entry corresponds to an element +specified in the outputs. The key of the dictionary entry must match the +name of the property specified in the outputs. The runtime will parse the +dictionary in order to extract the different outputs and bind them correctly. + +Tools also include a boolean ``requires_confirmation`` attribute. +When set to true, this signals that execution environments should require +user/operator approval before running the tool, which is especially relevant +for tools performing critical or sensitive actions. + +While ServerTool and ClientTool do not require specific additional +parameters, the RemoteTool, MCPTool and the BuiltinTool require to include also +the details needed to perform the remote call to the tool or to identify +the correct built-in tool. + +.. code-block:: python + + class Tool(ComponentWithIO): + # Flag to make tool require user confirmation before execution. + requires_confirmation: bool + + class ClientTool(Tool): + pass + + class ServerTool(Tool): + pass + + class RemoteTool(Tool): + # Basic parameters needed for a remote API call + url: str + http_method: str + api_spec_uri: Optional[str] + data: Any + query_params: Dict[str, Any] + headers: Dict[str, Any] + sensitive_headers: SensitiveField[Dict[str, Any]] + url_allow_list: Optional[List[str]] + retry_policy: Optional[RetryPolicy] + + class MCPTool(Tool): + client_transport: ClientTransport + retry_policy: Optional[RetryPolicy] + + class BuiltinTool(Tool): + tool_type: str + configuration: Optional[Dict[str, Any]] + executor_name: Optional[Union[str, List[str]]] + tool_version: Optional[str] + +An important security aspect of Agent Spec is the exclusion of arbitrary +code from the agent representation. The representation contains a complete description +of the tool—its attributes, inputs, outputs, and related metadata—but does +not embed executable code. + +.. _remotetools: + +Remote Tools +^^^^^^^^^^^^ + +One common way to make tools available to agents, is to make them +available under the for of REST APIs that the agent can call when the +tool has to be executed. + +Compared to ServerTools and ClientTools, these tools require more +information to be specified, in order to perform the actual request. + +We add the same parameters as the APINode (see the Nodes library in the +Flows - Nodes section below), in order to perform a complete REST call +in the right manner. + +Note that, similarly to the APINode, we allow users to use placeholders +(as described in section 5.3.4) in all the string attributes of the +RemoteTool (i.e., url, http_method, api_spec_uri, and in the dict values +of data, query_params, headers, and sensitive_headers). The ``data`` parameter in +``RemoteTool`` is what ends up in the body of the request. +If ``data`` is specified as a string, the runtime must encode and pass it as the body of the HTTP request, +otherwise the value of ``data`` should be JSON serializable and runtimes are +expected to dump it when adding it in the body of the HTTP request. +The data is passed to the request as Form Data if the header ``{"Content-Type": "application/x-www-form-urlencoded"}`` is specified. + +The ``url_allow_list`` field is an optional list of developer-controlled URLs or URL patterns that runtimes +or adapters can use to validate the rendered request URL. The intended matching behavior is exact matching on +scheme and authority (host and port), together with prefix matching on the path. Query parameters, URL params, +and fragments are not used for matching. + +These patterns are simple URL prefixes rather than wildcards or regexes. For example: + +* ``https://example.com`` allows ``https://example.com/page`` because scheme, host, and port match and the path prefix is ``/``. +* ``https://example.com/orders/`` allows ``https://example.com/orders/123`` and ``https://example.com/orders/123/items``. +* ``https://example.com/orders/`` does not allow ``https://example.com/customers/123`` because the path prefix differs. +* ``http://example.com/orders/`` does not allow ``https://example.com/orders/123`` because the scheme differs. + +For higher security, authors should strongly prefer configuring ``url_allow_list`` whenever templating is used in +``url``, especially if placeholders can affect the destination part of the URL (scheme, host, or port). +However, Agent Spec does not require runtime adapters to enforce this rule: whether to accept +or reject ``url`` containing placeholders without an explicit ``url_allow_list`` is left to runtime implementations. +The recommended pattern is to keep the base URL developer-controlled and template only path, query, or body values. + +MCP Tools +^^^^^^^^^ + +MCP Tools are a specialized type of tool that connects to servers implementing +the Model Context Protocol (MCP). They extend the base ``Tool`` with transport +configurations for secure, remote execution. + +Like other tools, MCP Tools define inputs, outputs, and metadata but include a +``client_transport`` for managing connections (e.g., via SSE or Streamable HTTP +with mTLS) (details about the client transport can be found in +:ref:`the MCP section ` below). + + +.. code-block:: python + + class MCPTool(Tool): + client_transport: ClientTransport + retry_policy: Optional[RetryPolicy] + +MCP Tools follow the same security principles as other tools: no arbitrary code is +embedded in the representation. Execution occurs remotely via the specified transport, with the +runtime handling session management and data relay. + +``retry_policy`` optionally specifies a ``RetryPolicy`` for semantic MCP tool +resolution and execution retries. For this use, runtimes should use only the +attempt and backoff fields: ``max_attempts``, ``initial_retry_delay``, +``max_retry_delay``, ``backoff_factor``, and ``jitter``. + +Resolution means locating and validating the configured MCP tool against the +tools currently exposed by the MCP server before the tool is called. Execution +means invoking that selected MCP tool after resolution has succeeded. + +For direct tool resolution, the policy should apply when the configured tool +name is missing from a successful MCP tool-list or resolution result. For +execution, the policy should apply when the MCP server reports that the selected +tool is temporarily unavailable or missing, for example a tool-not-found error +for a tool that was configured or previously exposed. + +This is distinct from retry policies on remote MCP transports, which apply to +requests sent through the transport layer. The ``request_timeout``, +``service_error_retry_on_any_5xx``, and ``recoverable_statuses`` fields apply to +transport/request retry policies, not to MCP semantic retry. Runtimes should not +apply MCP semantic retry to invalid configuration, authentication or +authorization failures, TLS/certificate verification failures, validation +failures, or signature/schema mismatches. + +For example, an MCP Tool might use an ``SSEmTLSTransport`` to securely call a remote +function for data processing. + + +ToolBoxes +^^^^^^^^^ + +A ToolBox is a component that exposes one or more tools to components. ToolBoxes are not +executable by themselves; they are discovery/aggregation mechanisms. A component can +receive tools from both: + +- tools (concrete Tool instances), and +- toolboxes (ToolBox instances that expose tools discovered elsewhere). + +When the set of tools is dynamic, its content should be established just before use by the +consuming component. ToolBoxes do not embed executable code or the full discovered tool list +at serialization time. Discovery happens at runtime. + +A ToolBox also exposes a flag ``requires_confirmation`` (default to False); it exists to let users enforce +confirmation for the entire toolbox without having to set ``requires_confirmation=True`` on every +tool it provides. If the tool box does not require confirmation, confirmation is still required +for individual tools which explicitly specify it. + +.. code-block:: python + + class ToolBox(Component): + requires_confirmation: bool = False + + +MCPToolBox +'''''''''' + +MCPToolBox connects to an MCP server via a ClientTransport and exposes tools discovered +from that server to components. + + +.. code-block:: python + + class MCPToolBox(ToolBox): + client_transport: ClientTransport + retry_policy: Optional[RetryPolicy] + tool_filter: Optional[List[Union[str, MCPToolSpec]]] + + +The ``client_transport`` specifies the MCP ClientTransport used to discover remote +MCP tools and route calls to them. + +``retry_policy`` optionally specifies a ``RetryPolicy`` for semantic MCP toolbox +discovery/resolution retries and for execution retries of tools generated by the +toolbox. For this use, runtimes should use only the attempt and backoff fields: +``max_attempts``, ``initial_retry_delay``, ``max_retry_delay``, +``backoff_factor``, and ``jitter``. + +Discovery/resolution means listing the tools currently exposed by the MCP server +and matching them against ``tool_filter`` before the toolbox provides tools to a +consuming component. Execution means invoking a selected tool generated by the +toolbox after discovery/resolution has succeeded. + +For toolbox discovery/resolution, the policy should apply only when +``tool_filter`` is configured and one or more expected tools are missing from a +successful MCP ``list_tools`` response. When ``tool_filter`` is null, the +runtime cannot know whether a successful response is incomplete, so this +semantic retry does not apply to discovery. For execution of tools generated by +the toolbox, the policy should apply when the MCP server reports that the +selected tool is temporarily unavailable or missing. + +This is distinct from retry policies on remote MCP transports, which apply to +requests sent through the transport layer. The ``request_timeout``, +``service_error_retry_on_any_5xx``, and ``recoverable_statuses`` fields apply to +transport/request retry policies, not to MCP semantic retry. Runtimes should not +apply MCP semantic retry to invalid configuration, authentication or +authorization failures, TLS/certificate verification failures, validation +failures, or signature/schema mismatches. + +.. _mcp_toolfilter_rules: + +By default the ``tool_filter`` parameter is null and the MCPToolBox exposes all tools +discovered from the MCP server. When the ``tool_filter`` is not null, the toolbox only +exposes the listed tools. For each entry in the list: + +- If an entry is a ``str``, the MCP server MUST expose a tool with the exact name, or the configuration is invalid. +- If an entry is an ``MCPToolSpec``, it is treated as a strict signature to validate against the tool exposed by the MCP server: + + - The name MUST exactly match a name of an exposed tool. + - If description is provided, it overrides the exposed tool description. + - If inputs are provided, they MUST exactly match the exposed tool input schema by name and JSON Schema type. + On mismatch, the configuration is invalid. + - If outputs are provided, they MUST consist of exactly one string-typed property with the expected tool output + name and optional description. + - If the :ref:`tool confirmation flag ` is provided, its value is used; otherwise, it defaults to False. + +The ``tool_filter`` information should be validated against the exposed MCP tools each time the tools +are provided to the consuming component (as the toolbox content may change dynamically). If any of the +constraints described above is not respected, the runtime can raise an error. + +.. note:: + + ToolBoxes are not valid values for ToolNode.tool; + ToolNode requires a concrete Tool (including MCPTool). + + +MCPToolSpec +''''''''''' + +``MCPToolSpec`` is a declarative tool signature used inside ``MCPToolBox.tool_filter`` to +pin and validate specific remote MCP tools. + +.. _mcp_tool_spec_def: +.. code-block:: python + + class MCPToolSpec(ComponentWithIO): + requires_confirmation: bool + +See the :ref:`filter rules for the MCPToolBox ` to see how the ``MCPToolSpec`` +is used. + +Built-in Tools +^^^^^^^^^^^^^^ + +Agent Spec execution engines may offer built-in tools out of the box and ready to use. +For example, + +- An executor could provide a tool to search websites. + Users do not need to provide a tool implementation, but only need to specify which website to search on. +- An executor running in a database might expose a native vector retrieval tool + with the possibility to configure which tables to search on or how many relevant results to return. + +Built-in tools in Agent Spec let users configure these types of tools. + +.. code-block:: python + + class BuiltinTool(Tool): + tool_type: str + configuration: Optional[Dict[str, Any]] + executor_name: Optional[Union[str, List[str]]] + tool_version: Optional[str] + +- ``tool_type`` identifies the runtime-provided built-in tool. +- ``configuration`` allows to configure the tool. + Legal configurations should be documented by the runtime providing the tool. + Not all built-in tools require configuration. +- ``executor_name`` optionally allows to specify the runtime name providing the tool for validation purposes + (i.e., for the runtime to check that the tool config is intended for it). + The correct value to put should be documented by the runtime providing the tool. +- ``tool_version``: if the runtime provides multiple versions of the tool, the built-in tool version can be specified here. + +The advantage of built-in tools is that no tool implementation needs to be provided by the users. +The disadvantage is that built-in tools are specific to the Agent Spec execution runtime which provides them. +In general, which built-in tools are available differs for each execution runtime, and using them might limit cross-framework portability. + + +Implementing Built-in Tools as Execution Engine Developer +''''''''''''''''''''''''''''''''''''''''''''''''''''''''' + +For Agent Spec execution engine developers implementing built-in tools, +the following documentation should be provided to users. + +- The semantics of tool +- The name of the tool, passed to the ``tool_type`` field +- The expected input and output properties (title and type), if any +- The expected static configuration parameters, if any +- The executor name. Users can optionally specify this field s.t. + the execution engine can validate the tool is intended for it. +- The tool version. If there are multiple versions of the tool, + this field can be used to select which tool to run. Furthermore, it should + be documented what the default behavior is if multiple tool versions are + available but no ``tool_version`` is specified. + +Execution engine developers should validate that inputs, outputs and configuration +parameters of the built-in tool component provided in the configuration are as expected. + +Furthermore, to prevent unintended behavior, execution engine developers should +validate the ``executor_name`` and ``tool_version`` fields, if they are provided, +and raise an error if the values are not expected. + +.. collapse:: Example documentation for built-in tool... + + **Vector Search Built-in Tool** + + Retrieves the most relevant documents from a table based on cosine similarity + between a query text and stored embeddings. + + **Tool Name:** "vector_retrieval_tool" + + **Inputs:** + + - ``StringProperty(title="query")``: user query text. + + **Outputs:** + + - ``ListProperty(title="results", item_type=StringProperty())``: + list of retrieved document contents. + + **Configuration Parameters:** + + - ``schema`` (str): schema containing the table. + - ``target_table`` (str): name of the table storing embeddings. + - ``target_column`` (str): column containing embedding vectors. + - ``top_k`` (int): number of nearest results to return. + + **Executor Name:** "my_execution_engine>=25.4.1". + + **Tool Version:** Our vector_retrieval_tool is not versioned. + + +Execution flows +~~~~~~~~~~~~~~~ + +Execution flows (or graphs) are directed, potentially-cyclic workflows. +They can be thought of as "subroutines" that encapsulate consistently-repeatable processes. + +Each execution flow requires 0 or more inputs (e.g., any input edges expressed on the +starting node of the graph) and may produce 0 or more outputs (e.g., any +output edges expressed by the terminal nodes of the graph - note that a +graph can have more than one terminal node, in the event of branching logic). + +Flow +^^^^ + +``Flow`` objects specify the entry point to the graph, the nodes, and edges for the graph. +Inner Components of the flow are described in subsequent sections. + +.. code-block:: python + + class Flow(ComponentWithIO): + start_node: Node + nodes: List[Node] + control_flow_connections: List[ControlFlowEdge] + data_flow_connections: Optional[List[DataFlowEdge]] + +A flow exposes a (sub)set of the union of all the outputs exposed by all the EndNodes. +However, if an output defined in the flow does not have a corresponding output with the same name in every EndNode, +then a default value for that output in the flow definition must be specified. +The default value is used in case the EndNode executed at runtime does not expose an output with that name. +It is not possible to define two outputs with the same name and different types in two distinct EndNodes of the same flow. +It is instead possible to define different default values for outputs with the same name in distinct EndNodes. + +For example, let's assume to have a flow with two EndNodes, called respectively ``end_node_a`` and ``end_node_b``. +``end_node_a`` exposes one output called ``output_a``, while ``end_node_b`` exposes another output called ``output_b``. +We want to expose both the outputs from the flow, so we will include them in the ``outputs`` definition of the flow +and, since they are not common to all the EndNodes in the flow, we must define a default value for them. +We assign ``default_value_a`` as default for ``output_a`` and ``default_value_b`` as default for ``output_b``. +Note that these default values are required to be included in the flow specification, not in the EndNodes. +Let's now assume that at runtime we execute the flow, and we complete it through the ``end_node_a`` with a value +for ``output_a`` set to ``value_a``. This means that as output values of the flow we will get ``value_a`` for +``output_a``, as it was generated by the EndNode that was executed, while we will get ``default_value_b`` for ``output_b``, +as no value was generated for ``output_b`` by the EndNode being executed. + +As inputs, a flow must expose all the inputs that are defined in its StartNode. +Note that flows must have a unique StartNode, which is defined by the ``start_node`` parameter, +and it should appear in the list of ``nodes``. + +Conversation +^^^^^^^^^^^^ + +At the core of the execution of a conversational agent there's the +conversation itself. The conversation is implicitly passed to all the +components throughout the flow. It contains the messages being produced +by the different nodes: each node in a flow and each agent can append +messages to the conversation if needed. Messages should have, at least, +a type (e.g., system, agent, user), content, the sender, and the recipient. + +Sub-flows and sub-agents contained in a Flow (i.e., as part of AgentNode and FlowNode, +see the Node section for more information) share the same conversation of the Flow they belong to. +Sub-agents and sub-flows should have access to all the messages in the conversation, +and they should append in the same conversation all the messages that are generated during +their execution. At the end of the execution of a sub-flow/sub-agent, the Flow that +contains them should have access to all the messages generated before and during the execution +of the sub-flow/sub-agent. + +.. warning:: + The conversation of the parent Flow is shared with sub-flows and sub-agents and vice-versa. + Do not use sub-flows or sub-agents for information isolation purposes. + For example, if a sub-agent calls a remote model, it may forward the entire conversation to that model. + +I/O Data Space +^^^^^^^^^^^^^^ + +Alongside the conversation, flows have another data space composed of inputs and outputs generated by nodes. + +Depending to their definition, nodes can require inputs and generate outputs. +When a node generates an output, this is exposed by the node itself, so that other nodes can consume it if needed. +In order to use an output's value as a component's input, there are two options: + +- A data flow edge between the output and the input is created (see :ref:`Data relationships `); +- In case the data flow is not defined (see :ref:`Optionality of data relationships `), + the input and output property names match. + +An output must be generated before it can be used to fulfil an input. +In other words, the node generating an output must be executed before the node consuming it as input. +An exception is made if the input property has a default value defined. +In that case, if the respective output was not generated, the default value is used instead. + +.. note:: + The Conversation and the I/O Data Space concepts are disjoint and independent from each other. + Agent Spec Runtimes are free to implement them separately or not (e.g., with the conversation + as a special entry in the I/O space), as long as the Agent Spec language semantic is respected. + +Node +^^^^ + +A ``Node`` is a vertex in an execution flow. + +.. code-block:: python + + class Node(ComponentWithIO): + branches: List[str] + +Being an extension of ComponentWithIO, the Node already has the +attributes of Component (id, name, ...) and ComponentWithIO (inputs and +outputs). + +Additionally, the each Node class has an attribute called branches used +to define the names of the branches that can follow the current node. +This is used to support cases where branching in control flow is needed +(see the respective section in this page). For example: + +- In a flow, at some point we need to take different paths based on the + value of an input, or the result of a condition (e.g., the equivalent + of an in-else statement) +- A flow used in a subflow has multiple end nodes, that can be reached + as a result of an earlier branching, and that might result in + different follow-up operations, so the different outgoing branches + have to be exposed + +Most nodes that are part of the flow have at least one default branch, +used to define which is the following node to be executed, that we +define with the name ``next`` . Note that the branches attribute of the +Node is typically managed (i.e., automatically inferred) by the +implementation of the Node, but it will appear in the representation. + +Each type of node can also have other specific attributes that define +its configuration. They are defined in the table in this section +containing the list of node types available in Agent Spec. + +The list of inputs and outputs exposed by a Node is supposed to be +automatically (and dynamically) generated by the Node instance itself in +one of the SDKs. The user is still allowed to explicitly define them +when generating the representation from an SDK, but the set of inputs and/or outputs +manually specified must match with the expected one as per node's +generation (i.e., all the expected inputs and/or outputs must be present +in the list specified by the user). This allows user specifying better +the type of an input/output with respect to the one inferred +automatically by the node (e.g., specify that a value in a prompt +template is supposed to be an integer instead of a generic string). The +lists of inputs and outputs will always appear in the representation of every node. + +The standard library of available nodes is described in a separate +section later. + +Some nodes infer inputs from special parts of their configurations. For +example, some nodes (e.g., the LLMNode) extract inputs from a property +called ``prompt_template``. We let users define placeholders in the +configuration parts that allow that, by simply wrapping the name of the +property among a double set of curly brackets. The node will +automatically infer the inputs by interpreting those configurations, and +the actual value of the input/output will be replaced at execution time. + +For example, setting the prompt_template configuration of an LLMNode to +"You are a great assistant. Please help the user with this request: +{{request}}" will generate an input called "request" of type string. + +Relationships / Edges +^^^^^^^^^^^^^^^^^^^^^ + +For flows, Agent Spec defines separate relationships (edges) for both +properties and control flow. This allows Agent Spec to unambiguously handle +patterns such as conditional branches and loops, which are very +challenging to support with data-only flow control. + +Relations are only applicable for flows. All relations express a +transition from some source node to some target node. + +Control edges +''''''''''''' + +A control relationship defines a *potential* transition from a branch of +some node to another. The actual transition to be taken during a given +execution is defined by the implementation of a specific node. + +.. code-block:: python + + class ControlFlowEdge(Component): + from_node: Node + from_branch: Optional[str] + to_node: Node + +The from_branch attribute of a ControlFlowEdge is set to null by +default, which means that it will connect the default branch (i.e., +``next`` ) of the source node. + +Data relationships (I/O system components) +'''''''''''''''''''''''''''''''''''''''''' + +Considering the I/O system, a component id alone is not sufficient to +identify a data relationship. A component may take multiple input +parameters and/or produce multiple output values. We must be able to +determine what value or reference is mapped to each input, and where +each output is used. + +Sources (inputs) can be connected to outputs of another node, or to +static values. + +Destination (outputs) can be connected to inputs of another node, or +left unconnected. + +.. code-block:: python + + class DataFlowEdge(Component): + source_node: Node + source_output: str + destination_node: Node + destination_input: str + +Connecting multiple data edges +'''''''''''''''''''''''''''''' + +In the case of control flow, multi-connections from the same outgoing branch +of a node are not allowed (note that we do not enable parallelism through edges). +Edges define which are the allowed "next step" transitions, +so that it's clear that it's possible to execute one node after the execution of another. +For more information about scenarios with multiple outgoing branches (e.g., based on the value +of a condition), please refer to the section about Conditional branching. + +For what concerns the data flow, instead, it is sometimes necessary to connect two +different data outputs to the same input. Two simple examples follow: + +|image7| + +Therefore, we let users connect multiple data outputs to the same input. +If multiple outputs are connected to the same input, the last node that +was executed and that has an output connected to the input will have the priority. +In other words, the behavior is similar to having the input exposed as a public +variable, and every node that has an output connected to that input updates its value. + +Optionality of data relationships +''''''''''''''''''''''''''''''''' + +Some frameworks do not require to specify the data flow, as they assume +that all the properties are publicly exposed in a shared "variable +space" that any component can access, and the read/write system is +simply name-based. + +In practice, this means that: + +- when a component has an input with a specific name, it will look into + the public variable space to read the value of the variable with that + name +- when a component has an output with a specific name, it will write (or + overwrite if it already exists) into the public variable space the + variable with that name + +The main advantage of this approach is that users do not have to define +any data flow, making the building experience faster. + +We let users adopt this type of I/O system by setting the +``data_flow_connections`` parameter of the Flow to None. + +In this case, a name-based approach explained above will be adopted, which means that +if a component writes an output whose name matches a variable space entry, +the respective value in the variable space is overwritten. + +Note that this name-based approach can be expressed defining explicitly +Data Flow connections (but the opposite is not possible) by connecting +all the outputs to the inputs with the same name, following the control +flow connections in the flow in order to account for overwrites. + +.. collapse:: How to translate public values into data flow edges... + + As just affirmed, it's possible to transform the name-based approach to + the data flow one, by creating automatically the right set of + DataFlowEdges. + + As a basic approach, this translation can be done by simply connecting + all the outputs to all the inputs with the same name. The priority-based + solution for value updates explained in previous section 5.4.4.4.3 will + ensure the correct behavior of the data flow. + + In order to minimize the amount of connections created, it's possible to + also adopt different solutions that do a simulation of an execution by + following the control flow connections. SDKs are allowed to implement + one version of this smarter solution. + +Conditional branching +^^^^^^^^^^^^^^^^^^^^^ + +Being able to follow different paths (or branches) based on some +conditions is an important feature required to represent processes as +flows (e.g., flowcharts). + +Conditional branching in Agent Spec is supported through a special step called +BranchingNode (detailed later), a node that maps input values to +different branches through a key-value mapping. + +Nodes can have multiple outgoing branches, as previously mentioned in this page. +The ``branches`` attribute of the Node is automatically filled and managed +by the implementation of the Node, but it will appear in the representation. Both +``Node.branches`` and ``ControlFlowEdge.from_branch`` are set to null by +default, and that is the default behavior in case one single branch is +going out of a node (this is compatible with the definition of Node and +ControlFlowEdge in this page). If a value is specified for the branches +of the from_node of a ControlFlowEdge, then also from_branch must be set +to one of the values in branches. GUIs will show the different branches +as output flows of the node, so that each of them can be connected in a +1-to-1 manner with another node's flow input. + +.. _parallelization: + +Parallelization +^^^^^^^^^^^^^^^ + +Support for parallelism in Agent Spec is limited to specific nodes that explicitly claim to support it. +The boundaries of parallelism (i.e., fork and join) are well defined, and coincide with those of the node where +parallelism is allowed. + +.. note:: + + Agent Spec does not enforce a specific way to implement parallelism. Runtimes can implement parallelism with different + techniques (including, but not limited to, multi-processing and multi-threading), as long as they respect + Agent Spec language directives and :ref:`security guidelines `. + Developers creating Agent Spec configurations should not make any assumption on how parallelism is implemented. + +During parallel execution, the execution order among parallel flows is not guaranteed. +Therefore, no assumptions should be made about the order or timing of operations, nor their atomicity. + +Consequently, take special precautions in flows and nodes that are supposed to be executed in parallel: + +- Avoid placing interrupts or user input/output operations (including, but not limited to, message nodes, + agent invocations, client tools) within parallel branches, as this can result in unpredictable behavior. +- Do not perform write operations on shared stateful objects in parallel nodes to prevent race conditions or inconsistent states. +- Parallel blocks should ideally be limited to independent, stateless tasks. + Carefully review flows with parallelism to ensure they remain safe and predictable. + + +Standard library of nodes to use in flows +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Here's the list of nodes supported in Agent Spec: + +- LLMNode: uses a LLM to generate some text from a prompt +- APINode: performs an API call with the given configuration and inputs +- AgentNode: runs a multi-round conversation with an Agentic Component, used to + better structure agentic components and easily reuse them. +- FlowNode: runs a flow inline, used to better structure and easily + reuse Flows +- CatchExceptionNode: Used to catch exceptions that may be raised during the + execution of a given subflow. +- MapNode: performs a map-reduce operation on a given input collection, + applying a specified Flow to each element in the collection +- StartNode: entry point of a flow +- EndNode: exit point of a flow +- BranchingNode: allows conditional branching based on the value of an input +- ToolNode: executes a tool +- InputMessageNode: interrupts temporarily the execution to retrieve user input +- OutputMessageNode: appends an agent message to the conversation +- ParallelMapNode: performs a parallel map-reduce operation on a given input collection +- ParallelFlowNode: execute a list of subflows in parallel + + +A more detailed description of each node follows. + +.. list-table:: + :widths: 5 20 15 15 15 15 + :class: wideoutertable + :header-rows: 1 + + * - Name + - Description + - Parameters + - Input + - Output + - Outgoing branches + * - LlmNode + - * Uses a LLM to generate some text or a structured output from a prompt + * Configured with a prompt template, a LLM and optionally generation parameters (number of tokens, etc) + * Single round + - .. list-table:: + :header-rows: 1 + :widths: 20 35 15 15 15 + :class: mywideinnertable + + * - Name + - Description + - Type + - Mandatory + - Default + * - prompt_template + - Defines the prompt sent to the model. Allows placeholders, which can define inputs + - string + - Yes + - - + * - llm_configuration + - The LLM to use for this generation + - LlmConfig + - Yes + - - + + - One per variable in the prompt template + - Two alternatives are available: + + * A single string property, that represents the raw text generated by the LLM + * A non-string property, or multiple properties. In this case, structured generation is triggered, following the property schemas provided as outputs + + - One, the default next + * - ApiNode + - * Performs an API call with the given settings and inputs + * Provides the parts of the API call response as outputs + - .. list-table:: + :header-rows: 1 + :widths: 20 35 15 15 15 + :class: mywideinnertable + + * - Name + - Description + - Type + - Mandatory + - Default + * - url + - The URL to send the request. Allows placeholders, which can define inputs + - string + - Yes + - - + * - http_method + - The HTTP method. Allows placeholders, which can define inputs + - string + - Yes + - - + * - api_spec_uri + - The path to get the specification json. Allows placeholders, which can define inputs + - string | null + - No + - null + * - data + - The data to send to this API call. Behaves the same way as the ``data`` parameter in :ref:`RemoteTool `. + - any + - No + - {} + * - query_params + - Query parameters for the API call. Allows placeholders in dict values, which can define inputs + - object[str, any] + - No + - {} + * - headers + - Additional headers for the API call. Allows placeholders in dict values, which can define inputs + - object[str, any] + - No + - {} + * - sensitive_headers + - Additional headers for the API call. These additional headers are merged with the headers provided + as `headers` when executing the request, but these headers are excluded from exported configuration + thus they are better suited to contain sensitive information not intended to be shared as widely as + the exported configuration. + - object[str, any] + - No + - {} + * - url_allow_list + - Optional list of allowed URLs or URL patterns for the rendered request URL. The intended semantics are + the same as for ``RemoteTool.url_allow_list``. For example, ``https://example.com`` allows any path + on that origin, while ``https://example.com/orders/`` allows only the ``/orders/`` subtree. + - array[string] | null + - No + - null + * - retry_policy + - Optional retry policy configuration applied to this API call. + - RetryPolicy | null + - No + - null + + - Inferred from the json spec retrieved from API Spec URI, if available and reachable. + Empty otherwise (users will have to manually specify them) + - Inferred from the json spec retrieved from API Spec URI, if available and reachable. + Empty otherwise (users will have to manually specify them) + - One, the default next + * - AgentNode + - * Runs a conversation with an Agentic Component (potentially multi-round) + * The component is started giving the specified inputs + * The component provides the specified outputs + * By separating the component definition from the node executing it (this node), + we can handle cases where the same component (defined once) is executed in several places of a flow, + or by different flows (future versions) + * If the agentic component is a flow, the flow is ran in a different conversation (isolated) + - .. list-table:: + :header-rows: 1 + :widths: 20 35 15 15 15 + :class: mywideinnertable + + * - Name + - Description + - Type + - Mandatory + - Default + * - agent + - The agent to be executed + - Agent + - Yes + - - + + - The ones defined by the Agent + - The ones defined by the Agent + - One, the default next + * - FlowNode + - * Runs a Flow + * Used to better structure agents and easily reuse flows across them + * The flow is started giving the specified inputs + * The flow provides the specified outputs + * The flow is run as it was inlined with the overall flow + - .. list-table:: + :header-rows: 1 + :widths: 20 35 15 15 15 + :class: mywideinnertable + + * - Name + - Description + - Type + - Mandatory + - Default + * - subflow + - The flow to be executed + - Flow + - Yes + - - + + - Inferred from the inner structure. + It's the sets of inputs required by the StartNode of the inner flow + - Inferred from the inner structure. + It's the set of outputs defined in the ``subflow``'s specification + - Inferred from the inner flow: one per unique ``branch_name`` of the ``subflow``'s EndNodes + * - MapNode + - The MapNode is used when we need to map a sequence of nodes to each of the values + defined in a list (from output of a previous node). This node is responsible to + asynchronically map each value of a collection (defined in input_schema) to the first node + of the 'subflow' and reduce the outputs of the last node of the 'subflow' to + defined variables (defined in output_schema) + - .. list-table:: + :header-rows: 1 + :widths: 20 35 15 15 15 + :class: mywideinnertable + + * - Name + - Description + - Type + - Mandatory + - Default + * - subflow + - The flow that should be applied to all the input values + - Flow + - Yes + - - + * - reducers + - The way the outputs of the different executions (map) should be collected together (reduce). + It's a dictionary mapping the name of an output to the respective reduction method. + Currently supported reduction methods are: ``append``, ``sum``, ``average``, ``max``, ``min``. + Allowed methods depend on the type of the output: + + - ``sum``, ``average``, ``max``, ``min`` are applicable only to ``integer`` and ``number`` types + - ``append`` is applicable to all the types + + - object[str, str] | null + - No + - null, each output is aggregated through value concatenation (append) + + - Inferred from the inner structure (as defined in FlowNode). + The names of the inputs will be the ones of the inner flow, + complemented with the ``iterated_`` prefix. Their type is + ``Union[inner_type, List[inner_type]]``, where ``inner_type`` + is the type of the respective input in the inner flow. + + - If an input of type ``inner_type`` is connected, the same value will used in + all the executions of the inner flow + - If an input of type ``List[inner_type]`` is connected, the input values will be iterated over + + Note that all the input lists must have the same length, otherwise a runtime error will be thrown. + + - Inferred from the inner structure (as defined in FlowNode), + combined with the reducer method of each output. + The names of the outputs will be the ones of the inner flow, + complemented with the ``collected_`` prefix. Their type depends + on the ``reduce`` method specified for that output: + + - ``List`` of the respective output type in case of ``append`` + - same type of the respective output type in case of ``sum``, ``avg`` + + - One, the default next + * - CatchExceptionNode + - * Used to catch exceptions that may be raised during the execution of a given subflow. + * If an exception is caught during the subflow execution, branches out to an exception branch. + Otherwise, uses the transitions/branches specified for the subflow. + * Exposes the inputs of the subflow and outputs the subflow outputs with additional information + about a potentially caught exception (for more information read the :ref:`security guidelines `). + * When the subflow runs to completion without failure, this node returns the subflow outputs. + Otherwise, it returns the default values for the subflow outputs, along with the exception + information. + - .. list-table:: + :header-rows: 1 + :widths: 20 35 15 15 15 + :class: mywideinnertable + + * - Name + - Description + - Type + - Mandatory + - Default + * - subflow + - Flow to execute and catch errors from + - Flow + - Yes + - - + + - Same as the inputs from the ``sub_flow``. + + - Composed of: + + * The outputs of the ``sub_flow``. If an exception is raised, will output the default values + of each output property of the subflow. As a consequence, all outputs of the node must have + default values. If they are not already specified at the subflow level, the developer must + specify them in the output properties of the CatchExceptionNode node. + * The caught exception information, named ``caught_exception_info``: (optional string, + default to null when no exception is raised). + + - Composed of: + + * The branches of the ``sub_flow``; + * A branch ``caught_exception_branch`` for when an exception is caught. + * - ParallelMapNode + - The ParallelMapNode is used when we need to map a sequence of nodes to each of the values + defined in a list (from output of a previous node). Its functionality is equivalent to the MapNode, + the only difference is that in this node the map operation is supposed to be performed in parallel. + Please check the concerns regarding parallel execution depicted in the :ref:`parallelization section `. + - .. list-table:: + :header-rows: 1 + :widths: 20 35 15 15 15 + :class: mywideinnertable + + * - Name + - Description + - Type + - Mandatory + - Default + * - subflow + - The flow that should be applied to all the input values + - Flow + - Yes + - - + * - reducers + - The way the outputs of the different executions (map) should be collected together (reduce). + It's a dictionary mapping the name of an output to the respective reduction method. + Currently supported reduction methods are: ``append``, ``sum``, ``average``, ``max``, ``min``. + Allowed methods depend on the type of the output: + + - ``sum``, ``average``, ``max``, ``min`` are applicable only to ``integer`` and ``number`` types + - ``append`` is applicable to all the types + + - object[str, str] | null + - No + - null, each output is aggregated through value concatenation (append) + + - Inferred from the inner structure (as defined in FlowNode). + The names of the inputs will be the ones of the inner flow, + complemented with the ``iterated_`` prefix. Their type is + ``Union[inner_type, List[inner_type]]``, where ``inner_type`` + is the type of the respective input in the inner flow. + + - If an input of type ``inner_type`` is connected, the same value will used in + all the executions of the inner flow + - If an input of type ``List[inner_type]`` is connected, the input values will be iterated over + + Note that all the input lists must have the same length, otherwise a runtime error will be thrown. + + - Inferred from the inner structure (as defined in FlowNode), + combined with the reducer method of each output. + The names of the outputs will be the ones of the inner flow, + complemented with the ``collected_`` prefix. Their type depends + on the ``reduce`` method specified for that output: + + - ``List`` of the respective output type in case of ``append`` + - same type of the respective output type in case of ``sum``, ``avg`` + + - One, the default next + * - ParallelFlowNode + - Execute a list of subflows in parallel. + Please check the concerns regarding parallel execution depicted in the :ref:`parallelization section `. + - .. list-table:: + :header-rows: 1 + :widths: 20 35 15 15 15 + :class: mywideinnertable + + * - Name + - Description + - Type + - Mandatory + - Default + * - subflows + - The flows that should be executed in parallel + - List[Flow] + - Yes + - - + + - Inferred from the inner structure. It's the union of the sets of inputs of the inner flows. + Inputs of different inner flows that have the same name are merged if they have the same type. + Inputs of different inner flows with the same name but different types are not allowed. + + - Inferred from the inner structure. It's the union of the outputs of the inner flows. + Outputs of different inner flows with the same name are not allowed. + + - One, the default next + * - StartNode + - * Entry point of a flow + * Defines the flow inputs + - None + - The list of inputs that should be exposed by the flow + - Inferred form the inputs. If a value is given, it must match exactly the list of properties defined in inputs + - One, the default next + * - EndNode + - * End point of a flow + * Defines exported outputs of the flow + - .. list-table:: + :header-rows: 1 + :widths: 20 35 15 15 15 + :class: mywideinnertable + + * - Name + - Description + - Type + - Mandatory + - Default + * - branch_name + - The name of the branch that corresponds to the branch that gets closed by this node, + which will be exposed by the Flow + - string | null + - No + - null (the default ``next`` branch value is used) + + - Inferred form the outputs. If a value is given, it must match exactly the list of properties defined in outputs + - The list of outputs that should be exposed by the flow + - None (note that the branch_name is used by the Flow, not by this node) + * - BranchingNode + - * Control flow branching point + * Defines which branch to follow based on the value of a given input + * Each input value is mapped to a different outgoing branch + - .. list-table:: + :header-rows: 1 + :widths: 20 35 15 15 15 + :class: mywideinnertable + + * - Name + - Description + - Type + - Mandatory + - Default + * - mapping + - Mapping between the value of the input and the name of the outgoing branch that will + be taken when that input value is given + - object[str, str] + - Yes + - - + + - The input value that should be used as key for the mapping + - None + - One for each value in the mapping, plus a branch called default, which is the branch taken by the flow + when mapping fails (i.e., the input does not match any key in the mapping) + * - ToolNode + - Executes the given tool + - .. list-table:: + :header-rows: 1 + :widths: 20 35 15 15 15 + :class: mywideinnertable + + * - Name + - Description + - Type + - Mandatory + - Default + * - tool + - The tool to be executed + - Tool + - Yes + - - + + - Inferred from the definition of the tool to execute + - Inferred from the definition of the tool to execute + - One, the default next + * - InputMessageNode + - * Appends an agent message to the conversation, if given + * Interrupts the execution of the flow in order to wait for a user input and restarts after getting it + * User input is appended to the conversation as a user message + * User input is also returned as a string property from the node. + - .. list-table:: + :header-rows: 1 + :widths: 20 35 15 15 15 + :class: mywideinnertable + + * - Name + - Description + - Type + - Mandatory + - Default + * - message + - Content of the agent message to append to the conversation before waiting for user input. + Allows placeholders, which can define inputs + - str + - No + - null (no agent message is appended) + + - One per variable in the message + - One string property that represents the content of the input user message + - One, the default next + * - OutputMessageNode + - Appends an agent message to the ongoing flow conversation + - .. list-table:: + :header-rows: 1 + :widths: 20 35 15 15 15 + :class: mywideinnertable + + * - Name + - Description + - Type + - Mandatory + - Default + * - message + - Content of the agent message to append. Allows placeholders, which can define inputs + - str + - Yes + - - + + - One per variable in the message + - No outputs + - One, the default next + +A2AAgent +~~~~~~~~ + +``A2AAgent`` is an implementation of ``AgenticComponent`` which uses the A2A protocol to communicate with a remote server agent. It handles all necessary data transformation and communication logic to and from the server agent. +For more details on the A2A protocol, refer to the :ref:`A2A (Agent to Agent Protocol) section `. + +.. code-block:: python + + class A2AAgent(AgenticComponent): + agent_url: str + connection_config: A2AConnectionConfig + session_parameters: Optional[Dict[str, Any]] + +- ``agent_url``: Specifies the URL of the remote server agent for establishing a connection, serving as the endpoint for communications. +- ``connection_config``: Contains HTTP connection details, including timeouts, SSL/TLS security settings, and optional retry policy configuration, ensuring a secure and optimized connection with necessary certificates. +- ``session_parameters``: Defines session behavior settings such as polling timeouts and retry mechanisms, enabling precise control over interactions with the remote server to manage interruptions or delays. + These settings include ``timeout``, specifying the maximum wait time in seconds before deeming a session unresponsive; + ``poll_interval``, defining the interval in seconds between polling attempts to check for server responses; + and ``max_retries``, indicating the maximum number of retry attempts to establish a connection or obtain a response before aborting. + +RemoteAgent +~~~~~~~~~~~ + +A ``RemoteAgent`` is an ``AgenticComponent`` whose logic executes outside the +current process, typically behind a remote endpoint. The representation must therefore +capture enough information for an executor to perform the remote invocation and +to relay messages back and forth. + + +OciAgent +~~~~~~~~ + +``OciAgent`` is a concrete implementation of ``RemoteAgent`` running on +Oracle Cloud Infrastructure. It adds OCI-specific authentication and connection details. + +.. code-block:: python + + class OciAgent(ComponentWithIO): + agent_endpoint_id: str + client_config: OciClientConfig # contains all OCI authentication related configurations + retry_policy: Optional[RetryPolicy] # Optional retry configuration for calls sent to the remote OCI agent + +- ``agent_endpoint_id`` identifies the OCI AI Agent endpoint that should receive the request. +- ``client_config`` contains the OCI client and authentication configuration used to connect to the service. +- ``retry_policy`` optionally specifies a ``RetryPolicy`` for outbound requests to the remote OCI agent. + + +Swarm +~~~~~ + +A ``Swarm`` is an ``AgenticComponent`` that enables multi-agent collaboration. +Unlike a single agent, a Swarm defines a group of agents that can communicate +and delegate tasks among each other based on a set of predefined relationships. +Agents in Swarm can be any ``AgenticComponent``. +Swarm preserves the standard messaging and execution semantics of ``AgenticComponent``. + +.. code-block:: python + + class Swarm(AgenticComponent): + first_agent: AgenticComponent + relationships: List[Tuple[AgenticComponent, AgenticComponent]] + handoff: Literal["never", "optional", "always"] + +When a Swarm is initialized, the conversation always begins with the ``first_agent``— this is the agent that interacts directly with the human user. + +From there, the ``first_agent`` may: + +1. Response directly to the user's query, or +2. Call another agent (that it has a defined relationship with) to handle a specialized subtask. + +The human user remains in conversation with the ``first agent`` during this time. +The called agent can, in turn, call other agents it has relationships with to further handle a subtask. + +If ``handoff="optional"``, the first_agent also gains a third option: +it may hand off the entire conversation to another agent. +Once the handoff occurs, the receiving agent becomes the new primary point of contact for the human user. This mechanism can significantly reduce latency by avoiding unnecessary back-and-forth message passing between agents. + +When ``handoff="always"`` is enabled, an agent cannot call another agent and wait for a reply (i.e. it does not have the second option mentioned above). +Delegation is only possible through a full handoff of the conversation. +Handing off the conversation establishes a strict chain-of-ownership: each agent must transfer the entire dialogue context when involving another agent. + +Each relationship is defined as a tuple ``(caller_agent, recipient_agent)`` +which represents a one-way communication link from ``caller_agent`` to ``recipient_agent``. + +ManagerWorkers +~~~~~~~~~~~~~~ + +A ``ManagerWorkers`` is an ``AgenticComponent`` designed for manager-worker style collaboration among multiple agents. +It consists of a manager agent that coordinates and assigns tasks to a group of worker agents. +Agents in ManagerWorkers can be any ``AgenticComponent``. +The ManagerWorkers component maintains the standard messaging and execution semantics of an ``AgenticComponent``. + +.. code-block:: python + + class ManagerWorkers(AgenticComponent): + group_manager: AgenticComponent + workers: List[AgenticComponent] + +The ManagerWorkers has two main parameters: + +- ``group_manager`` + An agentic component (e.g. Agent) that is used as the group manager, + responsible for coordinating and assigning tasks to the workers. + +- ``workers`` - List of agentic components + These agentic components serve as the workers within the group and are coordinated by the group manager. + + - Workers cannot interact with the end user directly. + - When invoked, each worker can leverage its equipped tools to complete the assigned task and report the result back to the group manager. + + +Datastores +~~~~~~~~~~ + +Datastores are the AgentSpec abstraction that enables agentic systems to store and access data. + +.. code-block:: python + + class Datastore(Component, abstract=True): + pass + +All datastores currently defined represent collections of objects defined by their type as an ``Entity``, however the Datastore parent type is left without constraint to enable future extension or the development of plugins with different Datastore structure (e.g. key-value stores, or graph database, or unstructured data storage) + +An ``Entity`` is a property that has a JSON schema equivalent to an object property; an object property would work. + +Relational datastores & In-Memory datastore +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Relational datastores (``OracleDatabaseDatastore``, ``PostgresDatabaseDatastore``) should support SQL queries. For development use, we have a drop-in replacement for them (``InMemoryCollectionDatastore``). +Both of these contain many collections (tables) where each collection is a set entities. They need to have a predefined fixed schema. A schema is a mapping from a collection name to an Entity object defining the entity(row). + +.. code-block:: python + + class InMemoryCollectionDatastore(Datastore): + datastore_schema: Dict[str, Entity] + + class RelationalDatastore(Datastore, is_abstract=True): + datastore_schema: Dict[str, Entity] + +OracleDatabaseDatastore +^^^^^^^^^^^^^^^^^^^^^^^ + +The ``OracleDatabaseDatastore`` is a relational datastore that requires an ``OracleDatabaseConnectionConfig`` object for configuration. Sensitive information (e.g., wallets, keys) is excluded from exported configurations and should be loaded at deserialization time. + +.. code-block:: python + + class OracleDatabaseDatastore(RelationalDatastore): + connection_config: OracleDatabaseConnectionConfig + +``OracleDatabaseConnectionConfig`` is the abstraction we use for configuring connections to Oracle Database. + +.. code-block:: python + + class OracleDatabaseConnectionConfig(Component, is_abstract=True): + pass + +``TlsOracleDatabaseConnectionConfig`` For standard TLS Oracle Database connections. + +.. code-block:: python + + class TlsOracleDatabaseConnectionConfig(OracleDatabaseConnectionConfig): + user: SensitiveField[str] + password: SensitiveField[str] + dsn: SensitiveField[str] + protocol: Literal["tcp", "tcps"] + config_dir: Optional[str] + +- ``user``: User used to connect to the database +- ``password``: Password for the provided user +- ``dsn``: Connection string for the database +- ``protocol``: 'tcp' or 'tcps' indicating whether to use unencrypted network traffic or encrypted network traffic (TLS) +- ``config_dir``: Configuration directory for the database connection. Set this if you are using an alias from your tnsnames.ora files as a DSN. Make sure that the specified DSN is appropriate for TLS connections. + +``MTlsOracleDatabaseConnectionConfig`` For Oracle DB connections using mutual TLS (wallet authentication). + +.. code-block:: python + + class MTlsOracleDatabaseConnectionConfig(TlsOracleDatabaseConnectionConfig): + + wallet_location: SensitiveField[str] + wallet_password: SensitiveField[str] + +- ``wallet_location``: Location where the Oracle Database wallet is stored. +- ``wallet_password``: Password for the provided wallet. + +PostgresDatastore +^^^^^^^^^^^^^^^^^ + +The ``PostgresDatabaseDatastore`` is a relational datastore intended to store entities in PostgreSQL databases. It requires a ``PostgresDatabaseConnectionConfig`` object for connection and authentication details. Sensitive information (e.g., user, password, SSL keys) is excluded from exported configurations and should be loaded at deserialization time. + +.. code-block:: python + + class PostgresDatabaseDatastore(RelationalDatastore): + connection_config: PostgresDatabaseConnectionConfig + + class PostgresDatabaseConnectionConfig(Component, is_abstract=True): + pass + + class TlsPostgresDatabaseConnectionConfig(PostgresDatabaseConnectionConfig): + user: SensitiveField[str] + password: SensitiveField[str] + url: str + sslmode: Literal["disable","allow", "prefer","require","verify-ca","verify-full"] + sslcert: Optional[str] + sslkey: Optional[SensitiveField[str]] + sslrootcert: Optional[str] + sslcrl: Optional[str] + +- ``user``: User of the postgres database +- ``password``: Password of the postgres database +- ``url``: URL to access the postgres database +- ``sslmode``: SSL mode for the PostgreSQL connection. +- ``sslcert``: Path of the client SSL certificate, replacing the default ``~/.postgresql/postgresql.crt``. Ignored if an SSL connection is not made. +- ``sslkey``: Path of the file containing the secret key used for the client certificate, replacing the default ``~/.postgresql/postgresql.key``. Ignored if an SSL connection is not made. +- ``sslrootcert``: Path of the file containing SSL certificate authority (CA) certificate(s). Used to verify server identity. +- ``sslcrl``: Path of the SSL server certificate revocation list (CRL). Certificates listed will be rejected while attempting to authenticate the server's certificate. + + +Transforms +^^^^^^^^^^ + +Transforms extend the base ``MessageTransform`` component that can be used in agents. They apply a transformation to the messages before they are passed to the agent's LLM. + +.. code-block:: python + + class MessageTransform(Component, abstract=True): + pass + +MessageSummarizationTransform +''''''''''''''''''''''''''''' + +Summarizes messages exceeding a given number of characters using an LLM and caches the summaries in a ``Datastore``. This is useful for conversations with long messages where the context can become too large for the agent LLM to handle. + +.. code-block:: python + + class MessageSummarizationTransform(MessageTransform): + llm: LlmConfig # LLM to use for the summarization. + max_message_size: int # The maximum size in number of characters for the content of a message. + summarization_instructions: str # Instruction for the LLM on how to summarize the messages. + summarized_message_template: str # Jinja2 template on how to present the summary (with variable `summary`) to the agent using the transform. + datastore: Optional[Datastore] # Datastore on which to store the cache. If None, no caching happens. + max_cache_size: Optional[int] # The number of cache entries (messages) kept in the cache. If None, there is no limit on cache size and no eviction occurs. + max_cache_lifetime: Optional[int] # max lifetime of a message in the cache in seconds. If None, cached data persists indefinitely. + cache_collection_name: str # Name of the collection in the cache for storing summarized messages. + +ConversationSummarizationTransform +'''''''''''''''''''''''''''''''''' + +Summarizes conversations exceeding a given number of messages using an LLM and caches conversation summaries in a ``Datastore``. This is useful to reduce long conversation history into a concise context for downstream LLM calls. + +.. code-block:: python + + class ConversationSummarizationTransform(MessageTransform): + llm: LlmConfig # LLM to use for the summarization. + max_num_messages: Optional[int] # Conversation-level message-count threshold. Mutually exclusive with ``max_num_characters``. + max_num_characters: Optional[int] # Total number of characters in the conversation after which we trigger summarization. Can be used together with ``min_num_messages``, but not with ``max_num_messages``. + min_num_messages: int # Number of recent messages to keep from summarizing, regardless of whether summarization is triggered by ``max_num_messages`` or ``max_num_characters``. + summarization_instructions: str # Instruction for the LLM on how to summarize the conversation. + summarized_conversation_template: str # Jinja2 template on how to present the summary (with variable ``summary``) to the agent using the transform. + datastore: Optional[Datastore] # Datastore on which to store the cache. If None, no caching happens. + max_cache_size: Optional[int] # The maximum number of entries kept in the cache + max_cache_lifetime: Optional[int] # max lifetime of an element in the cache in seconds + cache_collection_name: str # Name of the collection in the cache datastore where summarized conversations will be stored + +Versioning +---------- + +Every Agent Spec configuration should include a property called ``agentspec_version`` at the top level. +The value of ``agentspec_version`` specifies the Agent Spec specification version the configuration is +written for. When this field is missing from the configuration, the latest (current) Agent Spec version +is used instead. + +For example, the JSON serialized version of an Agent should look like the following: + +.. code-block:: json + + { + "component_type": "Agent", + "name": "my agent", + "id": "my_agent_id", + "description": "", + "human_in_the_loop": true, + "llm_config": { + "component_type": "VllmConfig", + "name": "my llm", + "id": "my_llm_id", + "description": "", + "default_generation_parameters": {}, + "model_id": "my/llm", + "url": "my.llm.url" + } + "tools": [], + "agentspec_version": "26.3.0" + } + +For release versioning, Agent Spec follows the format YEAR.QUARTER.PATCH. Agent Spec follows a +quarterly release cadence, and it aligns versioning to that cadence such that YEAR corresponds +to the last two digits of the year, and QUARTER refers to the quarter of the release (from 1 to 4). +The first release is 25.4.1. + +Updates in the PATCH version must not introduce new features or behavioral changes, +they should only cover security concerns and clarifications if needed. +However, any version update, including PATCH ones, could contain breaking changes. + +Breaking changes include all the modifications that would make a configuration written in +the previous version invalid or semantically different, for example: + +- Removing/modifying components +- Removing/renaming an attribute +- Supporting new, or changing existing behavior of components, + even when the existing signature is unchanged + +Non-breaking changes include: + +- Adding new components +- Adding new attributes +- Disambiguation or clarification of underspecified components' structure or behavior that do not + contradict previous versions + +It's the responsibility of the maintainers of Agent Spec Runtimes, SDKs, and Adapters to keep up +to date with the latest changes in the Agent Spec language specification, and to report +the compatibility of their artifacts with the different Agent Spec specification versions. + +Due to backward compatibility reasons, we recommend to create Agent Spec configurations with the +minimum version supported by all the Components in the configuration with the desired behavior. + +Backward compatibility +~~~~~~~~~~~~~~~~~~~~~~ + +All breaking changes must go through a deprecation cycle of one year. + +Whenever a breaking change is introduced, a deprecation notice must be provided +in the language specification. + +Deprecated features can be removed in any quarter release after 1 year from the deprecation notice, +but it should not be done in patch releases, unless required for security reasons. + +In any case, removed features must be announced in the release notes. + + +.. _agentspecauthspec_nightly: + + +Authentication +~~~~~~~~~~~~~~ + +Authentication configurations are used by components that communicate with remote services requiring +credentials (this may include tools, nodes, remote agents, and other components). In order to make +these tools usable in a secure and portable way, Agent Spec needs a standardized way for users to +declare the authentication method and the information required by the execution runtime to apply it +(for example, running an OAuth authorization flow to connect to a protected MCP Server). + +We define a new Component called ``AuthConfig`` that acts as the base type for all authentication configurations: + +.. code-block:: python + + class AuthConfig(Component): + pass + + +Concrete authentication mechanisms are represented as subclasses of ``AuthConfig``. Runtimes are +responsible for interpreting these configurations, enforcing security best practices (such as treating +secrets as sensitive fields), and performing any interactive steps required to obtain credentials. + + +OAuth Authentication (``OAuthConfig``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +OAuth authentication enables components to obtain and refresh access tokens via an OAuth 2.x authorization +server. ``OAuthConfig`` is intentionally generic so it can be used for MCP servers (per MCP authorization +requirements) and for non-MCP tools and other compnoents (e.g., ``ApiNode``) that call standard OAuth-protected +APIs. + +.. code-block:: python + + class OAuthConfig(AuthConfig): + issuer: Optional[str] + endpoints: Optional[OAuthEndpoints] + client: OAuthClientConfig + + redirect_uri: str + scopes: Optional[Union[str, List[str]]] + scope_policy: Optional[Literal["use_challenge_or_supported", "fixed"]] + + pkce: Optional[PKCEPolicy] + resource: Optional[str] + + +* ``issuer`` is the authorization server issuer URL used for discovery (e.g., OpenID Connect + (OIDC) discovery or RFC 8414). If provided, runtimes should discover endpoints and server + capabilities from this issuer. +* ``endpoints`` provides explicit OAuth endpoints (authorization, token, refresh, etc.). + If provided, runtimes should use these endpoints directly instead of discovery. +* ``client`` defines how the OAuth client identity is established (pre-registered client, client-id + metadata document, or dynamic client registration). + +* ``redirect_uri`` is the callback URI where the authorization server redirects the user agent + after consent (authorization code flow). +* ``scopes`` optionally defines requested scopes as a space-delimited string or list of strings. +* ``scope_policy`` specifies how the runtime selects scopes: + + * ``"use_challenge_or_supported"`` means the runtime should prefer scopes indicated by runtime + challenges/metadata (for MCP, this aligns with using ``WWW-Authenticate`` scope when present, + otherwise metadata-supported scopes). + * ``"fixed"`` means the runtime should request exactly the provided ``scopes``. + +* ``pkce`` configures Proof Key for Code Exchange (PKCE) behavior. For MCP authorization code flows, + runtimes should require PKCE and should use ``S256`` when technically capable. +* ``resource`` optionally sets a resource indicator (RFC 8707). For MCP this is typically derived + from the MCP server URL / protected resource metadata, but exposing it here allows explicit + configuration for non-MCP integrations or constrained environments. + + +OAuth Client Configuration (``OAuthClientConfig``) +'''''''''''''''''''''''''''''''''''''''''''''''''' + +OAuth client configuration specifies how the runtime identifies itself to the authorization server +and (optionally) how it registers as a client. This is used by ``OAuthConfig.client``. + +.. code-block:: python + + class OAuthClientConfig(Component): + type: Literal["pre_registered", "client_id_metadata_document", "dynamic_registration"] + + client_id: Optional[str] + client_secret: Optional[SensitiveField[str]] + token_endpoint_auth_method: Optional[str] + + client_id_metadata_url: Optional[str] + + registration_endpoint: Optional[str] + +* ``type`` selects the client identity / registration approach: + + * ``"pre_registered"`` uses client credentials that were registered out-of-band with the authorization server. + * ``"client_id_metadata_document"`` uses a URL-formatted ``client_id`` pointing to a hosted client metadata + JSON document (Client ID Metadata Document approach). + * ``"dynamic_registration"`` uses OAuth dynamic client registration (RFC 7591) to obtain a client id at runtime. + +* ``client_id`` is the OAuth client identifier (used for ``"pre_registered"``). +* ``client_secret`` is the client secret (used for confidential ``"pre_registered"`` clients). It SHOULD be omitted + for public clients. +* ``token_endpoint_auth_method`` specifies how the client authenticates to the token endpoint (e.g., ``"client_secret_basic"``, + ``"client_secret_post"``, ``"private_key_jwt"``, or ``"none"``). + +* ``client_id_metadata_url`` is the HTTPS URL used as the OAuth ``client_id`` when ``type="client_id_metadata_document"``. + This URL points to the client metadata JSON document. + +* ``registration_endpoint`` optionally specifies the dynamic client registration endpoint when ``type="dynamic_registration"``. + If omitted, runtimes SHOULD obtain it from authorization server discovery metadata when available. + + +OAuth Endpoints +''''''''''''''' + +OAuth-based tools sometimes need explicit endpoints (manual configuration) instead of discovery. ``OAuthEndpoints`` +groups these URLs so ``OAuthConfig`` stays readable and reusable across MCP tools and non-MCP tools. + +.. code-block:: python + + class OAuthEndpoints: + authorization_endpoint: str + token_endpoint: str + refresh_endpoint: Optional[str] + revocation_endpoint: Optional[str] + userinfo_endpoint: Optional[str] + +* ``authorization_endpoint`` is the authorization URL where the user agent is redirected to authenticate and grant consent. +* ``token_endpoint`` is the endpoint where the client exchanges an authorization code (or a refresh token) for access + (and optionally refresh) tokens. +* ``refresh_endpoint`` optionally overrides where refresh token requests are sent. If not provided, runtimes typically + reuse ``token_endpoint`` for refresh. +* ``revocation_endpoint`` optionally specifies where tokens can be revoked. +* ``userinfo_endpoint`` optionally specifies an OpenID Connect UserInfo endpoint when OIDC claims are needed. + + +PKCE Policy +''''''''''' + +The MCP authorization spec requires PKCE for authorization-code flows, and the reference ``OAuthClientProvider`` +enforces PKCE via server metadata (``code_challenge_methods_supported``). +``PKCEPolicy`` makes that behavior explicit and reusable for non-MCP tools. + +.. code-block:: python + + class PKCEPolicy: + required: bool + method: Literal["plain", "S256"] + +* ``required`` indicates whether the runtime must refuse to proceed if PKCE cannot be used/verified + via discovery metadata. Defaults to ``True``. +* ``method`` identifies the PKCE code challenge method. + + * with ``"plain"`` the code challenge is equal to code verifier. + * with ``"S256"`` the code verifier is hashed using SHA-256. + + Runtimes SHOULD use ``"S256"`` when technically capable. Defaults to ``"S256"``. + + +Token handling and session scope +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Runtimes that use ``AuthConfig`` (including ``OAuthConfig``) are responsible for obtaining, using, +and protecting credentials (e.g., access/refresh tokens). + +To keep configurations portable and safe (find more information in the +:ref:`security guidelines `): + +- **Treat tokens as secrets**: never expose tokens in exported configurations, UI output, tool traces, + prompts, or error messages. +- **Avoid URL-derived trust**: do not rely solely on a conversation/response identifier (especially + one that appears in a URL) as the "authentication" mechanism for accessing cached OAuth tokens. +- **Bind tokens to an authenticated principal**: in non-development deployments, token caches **should** + be scoped to an authenticated end-user and protected by server-side authorization checks. +- **Prefer short-lived access tokens** and use refresh tokens only when necessary; apply least-privilege + scopes and audience/resource restrictions. +- **Support safe interactive flows**: enforce PKCE for authorization-code flows when applicable, validate + ``state``, and restrict/validate redirect URIs. +- **Require user confirmation for discovered/auth-server trust & dynamic registration**: when using + discovery-based configuration (``issuer``) and/or ``client.type="dynamic_registration"``, runtimes/clients + **should** require an explicit user confirmation step. The prompt should clearly display the **authorization + server domain** and the **target resource/MCP server URL**, and warn when dynamic registration will create/modify + client state on the authorization server. Runtimes should also verify that discovered metadata corresponds to + the originally requested protected resource/server to reduce impersonation/phishing risk. + + + +.. _agentspecmcpspec_nightly: + +MCP (Model Context Protocol) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Model Context Protocol (MCP) provides a standardized way for LLM-based Assistants to +interact with remote servers that expose tools and data sources. MCP enables communication +over various transports such as Streamable HTTP, Server-Sent Events (SSE), or even local +stdio for prototyping. + +In Agent Spec, MCP components define configurations for establishing client sessions +and transports to MCP servers. These are used primarily for tools (via ``MCPTool``) +but can be extended to other remote interactions in future versions. + +MCP Client transport +^^^^^^^^^^^^^^^^^^^^ + +The core abstraction is ``ClientTransport``, which manages connections and sessions. + +.. code-block:: python + + class ClientTransport(Component): + session_parameters: Optional[Dict[str, Any]] + +The ``session_parameters`` should be used to specify parameters of the MCP client session, +such as the session name and version, or the session timeout. +These parameters are specified as a dictionary of parameter names and respective values. +The names are strings, while values can be of any type compatible with the JSON schema standard. + +MCP transports such as ``StdioTransport`` directly extend the ``ClientTransport`` component. + + +Stdio Transport +''''''''''''''' + +The ``StdioTransport`` component should be used for connecting to an MCP server via +subprocess with stdio. This transport must support being passed a the executable +command to run to start the servers as well as a list of command line arguments to +pass to the executable. It can also support being passed environment variables as +well as the working directory to use when spawning the process. + +.. code-block:: python + + class StdioTransport(ClientTransport): + command: str + args: List[str] + env: Optional[Dict[str, str]] + cwd: Optional[str] + + + +Remote MCP transports +^^^^^^^^^^^^^^^^^^^^^ + +Another category of MCP client transports rely on remote connections to MCP servers. +Those components should extend the ``RemoteTransport`` component and should support +the ``url``, ``auth``, ``headers``, ``sensitive_headers`` and optional +``retry_policy`` fields. + +.. code-block:: python + + class RemoteTransport(ClientTransport): + url: str + auth: Optional[AuthConfig] + headers: Dict[str, str] + sensitive_headers: SensitiveField[Dict[str, str]] + retry_policy: Optional[RetryPolicy] + + +- ``url`` is a string representing the URL to send the request. +- ``auth`` optionally specifies an ``AuthConfig`` to authenticate requests sent to the remote + MCP server. When set, runtime should use this configuration to attach credentials to requests + and/or to initiate interactive authentication flows as required. +- ``headers`` is a dictionary of additional headers to use in the client. +- ``sensitive_headers`` are additional headers that should be merged with the ``headers`` provided + when executing the request, but these headers are excluded from exported configuration thus they + are better suited to contain sensitive information not intended to be shared as widely as the + exported configuration. +- ``retry_policy`` optionally specifies a ``RetryPolicy`` for requests sent through the remote MCP transport. + + +SSE Transport +''''''''''''' + +The server-sent events (SSE) transport should be used to connect to +MCP servers via Server-Sent Events. + +.. code-block:: python + + class SSETransport(RemoteTransport): + pass + + +Streamable HTTP Transport +''''''''''''''''''''''''' + +The Streamable HTTP transport should be used to connect to MCP servers via the +`Streamable HTTP `_ +transport. + +.. code-block:: python + + class StreamableHTTPTransport(RemoteTransport): + pass + + +The transports defined above can be used in components like ``MCPTool`` +(see the Tools section) to connect to MCP servers. + + + +Additions to MCP transports +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Finally, individual MCP client transports should be extended to support additional +functionalities, such as mutual Transport Layer Security (mTLS) connections. + + +SSE Transport with mTLS +''''''''''''''''''''''' + +.. code-block:: python + + class SSEmTLSTransport(SSETransport): + key_file: SensitiveField[str] + cert_file: SensitiveField[str] + ca_file: SensitiveField[str] + +- ``key_file`` is the path to the client's private key file (PEM format). +- ``cert_file`` is the path to the client's certificate chain file (PEM format). +- ``ca_file`` is the path to the trusted CA certificate file (PEM format) to verify the server. + + +Streamable HTTP Transport with mTLS +''''''''''''''''''''''''''''''''''' + +.. code-block:: python + + class StreamableHTTPmTLSTransport(StreamableHTTPTransport): + key_file: SensitiveField[str] + cert_file: SensitiveField[str] + ca_file: SensitiveField[str] + + +- ``key_file`` is the path to the client's private key file (PEM format). +- ``cert_file`` is the path to the client's certificate chain file (PEM format). +- ``ca_file`` is the path to the trusted CA certificate file (PEM format) to verify the server. + + +.. warning:: + + For production use, always prefer secure transports like those with mTLS to ensure + mutual authentication. + +.. _agentspeca2aspec_nightly: + +A2A (Agent to Agent Protocol) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Agent to Agent Protocol (A2A) is a protocol designed to facilitate communication among agents. +This protocol enables agents to exchange messages, delegate tasks, and collaborate effectively. +It can also be used to establish a connection with a remote agent, allowing for distributed agentic systems. + +The connection to a remote agent or between agents is configured using ``A2AConnectionConfig``, +which defines the parameters for establishing a secure and reliable HTTP connection. Session +behavior such as polling is configured with ``A2AAgent.session_parameters``, while request retry +behavior can be configured with ``A2AConnectionConfig.retry_policy``. Together these settings +cover timeouts, polling, retries, and SSL/TLS security for encrypted and authenticated +communication. + +.. code-block:: python + + class A2AConnectionConfig(Component): + timeout: float # Connection timeout in seconds + headers: Optional[Dict[str, str]] # A dictionary of HTTP headers to include in requests + verify: bool # Boolean to enable HTTPS or not + key_file: Optional[str] # Path to the client's private key file + cert_file: Optional[str] # Path to the client's certificate chain file + ssl_ca_cert: Optional[str] # Path to private key file + ca_path: Optional[str] # Path to the trusted CA certificate file + retry_policy: Optional[RetryPolicy] # Optional retry configuration for requests sent through this A2A connection + +Ecosystem of plugins +-------------------- + +In order to support a wide range of use-cases, Agent Spec allows the creation of +custom components through a plugin system. Plugins are extensions +which can include new components or variants of the built-in Agent Spec +components. For supporting reading, writing and executing plugin components, +the serialization and deserialization logic must be added in an Agent Spec SDK +and the execution logic of the plugin component must be added in an Agent Spec +Runtime of choice. + +To enable the support of plugins in Agent Spec SDK and Runtime, every component +from a plugin should specify its ``component_type``. This is needed for +serialization and deserialization in order to be able to select the right +plugin serializer or deserializer. Additionally, the name and version of plugins +are needed, because this information will help track and understand the provenance +and compatibility of various components from the plugin ecosystem. + +Additionally, if a plugin component is intended as a subtype of another component +(for example a ``Node``, such that the new component can be included in a Flow +which expects sub components of type ``Node``), it must then have the same +unmodified attributes as the parent type and can only add new attributes. + +.. literalinclude:: ../agentspec_config_examples/example_plugin_component.json5 + :language: JSON5 + +Assuming an SDK in the Python programming language, as an example (see +:ref:`the sdk detailed below `), the abstract interface +implemented for a plugin should be similar to this: + +.. code-block:: python + + class ComponentSerializationPlugin: + + @abstractmethod + def plugin_name(self) -> str: + """Return the plugin name.""" + pass + + @abstractmethod + def plugin_version(self) -> str: + """Return the plugin version.""" + pass + + @abstractmethod + def supported_component_types(self) -> List[str]: + """Indicate what component types the plugin supports.""" + pass + + @abstractmethod + def serialize(self, component: Component, serialization_provider) -> Dict[str, Any]: + """Method to implement to serialize a component that the plugin should be able to support.""" + pass + + + +And similarly for deserialization: + +.. code-block:: python + + class ComponentDeserializationPlugin: + def plugin_name(self) -> str: ... + + def plugin_version(self) -> str: ... + + def supported_component_types(self) -> List[str]: ... + + @abstractmethod + def deserialize(self, serialized_component: Dict[str, Any], deserialization_provider: DeserializationProvider) -> Component: + """Method to implement to deserialize a component that the plugin should be able to support.""" + pass + + +Disaggregated components +------------------------ + +There are scenarios where some parts of an Agent Spec configuration should not be exported +as part of the main assistant's configuration. For example when: + +- Some parts of a Component configuration contain sensitive information; +- It's useful to plug different Component versions in a configuration, based on the use case + (e.g., use different LlmConfigurations in development and production environment), but + without duplicating the configuration for every Component's version. + +.. important:: + Agent Spec configurations intended for storage, sharing, version control, logging, + or deployment should not contain sensitive information. Sensitive values should be + represented as references and supplied through trusted runtime or deserialization + mechanisms. + +For this reason, Agent Spec supports referencing **components and values** that are not serialized +in the same configuration, therefore called *disaggregated*. +A serialized configuration of disaggregated components contains only the dictionary of ``$referenced_components``, +and it does not contain any component at the top level. +These disaggregated components must be provided when the main configuration is deserialized, +otherwise the deserialization should fail. +Additional disaggregated components that are not part of any exported configuration (e.g., those containing +potentially sensitive information) can be additionally provided at deserialization time by the SDK or the Runtime +performing it. + +The disaggregated components references follow the same :ref:`reference system ` described for components, based on ID matching. +The ID used for matching the disaggregated component is, by default, the key used in the ``$referenced_components`` dictionary. +Users can override this behavior at deserialization time by mapping it to a different ID to match a component reference. +A component reference and its matched disaggregated component must be type-compatible. + +Note that also values (e.g., the ``prompt_template`` in an ``LlmNode``, or the ``url`` of a ``VllmConfig``) +can become referenced components. This means that in the main configuration from which they are disaggregated, +they will appear as component references in the ``$referenced_components`` dictionary, +with an ID assigned to them (i.e., the key of the dictionary entry). +The value should replace the reference in the main configuration during deserialization. + +Here's an example of an Agent Spec configuration that uses disaggregated components: + +.. code-block:: JSON5 + + { + "component_type": "Agent", + "id": "powerful_agent_id", + "name": "Powerful agent", + "description": null, + "human_in_the_loop": true + "metadata": {}, + "llm_config": { + "component_type": "VllmConfig", + "default_generation_parameters": null, + "description": null, + "id": "llama_llm_id", + "metadata": {}, + "model_id": "meta/llama-3.3-70b", + "name": "vllm", + "url": { "$component_ref": "llm_url_id" } + }, + "system_prompt": "You are a powerful agent", + "tools": [ + { "$component_ref": "powerful_tool_id" } + ], + "inputs": [], + "outputs": [] + } + +And here's the configuration containing the disaggregated components: + +.. code-block:: JSON5 + + { + "$referenced_components": { + "powerful_tool_id": { + "component_type": "ClientTool", + "name": "powerful_tool", + "id": "powerful_tool_id", + "description": "Does powerful things", + "metadata": {}, + "inputs": [], + "outputs": [] + }, + "llm_url_id": "my.url.to.llm" + } + } + + + + +.. _agentspecsensitivefield_nightly: + +Sensitive fields +---------------- + +Some of the fields in the configuration of Agent Spec components may contain sensitive information +such as API keys or authentication tokens. To help developers securely support these components, +AgentSpec runtimes and SDKs must follow these three guidelines: + +- **When exporting configurations**: Sensitive fields should be excluded by default and represented + as references. Any export that intentionally includes sensitive values must be treated as secret + material, not as an ordinary portable Agent Spec configuration. +- **When loading configurations**: Runtime adapters and SDKs must allow passing the excluded sensitive information. +- **When loading configurations**: If developers have already inlined the sensitive information into their + configurations, runtimes and SDKs should allow loading these configurations too. + +See all the fields below that are considered sensitive fields: + ++----------------------------------+--------------------+ +| Component | Attribute | ++==================================+====================+ +| OpenAiCompatibleConfig | api_key | ++----------------------------------+--------------------+ +| OpenAiCompatibleConfig | key_file | ++----------------------------------+--------------------+ +| OpenAiCompatibleConfig | cert_file | ++----------------------------------+--------------------+ +| OpenAiCompatibleConfig | ca_file | ++----------------------------------+--------------------+ +| OpenAiConfig | api_key | ++----------------------------------+--------------------+ +| LlmConfig | api_key | ++----------------------------------+--------------------+ +| GeminiAIStudioAuthConfig | api_key | ++----------------------------------+--------------------+ +| GeminiVertexAIAuthConfig | credentials | ++----------------------------------+--------------------+ +| OciClientConfigWithSecurityToken | auth_file_location | ++----------------------------------+--------------------+ +| OciClientConfigWithApiKey | auth_file_location | ++----------------------------------+--------------------+ +| RemoteTool | sensitive_headers | ++----------------------------------+--------------------+ +| ApiNode | sensitive_headers | ++----------------------------------+--------------------+ +| RemoteTransport | sensitive_headers | ++----------------------------------+--------------------+ +| SSEmTLSTransport | key_file | ++----------------------------------+--------------------+ +| SSEmTLSTransport | cert_file | ++----------------------------------+--------------------+ +| SSEmTLSTransport | ca_file | ++----------------------------------+--------------------+ +| StreamableHTTPmTLSTransport | key_file | ++----------------------------------+--------------------+ +| StreamableHTTPmTLSTransport | cert_file | ++----------------------------------+--------------------+ +| StreamableHTTPmTLSTransport | ca_file | ++----------------------------------+--------------------+ + +For example, ``GeminiAIStudioAuthConfig.api_key`` or +``GeminiVertexAIAuthConfig.credentials`` may become references while the enclosing +``auth`` component remains inline. + + +For example, the following component produced using the `pyagentspec` SDK: + +.. code-block:: python + + OpenAiCompatibleConfig( + name="llm-config", + id="llm-config-id", + url="https://some.api.com/v2", + model_id="some_model_id", + api_key="THIS_IS_SECRET", + key_file="/etc/certs/client.key", + cert_file="/etc/certs/client.pem", + ca_file="/etc/certs/ca.pem", + ) + +should produce the following configuration, in which sensitive fields are replaced by references + +.. code-block:: JSON5 + + { + "component_type" : "OpenAiCompatibleConfig", + "id" : "llm-config-id", + "name" : "llm-config", + "url" : "https://some.api.com/v2", + "model_id" : "some_model_id", + "api_key" : { + "$component_ref" : "llm-config-id.api_key" + }, + "key_file" : { + "$component_ref" : "llm-config-id.key_file" + }, + "cert_file" : { + "$component_ref" : "llm-config-id.cert_file" + }, + "ca_file" : { + "$component_ref" : "llm-config-id.ca_file" + }, + } + +Such a configuration would then require to pass the referenced sensitive information when loading, as +in the example code below: + +.. code-block:: python + + llm_config = AgentSpecDeserializer().from_json( + serialized_llm, + components_registry={ + "llm-config-id.api_key": "THIS_IS_SECRET", + "llm-config-id.key_file": "/etc/certs/client.key", + "llm-config-id.cert_file": "/etc/certs/client.pem", + "llm-config-id.ca_file": "/etc/certs/ca.pem", + }, + ) + + +Additional components for future versions +----------------------------------------- + +In this first version we focused on the foundational elements of agents, +but some concepts are not yet covered, and should be part of further +discussions, and included in future versions. + +Among them, we can highlight: + +- Memory +- Planning + +These topics will be covered in future versions of Agent Spec. + +Language examples +================= + +Standalone agent +---------------- + +Agents are capable of leveraging tools in multi-turn conversation in +order to reach a specific goal. + +Agents do not express flow of control: the layout for these components +simply expresses property assignments. + +In the following example we provide a generic implementation of a +flexible agent that includes all the components it can take advantage of +(e.g., tools, llms). In a future extension of the language, more +components can be added to this spec (e.g., memory, planner). + +|image2| + +In the following example, we show the representation of an agent focused on +benefits, which has the same structure depicted above. + +.. collapse:: Agent Spec representation + + .. literalinclude:: ../agentspec_config_examples/standalone_agent.json5 + :language: JSON5 + + +Standalone flow +--------------- + +The following diagram illustrates a flow that could be used in an online +store's customer support system. + +The flow checks if an order is eligible for a replacement, and if that +is the case, which products should be offered to the user in exchange +for their defective item. + +The flow may be invoked by a conversational agent or executed upon +configuration via another user interface. + +|image3| + +This graph is implemented by the following Agent Spec representation: + +.. collapse:: Agent Spec representation + + .. literalinclude:: ../agentspec_config_examples/standalone_flow.json5 + :language: JSON5 + + +Agents in flow +-------------- + +In this example we are going to show how to use agents in a flow. + +We propose a Coding Agent where different specialized agents operate in +sequence in order to generate code based on a user request. + +The flow consists of a main conversational agent that talks with a user +to gather the request, then three agents that take care of the code +generation and its review. + +The advantage of having this type of agent implemented as a graph is +that we have control on what is the sequence of calls and events that +should happen before approving the generated code. + +In particular, we can ensure that the code gets generated by the +respective agent, and before being passed back to the main assistant +that interacts with the user, it is passed through the two reviewer +agents, and we must get both validations before going back to the main +user facing agent. + +For brevity, in the diagram we omit the internal structure of the +agents, which is comparable to the one defined in example 5.1. + +Put name of the agent in the diagram. Show that same agent can be +executed in different places. + +|image5| + +This graph is implemented by the following Agent Spec representation: + +.. collapse:: Agent Spec representation + + .. literalinclude:: ../agentspec_config_examples/flow_calling_agent.json5 + :language: JSON5 + + +(Generated) JSON Language spec +============================== + +We put here the current JSON spec of the Agent Spec language. + +.. collapse:: JSON Schema + + .. literalinclude:: json_spec/agentspec_json_spec_26_3_0.json + :language: json + +Note about serialization of components +-------------------------------------- + +Every ``Component`` is supposed to be serialized with an additional field +called ``component_type``, which defines what type of component is being described. +The value of ``component_type`` should coincide with the name of the specific component's class. + +Examples of JSON serialization for a few common Components follow. + + +.. code-block:: JSON5 + + // VllmConfig serialization + { + "id": "vllm", + "name": "VLLM Model", + "description": null, + "metadata": {}, + "default_generation_parameters": {}, + "url": "http://url.of.my.vllm", + "model_id": "vllm_model_id", + "component_type": "VllmConfig", + "agentspec_version": "25.4.1" + } + + +.. code-block:: JSON5 + + // ServerTool serialization + { + "id": "get_weather_tool", + "name": "get_weather", + "description": "Gets the weather in specified city", + "metadata": {}, + "inputs": [ + { + "title": "city_name", + "type": "string" + } + ], + "outputs": [ + { + "title": "forecast", + "type": "string" + } + ], + "component_type": "ServerTool", + "agentspec_version": "25.4.1" + } + + +.. code-block:: JSON5 + + // Agent serialization + { + "id": "expert_agent_id", + "name": "Adaptive expert agent", + "description": null, + "metadata": {}, + "inputs": [ + { + "title": "domain_of_expertise", + "type": "string" + } + ], + "outputs": [], + "human_in_the_loop": true + "llm_config": { + "id": "llama_model_id", + "name": "Llama 3.1 8B instruct", + "description": null, + "metadata": {}, + "default_generation_parameters": {}, + "url": "url.of.my.llm.deployment:12345", + "model_id": "meta-llama/Meta-Llama-3.1-8B-Instruct", + "component_type": "VllmConfig" + }, + "system_prompt": "You are an expert in {{domain_of_expertise}}. Please help the users with their requests.", + "tools": [], + "component_type": "Agent", + "agentspec_version": "26.3.0" + } + + +Note about references +--------------------- + +To avoid duplicating the same component serialization in a document, we use references. +For this reason, we need two types for each component in the JSON schema specification of Agent Spec. +For example, ``Agent`` becomes a union to specify that it can be replaced by a reference: + +.. code-block:: json + + "Agent": { + "anyOf": [ + { "$component_ref": "#/$defs/ComponentReference" }, + { "$component_ref": "#/$defs/RawAgent" } + ] + } + +The ``ComponentReference`` is the same for every component, and it represents the way component references +are specified according to the Agent Spec language specification. + +.. code-block:: json + + "ComponentReference": { + "type": "object", + "properties": { + "$component_ref": {"type": "string"} + } + } + +``RawAgent`` is the un-changed version with the exception that some reference like ``#/$defs/LlmConfig`` +now are pointing to the type of the schema that can be potentially replaced by a ref + +.. code-block:: json + + "RawAgent": { + "description": "An agent is a component that can do se.... ", + "properties": { } + } + +JSON Example +~~~~~~~~~~~~ + +Here's an example of a Flow's definition in Python, and the respective +representation generated in JSON. We use PyAgentSpec in order to define the Agent Spec's Flow. + +.. collapse:: Python Flow example + + .. literalinclude:: ../code_examples/pyagentspec_example.py + :language: python + :start-after: .. start-code: + :end-before: .. end-code + +.. collapse:: Flow's representation + + .. literalinclude:: ../agentspec_config_examples/pyagentspec_example_config.json + :language: json + +.. _sdk-agent-spec_nightly: + +SDKs for consuming/producing Agent Spec +======================================= + +A SDK for Agent Spec would be developed, to guarantee adherence to the +specification, as well as providing easy to use APIs to +serialize/deserialize Agent Spec representation, or create them from code. + +All SDKs should provide the following: + +- classes to represent components +- APIs to help with serialization/deserialization + +PyAgentSpec represents the implementation of an Agent Spec SDK in python. +The API documentation of PyAgentSpec is available at :doc:`this link <../api/index>`. + + +Serialization/deserialization APIs +---------------------------------- + +The serialization/deserialization would be served by the following APIs + +.. code-block:: python + + class AgentSpecSerializer: + def __init__(self, plugins: Optional[List[ComponentSerializationPlugin]] = None) -> None: + ... + + def to_json(self, component: Component) -> str: + # gets the JSON string representation for the component + pass + + class AgentSpecDeserializer: + def __init__(self, plugins: Optional[List[ComponentDeserializationPlugin]] = None) -> None: + ... + + def from_json(self, json_content: str) -> Component: + # creates a component, given its JSON string representation + pass + +So that it is possible to execute the following code: + +.. code-block:: python + + # Create a Python object Agent Spec flow + node_1 = StartNode(id="NODE_1", name="node_1") + node_2 = LlmNode(id="NODE_2", name="node_2", prompt_template="Hi!") + end_node = EndNode(id="end_node", name="End node") + + control_flow_edges = [ + ControlFlowEdge(id="1to2", name="1->2", from_node=node_1, to_node=node_2), + ControlFlowEdge(id="2toend", name="2->end", from_node=node_2, to_node=end_node), + ] + + flow = Flow( + id="FLOW_1", + name="My Flow", + start_node=node_1, + nodes=[node_1, node_2, end_node], + control_flow_connections=control_flow_edges, + data_flow_connections=[], + ) + + # Serialize it to JSON + serializer = AgentSpecSerializer() + serialized_flow = serializer.to_json(flow) + +and the converse for deserialization. + +The documentation of PyAgentSpec serialization is available at :doc:`this link <../api/serialization>`. + + +.. |image2| image:: ../_static/agentspec_spec_img/standalone_agent.png +.. |image3| image:: ../_static/agentspec_spec_img/standalone_flow.png +.. |image4| image:: ../_static/agentspec_spec_img/agent_calling_flows.png +.. |image5| image:: ../_static/agentspec_spec_img/flow_calling_agent.png +.. |image6| image:: ../_static/agentspec_spec_img/flow_io_example.png +.. |image7| image:: ../_static/agentspec_spec_img/flow_connections.png diff --git a/docs/pyagentspec/source/agentspec/language_spec_nightly.rst b/docs/pyagentspec/source/agentspec/language_spec_nightly.rst index bb28188f..99de2c3a 100644 --- a/docs/pyagentspec/source/agentspec/language_spec_nightly.rst +++ b/docs/pyagentspec/source/agentspec/language_spec_nightly.rst @@ -2452,7 +2452,7 @@ For example, the JSON serialized version of an Agent should look like the follow "url": "my.llm.url" } "tools": [], - "agentspec_version": "26.1.2" + "agentspec_version": "26.4.0" } For release versioning, Agent Spec follows the format YEAR.QUARTER.PATCH. Agent Spec follows a @@ -3261,7 +3261,7 @@ We put here the current JSON spec of the Agent Spec language. .. collapse:: JSON Schema - .. literalinclude:: json_spec/agentspec_json_spec_26_2_0.json + .. literalinclude:: json_spec/agentspec_json_spec_26_4_0.json :language: json Note about serialization of components @@ -3344,7 +3344,7 @@ Examples of JSON serialization for a few common Components follow. "system_prompt": "You are an expert in {{domain_of_expertise}}. Please help the users with their requests.", "tools": [], "component_type": "Agent", - "agentspec_version": "26.1.2" + "agentspec_version": "26.4.0" } diff --git a/docs/pyagentspec/source/changelog.rst b/docs/pyagentspec/source/changelog.rst index ac842b89..60795a13 100644 --- a/docs/pyagentspec/source/changelog.rst +++ b/docs/pyagentspec/source/changelog.rst @@ -4,6 +4,25 @@ Changelog Agent Spec |release| -------------------- +New features +^^^^^^^^^^^^ + +Improvements +^^^^^^^^^^^^ + +* ** Added back the CrewAI adapter** + + The CrewAI adapter has been added back. + +Bug fixes +^^^^^^^^^ + +Breaking Changes +^^^^^^^^^^^^^^^^ + +Agent Spec 26.3.0 +----------------- + Improvements ^^^^^^^^^^^^ @@ -61,6 +80,10 @@ New features Breaking Changes ^^^^^^^^^^^^^^^^ +* **Removed CrewAI adapter** + + The CrewAI adapter and its optional dependencies have been removed from ``pyagentspec`` due to a CVE in a pinned dependency of CrewAI (Chroma DB 1.1.0) at the time of our release. + Agent Spec 26.1.2 ----------------- diff --git a/docs/pyagentspec/source/docs_home.rst b/docs/pyagentspec/source/docs_home.rst index 78a752e6..9a4ee17d 100644 --- a/docs/pyagentspec/source/docs_home.rst +++ b/docs/pyagentspec/source/docs_home.rst @@ -118,6 +118,7 @@ Agent Spec is developed jointly between Oracle Cloud Infrastructure and Oracle L :caption: Adapters :hidden: + Oracle Select AI LangGraph WayFlow CrewAI @@ -125,7 +126,6 @@ Agent Spec is developed jointly between Oracle Cloud Infrastructure and Oracle L Agent Framework OpenAI Agents - .. toctree:: :maxdepth: 1 :caption: Ecosystem diff --git a/docs/pyagentspec/source/oracle_select_ai.rst b/docs/pyagentspec/source/oracle_select_ai.rst new file mode 100644 index 00000000..0e5ddf9c --- /dev/null +++ b/docs/pyagentspec/source/oracle_select_ai.rst @@ -0,0 +1,22 @@ +Oracle Select AI +================ + +Import and Export Select AI Agent Teams +--------------------------------------- + +Oracle Autonomous AI Database supports importing and exporting Select AI agent team +definitions by using the ``DBMS_CLOUD_AI_AGENT.IMPORT_TEAM`` and +``DBMS_CLOUD_AI_AGENT.EXPORT_TEAM`` APIs. You can import agent team definitions from a +JSON specification, an Object Storage file, or a directory file, and export agent teams +as a CLOB or directly to Object Storage or a directory file. These APIs simplify +migration, backup, version control, and sharing of Select AI agent teams across +environments. + +See: + +* `Portable Oracle Select AI Agent Teams: From Development to Production `_ +* `What's New: Import and Export Select AI Agent Teams `_ +* `Example: Import and Export Agent Teams `_ +* `EXPORT_TEAM Function `_ +* `EXPORT_TEAM Procedure `_ +* `IMPORT_TEAM Procedure `_ diff --git a/pyagentspec/constraints/constraints.txt b/pyagentspec/constraints/constraints.txt index ae4d4264..02838652 100644 --- a/pyagentspec/constraints/constraints.txt +++ b/pyagentspec/constraints/constraints.txt @@ -13,9 +13,9 @@ autogen-agentchat==0.7.4 crewai==1.6.1 # LangGraph adapter -langgraph==1.2.0 -langchain==1.3.1 -langchain-core==1.4.0 +langgraph==1.2.4 +langchain==1.3.9 +langchain-core==1.4.6 langchain-openai==1.2.1 langchain-ollama==1.0.1 langchain-oci==0.2.6 @@ -23,7 +23,7 @@ langchain-mcp-adapters==0.2.2 langgraph-swarm==0.1.0 # WayFlow adapter -wayflowcore==26.1.2 +wayflowcore==26.3.0 # AgentFramework adapter agent-framework-core==1.13.0 @@ -35,7 +35,7 @@ libcst==1.8.5 # Evaluation pandas==2.3.3 -oci==2.174.0 +oci==2.184.2 litellm==1.84.0 numpy==2.2.6; python_version<"3.14" numpy==2.4.2; python_version>="3.14" diff --git a/pyagentspec/constraints/constraints_dev.txt b/pyagentspec/constraints/constraints_dev.txt index ae4d4264..02838652 100644 --- a/pyagentspec/constraints/constraints_dev.txt +++ b/pyagentspec/constraints/constraints_dev.txt @@ -13,9 +13,9 @@ autogen-agentchat==0.7.4 crewai==1.6.1 # LangGraph adapter -langgraph==1.2.0 -langchain==1.3.1 -langchain-core==1.4.0 +langgraph==1.2.4 +langchain==1.3.9 +langchain-core==1.4.6 langchain-openai==1.2.1 langchain-ollama==1.0.1 langchain-oci==0.2.6 @@ -23,7 +23,7 @@ langchain-mcp-adapters==0.2.2 langgraph-swarm==0.1.0 # WayFlow adapter -wayflowcore==26.1.2 +wayflowcore==26.3.0 # AgentFramework adapter agent-framework-core==1.13.0 @@ -35,7 +35,7 @@ libcst==1.8.5 # Evaluation pandas==2.3.3 -oci==2.174.0 +oci==2.184.2 litellm==1.84.0 numpy==2.2.6; python_version<"3.14" numpy==2.4.2; python_version>="3.14" diff --git a/pyagentspec/constraints/constraints_v26.3.0.txt b/pyagentspec/constraints/constraints_v26.3.0.txt new file mode 100644 index 00000000..64e5d8fb --- /dev/null +++ b/pyagentspec/constraints/constraints_v26.3.0.txt @@ -0,0 +1,38 @@ +jsonschema==4.23.0 +pydantic==2.12.5 +pyyaml==6.0.3 +httpx==0.28.1 +typing-extensions==4.15.0 +anyio==4.11.0 +# AutoGen adapter +autogen-core==0.7.4 +autogen-ext==0.7.4 +autogen-agentchat==0.7.4 + +# LangGraph adapter +langgraph==1.2.4 +langchain==1.3.9 +langchain-core==1.4.6 +langchain-openai==1.2.1 +langchain-ollama==1.0.1 +langchain-oci==0.2.6 +langchain-mcp-adapters==0.2.2 +langgraph-swarm==0.1.0 + +# WayFlow adapter +wayflowcore==26.3.0 + +# AgentFramework adapter +agent-framework-core==1.13.0 +agent-framework-openai==1.12.0 + +# OpenAI Agents adapter +openai-agents==0.19.4 +libcst==1.8.5 + +# Evaluation +pandas==2.3.3 +oci==2.184.2 +litellm==1.84.0 +numpy==2.2.6; python_version<"3.14" +numpy==2.4.2; python_version>="3.14" diff --git a/pyagentspec/pyproject.toml b/pyagentspec/pyproject.toml index 63e9d7f1..faa2b7e1 100644 --- a/pyagentspec/pyproject.toml +++ b/pyagentspec/pyproject.toml @@ -1,7 +1,7 @@ [build-system] requires = [ - # lower bound 78.1.1 to address CVE-2025-47273 - "setuptools>=78.1.1,<82.0.0", + # Keep setuptools on the patched 83.x release line. + "setuptools>=83.0.0,<90.0.0", "wheel", # setuptools-scm lets us include all necessary files (e.g. .pyx files) in the source # distribution (sdist) without needing a MANIFEST.in file. diff --git a/pyagentspec/requirements-dev-common.txt b/pyagentspec/requirements-dev-common.txt index 419145fe..3d8efdeb 100644 --- a/pyagentspec/requirements-dev-common.txt +++ b/pyagentspec/requirements-dev-common.txt @@ -13,7 +13,7 @@ flake8-copyright==0.2.4 pyflakes==2.5.0 pytest==9.0.3 pytest-asyncio==1.3.0 -setuptools==81.0.0 +setuptools==83.0.0 # stubs for mypy types-PyYAML diff --git a/pyagentspec/setup.py b/pyagentspec/setup.py index 30a4cb36..0a79644e 100644 --- a/pyagentspec/setup.py +++ b/pyagentspec/setup.py @@ -27,9 +27,9 @@ def read(file_name): LANGGRAPH_DEPS = [ # 3rd party dependencies (imported in code) - "langgraph>=1.2.0,<1.3.0", - "langchain-core>=1.4.0,<1.5.0", - "langchain>=1.3.1,<1.4.0", + "langgraph>=1.2.4,<1.3.0", + "langchain-core>=1.4.6,<2.0.0", + "langchain>=1.3.9,<2.0.0", "langchain-openai>=1.2.1,<1.3.0", "langchain-ollama>=1.0.1", "anyio>=4.10.0,<4.12.0", @@ -37,9 +37,10 @@ def read(file_name): "langgraph-swarm>=0.1.0", # 4rth party dependencies "certifi>=2025.1.31", # needed to avoid CVE present in earlier versions - "langgraph-checkpoint>=4.0.1,<5.0.0", # needed to avoid CVE present in earlier versions - "langsmith>=0.8.0,<1.0.0", # needed to avoid CVE present in earlier versions + "langgraph-checkpoint>=4.1.1,<5.0.0", # needed to avoid CVE present in earlier versions + "langsmith>=0.8.18,<1.0.0", # needed to avoid CVE present in earlier versions "urllib3>=2.7.0", # needed to avoid CVE present in earlier versions + "langgraph-sdk>=0.3.15", # needed to avoid CVE present in earlier versions ] LANGGRAPH_FULL_DEPS = LANGGRAPH_DEPS + [ @@ -47,7 +48,7 @@ def read(file_name): "langchain-mcp-adapters>=0.2.2", "langchain-oci>=0.2.6", # 4rth party dependencies - "cryptography>=46.0.7", # needed to avoid CVE present in earlier versions + "cryptography>=50.0.0", # needed to avoid CVE present in earlier versions "pyOpenSSL>=26.0.0,<27.0.0", # needed to avoid CVE present in earlier versions ] @@ -100,6 +101,7 @@ def read(file_name): # 4rth party dependencies "certifi>=2025.1.31; python_version < '3.13'", # needed to avoid CVE present in earlier versions "urllib3>=2.7.0; python_version < '3.13'", # needed to avoid CVE present in earlier versions + "pillow>=12.3.0; python_version < '3.13'", # needed to avoid CVE present in earlier versions ], "openai-agents": [ # 3rd party dependencies (imported in code) @@ -108,7 +110,7 @@ def read(file_name): "httpx>0.28.0", # 4rth party dependencies "certifi>=2025.1.31", # needed to avoid CVE present in earlier versions - "cryptography>=46.0.7", # needed to avoid CVE present in earlier versions + "cryptography>=50.0.0", # needed to avoid CVE present in earlier versions "urllib3>=2.7.0", # needed to avoid CVE present in earlier versions ], "crewai": [ @@ -117,42 +119,40 @@ def read(file_name): "httpx>0.28.0; python_version < '3.14'", # 4rth party dependencies "certifi>=2025.1.31; python_version < '3.14'", # needed to avoid CVE present in earlier versions - "cryptography>=46.0.7; python_version < '3.14'", # needed to avoid CVE present in earlier versions + "cryptography>=50.0.0; python_version < '3.14'", # needed to avoid CVE present in earlier versions # litellm is included to fix CVEs "litellm>=1.84.0,<2.0; python_version < '3.14'", "urllib3>=2.7.0; python_version < '3.14'", # needed to avoid CVE present in earlier versions + "chromadb>1.5.9; python_version < '3.14'", # # needed to avoid CVE present in earlier versions + "uv>=0.11.15; python_version < '3.14'", # needed to avoid CVE present in earlier versions ], "langgraph": LANGGRAPH_DEPS, "langgraph-full": LANGGRAPH_FULL_DEPS, "wayflow": [ # 3rd party dependencies (imported in code) - "wayflowcore>=26.1.2", + "wayflowcore>=26.3.0", # 4rth party dependencies "certifi>=2025.1.31; python_version < '3.14'", # needed to avoid CVE present in earlier versions - "cryptography>=46.0.7; python_version < '3.14'", # needed to avoid CVE present in earlier versions ], "wayflow_oci": [ # 3rd party dependencies (imported in code) - "wayflowcore[oci]>=26.1.2", + "wayflowcore[oci]>=26.3.0", # 4rth party dependencies "certifi>=2025.1.31; python_version < '3.14'", # needed to avoid CVE present in earlier versions - "cryptography>=46.0.7; python_version < '3.14'", # needed to avoid CVE present in earlier versions "pyOpenSSL>=26.0.0,<27.0.0; python_version < '3.14'", # needed to avoid CVE present in earlier versions "urllib3>=2.7.0; python_version < '3.14'", # needed to avoid CVE present in earlier versions ], "wayflow_a2a": [ # 3rd party dependencies (imported in code) - "wayflowcore[a2a]>=26.1.2", + "wayflowcore[a2a]>=26.3.0", # 4rth party dependencies "certifi>=2025.1.31; python_version < '3.14'", # needed to avoid CVE present in earlier versions - "cryptography>=46.0.7; python_version < '3.14'", # needed to avoid CVE present in earlier versions ], "wayflow_datastore": [ # 3rd party dependencies (imported in code) - "wayflowcore[datastore]>=26.1.2", + "wayflowcore[datastore]>=26.3.0", # 4rth party dependencies "certifi>=2025.1.31; python_version < '3.14'", # needed to avoid CVE present in earlier versions - "cryptography>=46.0.7; python_version < '3.14'", # needed to avoid CVE present in earlier versions ], "agent-framework": [ # 3rd party dependencies (imported in code) @@ -161,7 +161,7 @@ def read(file_name): "httpx>0.28.0", # 4rth party dependencies "certifi>=2025.1.31", # needed to avoid CVE present in earlier versions - "cryptography>=46.0.7", # needed to avoid CVE present in earlier versions + "cryptography>=50.0.0", # needed to avoid CVE present in earlier versions "urllib3>=2.7.0", # needed to avoid CVE present in earlier versions ], "evaluation": [ @@ -169,11 +169,11 @@ def read(file_name): "anyio>=4.10.0,<4.12.0", "litellm>=1.84.0,<2.0; python_version < '3.14'", "pandas>=2.3.0,<3.0.0", - "oci>=2.158.2", + "oci>=2.184.2", "numpy>=2.2.6", # 4rth party dependencies "certifi>=2025.1.31", # needed to avoid CVE present in earlier versions - "cryptography>=46.0.7", # needed to avoid CVE present in earlier versions + "cryptography>=50.0.0", # needed to avoid CVE present in earlier versions "pyOpenSSL>=26.0.0,<27.0.0", # needed to avoid CVE present in earlier versions "urllib3>=2.7.0", # needed to avoid CVE present in earlier versions ], diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 069ea273..549e24b2 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -1174,10 +1174,23 @@ def _create_react_agent_with_given_info( ) output_model: Optional[type[BaseModel]] = None state_schema: Optional[Any] = None + response_format: Any = None # Build response (output) model (used for response_format) if outputs: output_model = create_pydantic_model_from_properties("AgentOutputModel", outputs) + # Explicitly use ToolStrategy instead of letting LangChain select a provider + # strategy. OpenAI-compatible models do not necessarily support provider-native + # structured output, and may otherwise return a plain AIMessage without the + # structured_response state entry after a client-tool resume. + from langchain.agents.structured_output import ToolStrategy + + response_format = ToolStrategy(output_model) + system_prompt = ( + f"{system_prompt}\n\n" + "After using the available tools, provide the final result by calling the " + "structured output tool. Do not respond with a plain-text final answer." + ) if inputs: state_schema = _create_agent_state_typed_dict( @@ -1191,7 +1204,7 @@ def _create_react_agent_with_given_info( tools=langgraph_tools, system_prompt=system_prompt, checkpointer=checkpointer, - response_format=output_model, + response_format=response_format, state_schema=state_schema, ) if middleware: diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/tracing.py b/pyagentspec/src/pyagentspec/adapters/langgraph/tracing.py index fb8bff97..04eaf9d9 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/tracing.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/tracing.py @@ -305,7 +305,7 @@ def on_chat_model_start( def on_llm_new_token( self, - token: str, + token: Union[str, List[Union[str, Dict[str, Any]]]], *, chunk: Optional[Union[ChatGenerationChunk, GenerationChunk]] = None, run_id: UUID, @@ -461,7 +461,7 @@ async def on_chat_model_start_async( async def on_llm_new_token_async( self, - token: str, + token: Union[str, List[Union[str, Dict[str, Any]]]], *, chunk: Optional[Union[ChatGenerationChunk, GenerationChunk]] = None, run_id: UUID, diff --git a/pyagentspec/src/pyagentspec/llms/dbmsvectorchainllmconfig.py b/pyagentspec/src/pyagentspec/llms/dbmsvectorchainllmconfig.py index 343f928f..d334481f 100644 --- a/pyagentspec/src/pyagentspec/llms/dbmsvectorchainllmconfig.py +++ b/pyagentspec/src/pyagentspec/llms/dbmsvectorchainllmconfig.py @@ -23,7 +23,7 @@ class DbmsVectorChainLlmConfig(LlmConfig): """Configure an LLM executed by Oracle Database through DBMS_VECTOR_CHAIN.""" min_agentspec_version: SkipJsonSchema[AgentSpecVersionEnum] = Field( - default=AgentSpecVersionEnum.v26_2_0, + default=AgentSpecVersionEnum.v26_3_0, init=False, exclude=True, ) diff --git a/pyagentspec/src/pyagentspec/mcp/tools.py b/pyagentspec/src/pyagentspec/mcp/tools.py index 3fee83d3..a6efd165 100644 --- a/pyagentspec/src/pyagentspec/mcp/tools.py +++ b/pyagentspec/src/pyagentspec/mcp/tools.py @@ -38,14 +38,14 @@ def _versioned_model_fields_to_exclude( self, agentspec_version: AgentSpecVersionEnum ) -> set[str]: fields_to_exclude = super()._versioned_model_fields_to_exclude(agentspec_version) - if agentspec_version < AgentSpecVersionEnum.v26_2_0: + if agentspec_version < AgentSpecVersionEnum.v26_3_0: fields_to_exclude.add("retry_policy") return fields_to_exclude def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: min_version = super()._infer_min_agentspec_version_from_configuration() if self.retry_policy is not None: - min_version = max(min_version, AgentSpecVersionEnum.v26_2_0) + min_version = max(min_version, AgentSpecVersionEnum.v26_3_0) return min_version @@ -107,12 +107,12 @@ def _versioned_model_fields_to_exclude( self, agentspec_version: AgentSpecVersionEnum ) -> set[str]: fields_to_exclude = super()._versioned_model_fields_to_exclude(agentspec_version) - if agentspec_version < AgentSpecVersionEnum.v26_2_0: + if agentspec_version < AgentSpecVersionEnum.v26_3_0: fields_to_exclude.add("retry_policy") return fields_to_exclude def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: min_version = super()._infer_min_agentspec_version_from_configuration() if self.retry_policy is not None: - min_version = max(min_version, AgentSpecVersionEnum.v26_2_0) + min_version = max(min_version, AgentSpecVersionEnum.v26_3_0) return min_version diff --git a/pyagentspec/src/pyagentspec/versioning.py b/pyagentspec/src/pyagentspec/versioning.py index d0dacda5..fa465793 100644 --- a/pyagentspec/src/pyagentspec/versioning.py +++ b/pyagentspec/src/pyagentspec/versioning.py @@ -37,9 +37,10 @@ class AgentSpecVersionEnum(Enum): v25_4_2 = "25.4.2" v26_1_0 = "26.1.0" v26_1_2 = "26.1.2" - v26_2_0 = "26.2.0" - current_version = "26.2.0" - latest_supported_version = "26.2.0" + v26_3_0 = "26.3.0" + v26_4_0 = "26.4.0" + current_version = "26.4.0" + latest_supported_version = "26.4.0" def __lt__(self, other: "AgentSpecVersionEnum") -> bool: return _version_lt(self.value, other.value) diff --git a/pyagentspec/tests/serialization/test_mcp_tools.py b/pyagentspec/tests/serialization/test_mcp_tools.py index f49072a5..8a494e36 100644 --- a/pyagentspec/tests/serialization/test_mcp_tools.py +++ b/pyagentspec/tests/serialization/test_mcp_tools.py @@ -138,7 +138,7 @@ def test_mcp_tool_with_retry_policy_can_be_serialized_then_deserialized() -> Non dumped_tool = AgentSpecSerializer().to_dict(mcp_tool) loaded_tool = AgentSpecDeserializer().from_dict(dumped_tool) - assert dumped_tool["agentspec_version"] == AgentSpecVersionEnum.v26_2_0.value + assert dumped_tool["agentspec_version"] == AgentSpecVersionEnum.v26_3_0.value assert dumped_tool["retry_policy"]["max_attempts"] == 3 assert AgentSpecSerializer().to_dict(loaded_tool) == dumped_tool @@ -159,7 +159,7 @@ def test_mcp_toolbox_with_retry_policy_can_be_serialized_then_deserialized() -> dumped_toolbox = AgentSpecSerializer().to_dict(mcp_toolbox) loaded_toolbox = AgentSpecDeserializer().from_dict(dumped_toolbox) - assert dumped_toolbox["agentspec_version"] == AgentSpecVersionEnum.v26_2_0.value + assert dumped_toolbox["agentspec_version"] == AgentSpecVersionEnum.v26_3_0.value assert dumped_toolbox["retry_policy"]["max_attempts"] == 4 assert AgentSpecSerializer().to_dict(loaded_toolbox) == dumped_toolbox diff --git a/pyagentspec/tests/serialization/test_serialization.py b/pyagentspec/tests/serialization/test_serialization.py index f65d7af5..29d0a7f5 100644 --- a/pyagentspec/tests/serialization/test_serialization.py +++ b/pyagentspec/tests/serialization/test_serialization.py @@ -323,15 +323,15 @@ def test_deserialization_and_serialization_preserves_min_version(simplest_flow: ) -def test_deserialization_accepts_v26_2_0_configs(simplest_flow: Flow) -> None: +def test_deserialization_accepts_v26_3_0_configs(simplest_flow: Flow) -> None: serialized_flow = AgentSpecSerializer().to_dict( - simplest_flow, agentspec_version=AgentSpecVersionEnum.v26_2_0 + simplest_flow, agentspec_version=AgentSpecVersionEnum.v26_3_0 ) deserialized_flow = AgentSpecDeserializer().from_dict(serialized_flow) - assert serialized_flow[AGENTSPEC_VERSION_FIELD_NAME] == AgentSpecVersionEnum.v26_2_0.value - assert deserialized_flow.min_agentspec_version == AgentSpecVersionEnum.v26_2_0 + assert serialized_flow[AGENTSPEC_VERSION_FIELD_NAME] == AgentSpecVersionEnum.v26_3_0.value + assert deserialized_flow.min_agentspec_version == AgentSpecVersionEnum.v26_3_0 def test_dict_serialization_and_deserialization(simplest_flow: Flow) -> None: diff --git a/pyagentspec/tests/test_versioning.py b/pyagentspec/tests/test_versioning.py index 45a3aa9a..0d680310 100644 --- a/pyagentspec/tests/test_versioning.py +++ b/pyagentspec/tests/test_versioning.py @@ -30,10 +30,10 @@ from .serialization.conftest import simplest_flow # noqa: F401 -def test_current_version_is_26_2_0() -> None: - assert AgentSpecVersionEnum.current_version == AgentSpecVersionEnum.v26_2_0 - assert AgentSpecVersionEnum.latest_supported_version == AgentSpecVersionEnum.v26_2_0 - assert AgentSpecVersionEnum("26.2.0") == AgentSpecVersionEnum.v26_2_0 +def test_current_version_is_26_4_0() -> None: + assert AgentSpecVersionEnum.current_version == AgentSpecVersionEnum.v26_4_0 + assert AgentSpecVersionEnum.latest_supported_version == AgentSpecVersionEnum.v26_4_0 + assert AgentSpecVersionEnum("26.4.0") == AgentSpecVersionEnum.v26_4_0 def test_flow_exports_with_agentspec_version(simplest_flow: Flow) -> None: From fdfa0570a07e42a87e911e0776e19b833be3e046 Mon Sep 17 00:00:00 2001 From: Salah Date: Sat, 25 Jul 2026 15:22:50 +0400 Subject: [PATCH 04/17] feat(adapters/langgraph): compile ManagerWorkers into a hierarchical graph --- .../adapters/langgraph/_langgraphconverter.py | 161 ++ .../adapters/langgraph/_managerworkers.py | 661 ++++++++ .../adapters/langgraph/_node_execution.py | 60 +- pyagentspec/src/pyagentspec/managerworkers.py | 19 + .../flows/test_managerworkers_node.py | 157 ++ .../adapters/langgraph/test_managerworkers.py | 1343 +++++++++++++++++ 6 files changed, 2400 insertions(+), 1 deletion(-) create mode 100644 pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py create mode 100644 pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py create mode 100644 pyagentspec/tests/adapters/langgraph/test_managerworkers.py diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 549e24b2..4a09ce1b 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -37,6 +37,16 @@ _build_type_from_schema, create_pydantic_model_from_properties, ) +from pyagentspec.adapters.langgraph._managerworkers import ( + _MANAGER_NODE_KEY, + _append_workers_roster, + _make_worker_delegation_tool, + _patch_hide_delegation_in_astream_events, + _patch_with_manager_workers_execution_span, + _route_manager_to_worker_or_end, + _safe_node_name, + _wrap_worker_for_subgraph, +) from pyagentspec.adapters.langgraph._node_execution import ( NodeExecutor, extract_outputs_from_invoke_result, @@ -103,6 +113,7 @@ ) from pyagentspec.llms.openaiconfig import OpenAiConfig from pyagentspec.llms.vllmconfig import VllmConfig +from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers from pyagentspec.mcp.clienttransport import ClientTransport as AgentSpecClientTransport from pyagentspec.mcp.clienttransport import SSEmTLSTransport as AgentSpecSSEmTLSTransport from pyagentspec.mcp.clienttransport import SSETransport as AgentSpecSSETransport @@ -274,6 +285,15 @@ def _convert( config=config, middleware=middleware, ) + elif isinstance(agentspec_component, AgentSpecManagerWorkers): + return self._manager_workers_convert_to_langgraph( + agentspec_component, + tool_registry=tool_registry, + converted_components=converted_components, + checkpointer=checkpointer, + config=config, + middleware=middleware, + ) elif isinstance(agentspec_component, AgentSpecLlmConfig): return self._llm_convert_to_langgraph(agentspec_component, config=config) elif isinstance(agentspec_component, AgentSpecClientTransport): @@ -1123,6 +1143,147 @@ def _swarm_convert_to_langgraph( default_active_agent=agentspec_component.first_agent.name, ).compile(name=agentspec_component.name, checkpointer=checkpointer) + def _manager_workers_convert_to_langgraph( + self, + mw: AgentSpecManagerWorkers, + tool_registry: Dict[str, "LangGraphTool"], + converted_components: Dict[str, Any], + checkpointer: Optional[Checkpointer], + config: RunnableConfig, + middleware: List[Any], + ) -> CompiledStateGraph[Any, Any, Any]: + """Compile a ``ManagerWorkers`` into a hierarchical LangGraph. + + Topology:: + + ┌─ delegate_to_w1 ─→ worker_1 ─┐ + START → manager ┤ ├→ manager (loop) + └─ delegate_to_w2 ─→ worker_2 ─┘ + │ + └─ no tool_call ─→ END + + Each worker is recursively converted into a ``CompiledStateGraph`` + and wired in as a *subgraph node*, so ``astream_events`` exposes + the parent/child boundary (``subgraph=True``) for tracing and SSE + streaming. The manager is a react-agent given one synthetic + ``delegate_to_`` tool per worker; the parent graph's + conditional edge inspects the manager's last AIMessage to choose + the next node, then the worker node runs in an isolated message + context and emits a ``ToolMessage`` matched to the pending + delegation tool-call id. Recursive ``ManagerWorkers`` (workers + that are themselves ``ManagerWorkers``) compose for free through + ``self.convert(...)``. + """ + if not isinstance(mw.group_manager, AgentSpecAgent): + # Pyagentspec allows any AgenticComponent as group_manager, + # but the manager has to *decide* which worker to delegate to, + # which means it needs a chat-LLM that emits tool_calls. Today + # only Agent (and SpecializedAgent, a subclass) does that — a + # Flow / Swarm / nested ManagerWorkers as the group_manager + # doesn't have a "tool-call to delegate" output shape we can + # route on. + raise NotImplementedError( + f"ManagerWorkers.group_manager must be an Agent for LangGraph " + f"conversion; got {type(mw.group_manager).__name__}." + ) + + worker_node_names: List[str] = [ + _safe_node_name(worker.name, fallback_id=worker.id) for worker in mw.workers + ] + if len(set(worker_node_names)) != len(worker_node_names): + raise ValueError( + "ManagerWorkers worker names collide after normalization: " + f"{worker_node_names}. Give each worker a unique name." + ) + + # 1. Recursively compile each worker as its own CompiledStateGraph. + worker_graphs: Dict[str, CompiledStateGraph[Any, Any, Any]] = {} + for worker, node_name in zip(mw.workers, worker_node_names): + worker_graphs[node_name] = self.convert( + worker, + tool_registry=tool_registry, + converted_components=converted_components, + checkpointer=checkpointer, + config=config, + middleware=middleware, + ) + + # 2. Render the workers roster into the manager's system prompt + # so the LLM knows which delegation tool maps to which worker. + manager_agent = mw.group_manager + rendered_prompt = _append_workers_roster( + manager_agent.system_prompt, + [ + (node_name, worker.description or "") + for worker, node_name in zip(mw.workers, worker_node_names) + ], + ) + + # 3. Synthesize one delegation tool per worker. The tool body is a + # placeholder — the parent graph intercepts the manager's tool + # call before it executes and routes to the worker node. + delegation_tools: List[Any] = [ + _make_worker_delegation_tool(node_name) for node_name in worker_node_names + ] + + # 4. Compile the manager as a react-agent with the delegation tools. + manager_graph = self._create_react_agent_with_given_info( + name=manager_agent.name, + system_prompt=rendered_prompt, + agent=manager_agent, + llm_config=manager_agent.llm_config, + tools=manager_agent.tools, + toolboxes=manager_agent.toolboxes, + inputs=manager_agent.inputs or [], + outputs=manager_agent.outputs or [], + tool_registry=tool_registry, + converted_components=converted_components, + checkpointer=checkpointer, + config=config, + middleware=middleware, + additional_langgraph_tools=delegation_tools, + ) + + # 5. Compose the parent StateGraph. The manager and every worker + # are CompiledStateGraphs added as subgraph nodes; LangGraph's + # streaming surfaces them with ``subgraph=True``. + from langgraph.graph import MessagesState # local: optional dep + + manager_node_key = _MANAGER_NODE_KEY + builder = StateGraph(MessagesState) + builder.add_node(manager_node_key, manager_graph) + for node_name, worker_graph in worker_graphs.items(): + builder.add_node( + node_name, + _wrap_worker_for_subgraph(worker_graph, node_name), + ) + + # Path-map covers delegate-to-worker and the END branch so langgraph + # can statically validate the routing. + routing_path_map: Dict[str, str] = {node_name: node_name for node_name in worker_node_names} + routing_path_map[langgraph_graph.END] = langgraph_graph.END + + builder.add_edge(langgraph_graph.START, manager_node_key) + builder.add_conditional_edges( + manager_node_key, + _route_manager_to_worker_or_end, + routing_path_map, + ) + for node_name in worker_node_names: + builder.add_edge(node_name, manager_node_key) + + compiled_graph = builder.compile(checkpointer=checkpointer, name=mw.name) + + # 6. Tracing — wrap stream/astream so ManagerWorkersExecutionSpan + # surrounds each run. Mirrors the patches applied to Agent and + # Flow graphs above. + _patch_with_manager_workers_execution_span(compiled_graph, mw) + # Hide the delegate_to_ routing protocol from the + # astream_events view (tool calls, their tool lifecycle events, and + # the worker's synthetic reply ToolMessage) without touching state. + _patch_hide_delegation_in_astream_events(compiled_graph) + return compiled_graph + def _create_react_agent_with_given_info( self, *, diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py new file mode 100644 index 00000000..d63d24db --- /dev/null +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py @@ -0,0 +1,661 @@ +# Copyright © 2025, 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +"""ManagerWorkers LangGraph compilation helpers. + +Module-level building blocks for compiling a ``ManagerWorkers`` into LangGraph. +The ``AgentSpecToLangGraphConverter`` method +``_manager_workers_convert_to_langgraph`` orchestrates these helpers; the +helpers themselves are pure functions with no dependency on the converter, +which is why they live here rather than bloating the converter module. +""" + +import logging +import re +from typing import Any, AsyncGenerator, Dict, Generator, List, Optional, Tuple + +from pyagentspec.adapters.langgraph._types import CompiledStateGraph, langgraph_graph +from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers +from pyagentspec.tracing.events import ( + ManagerWorkersExecutionEnd as AgentSpecManagerWorkersExecutionEnd, +) +from pyagentspec.tracing.events import ( + ManagerWorkersExecutionStart as AgentSpecManagerWorkersExecutionStart, +) +from pyagentspec.tracing.spans import ( + ManagerWorkersExecutionSpan as AgentSpecManagerWorkersExecutionSpan, +) + +# ─── ManagerWorkers helpers ────────────────────────────────────────────────── + +# Node key for the manager subgraph in the ManagerWorkers parent StateGraph. +# Chosen so it cannot collide with a normalized worker node name (which is +# always lowercase + [a-z0-9_]). +_MANAGER_NODE_KEY = "__manager__" + +# Prefix the manager's LLM uses to address a delegation tool. The suffix is +# the normalized worker node name. +_DELEGATE_TOOL_PREFIX = "delegate_to_" + +# Keys carried on the per-delegation ``Send`` payload from the manager's +# routing edge to a worker node, so a worker run knows which task it was +# given and which ``tool_call_id`` its reply ToolMessage must answer. This +# is what lets one manager turn delegate to several workers at once: each +# delegation routes as its own ``Send`` and is answered independently. +_DELEGATE_TASK_KEY = "__delegate_task__" +_DELEGATE_CALL_ID_KEY = "__delegate_tool_call_id__" + +# Collapses any run of whitespace to a single space so multi-line worker +# descriptions stay on one roster line. +_WHITESPACE_RE = re.compile(r"\s+") + + +def _normalize_identifier(s: str) -> str: + """Lowercase, collapse non-alphanumerics to underscores, strip surrounding + underscores. The single source of truth for turning a spec name into an + ASCII identifier, so a worker node name and the ``delegate_to_`` + tool name addressing it always agree.""" + return re.sub(r"[^a-z0-9]+", "_", (s or "").lower()).strip("_") + + +def _safe_node_name(name: str, fallback_id: str) -> str: + """Normalize a worker name into a LangGraph node identifier. + + LangGraph node names must be hashable strings; in practice we want + ASCII-friendly identifiers that also work as Python attribute-ish + names (the LLM is going to see ``delegate_to_`` as a tool + name and needs to be able to emit it reliably). We normalize via + :func:`_normalize_identifier`, and fall back to the (component) id — + normalized the same way — if the name yields an empty string. Falling + through both transforms keeps node names internally consistent + regardless of which input wins. + """ + return _normalize_identifier(name) or _normalize_identifier(fallback_id) or "worker" + + +def _tc_get(tool_call: Any, key: str) -> Any: + """Read ``key`` off a tool call that may be a dict or a pydantic-style + object (langchain emits either depending on the message source).""" + if isinstance(tool_call, dict): + return tool_call.get(key) + return getattr(tool_call, key, None) + + +def _messages_of(state: Any) -> List[Any]: + """Read the ``messages`` list off a state that may be a dict or an + attribute-bearing object (langgraph injects either into a tool).""" + if isinstance(state, dict): + return list(state.get("messages") or []) + return list(getattr(state, "messages", []) or []) + + +def _surface_to_parent_command(state: Any) -> Any: + """The delegation tool's body: break out of the manager's react loop and + project the subgraph's messages — including the AIMessage carrying the + triggering tool call — onto the PARENT state, carrying **no** ``goto`` + (routing is the parent graph's job). The ``add_messages`` reducer dedupes + by id, so re-surfacing existing messages is a no-op. Modelled on + ``langgraph_swarm.create_handoff_tool``.""" + from langgraph.types import Command + + return Command(graph=Command.PARENT, update={"messages": _messages_of(state)}) + + +def _append_workers_roster( + system_prompt: str, + entries: List[Tuple[str, str]], +) -> str: + """Prepend the manager's system prompt with an ``Available workers:`` + roster block listing ``- : `` per worker. + + Each description has whitespace flattened so multi-line descriptions + don't corrupt the one-line-per-worker block shape that the LLM relies + on for routing. + """ + if not entries: + return system_prompt + lines = [ + f"- {name}: {_WHITESPACE_RE.sub(' ', description).strip()}" for name, description in entries + ] + roster = "Available workers:\n" + "\n".join(lines) + return f"{system_prompt}\n\n{roster}" if system_prompt else roster + + +def _make_worker_delegation_tool(worker_node_name: str) -> Any: + """Build the ``delegate_to_`` tool the manager's LLM emits to route to a + worker. The body carries **no** ``goto`` — routing fans out one ``Send`` per + delegation (:func:`_route_manager_to_worker_or_end`); a ``goto`` here would + collapse multiple same-turn delegations into one parent Command, leaving the other + ``tool_call_id``s unanswered. + """ + from typing import Annotated + + from langchain_core.tools import InjectedToolCallId, tool + from langgraph.prebuilt import InjectedState + from langgraph.types import Command + + tool_name = f"{_DELEGATE_TOOL_PREFIX}{worker_node_name}" + + @tool(tool_name) + def _delegate( + task: str, + state: Annotated[Any, InjectedState], + tool_call_id: Annotated[str, InjectedToolCallId], + ) -> Command: + """Delegate a task to the named worker and wait for its reply. + + ``task`` is the natural-language instruction the worker should + execute. The worker runs in its own isolated message context; + only this ``task`` is forwarded as the worker's first message. + """ + del task, tool_call_id # recovered from the surfaced AIMessage by the routing edge + return _surface_to_parent_command(state) + + _delegate.description = ( + f"Delegate a task to the {worker_node_name} worker and receive " + f"its response. Use this when the task fits the worker's " + f"described capability." + ) + return _delegate + + +def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: + """Inspect the manager's last AIMessage and route the parent graph: one + ``Send`` per ``delegate_to_`` tool call, or ``END`` when the manager + emitted none. + + A single manager turn may emit several ``delegate_to_`` calls; each gets its + own ``Send`` carrying the ``task`` + ``tool_call_id``, so every call is answered + independently (an unanswered delegation breaks the manager's next-turn + tool-call/result sequence). Multiple ``Send``s to one worker run independently; plain + tool calls already ran inside the manager's react loop. + """ + from langgraph.types import Send + + messages = state.get("messages") or [] + if not messages: + return langgraph_graph.END + last = messages[-1] + tool_calls = getattr(last, "tool_calls", None) or [] + sends = [] + for tc in tool_calls: + name = _tc_get(tc, "name") + if _is_delegate_name(name): + args = _tc_get(tc, "args") or {} + sends.append( + Send( + name[len(_DELEGATE_TOOL_PREFIX) :], + { + _DELEGATE_TASK_KEY: args.get("task") or "", + _DELEGATE_CALL_ID_KEY: _tc_get(tc, "id") or "", + }, + ) + ) + return sends or langgraph_graph.END + + +def _wrap_worker_for_subgraph( + worker_graph: CompiledStateGraph[Any, Any, Any], + worker_node_name: str, +) -> Any: + """Wrap a worker subgraph so it runs with an isolated ``messages`` + context (the delegation task only) and its final reply comes back as + a ToolMessage matched to the manager's pending delegation tool-call. + + This is what makes a ManagerWorkers parent graph hierarchical rather + than a shared-state Swarm: workers do NOT see each other's messages, + and only one message — the manager's chosen task — is forwarded to + each worker run. The worker's last AIMessage content is captured as + the ToolMessage content so the manager's react-agent loop sees a + well-formed tool response on the next turn. + + Returns a ``RunnableLambda`` exposing both sync (``func``) and async + (``afunc``) entrypoints — LangGraph picks the right one based on + whether the parent graph is invoked via ``invoke`` or ``ainvoke``. + """ + from langchain_core.messages import HumanMessage, ToolMessage + + from pyagentspec.adapters.langgraph._types import RunnableLambda + + delegate_tool_name = f"{_DELEGATE_TOOL_PREFIX}{worker_node_name}" + + def _extract_pending(state: Dict[str, Any]) -> Tuple[str, str]: + # Fan-out path: the routing edge's ``Send`` payload carries this + # delegation's task and its originating tool_call_id directly, so a + # single manager turn can delegate to this worker more than once + # without the runs colliding on a shared "first pending call". + if isinstance(state, dict) and _DELEGATE_CALL_ID_KEY in state: + return ( + state.get(_DELEGATE_TASK_KEY) or "", + state.get(_DELEGATE_CALL_ID_KEY) or "", + ) + # Direct-edge path (a worker wired in without Send): recover task + + # id from the manager's last AIMessage. Only the first matching call + # is recoverable this way, which is why routing prefers Send. + messages = state.get("messages") or [] + if not messages: + raise RuntimeError(f"Worker '{worker_node_name}' was invoked with empty manager state.") + last_ai = messages[-1] + tool_calls = getattr(last_ai, "tool_calls", None) or [] + pending_call = next( + (tc for tc in tool_calls if _tc_get(tc, "name") == delegate_tool_name), + None, + ) + if pending_call is None: + raise RuntimeError( + f"Worker '{worker_node_name}' was routed to but the manager's " + f"last message has no '{delegate_tool_name}' tool call." + ) + args = _tc_get(pending_call, "args") or {} + call_id = _tc_get(pending_call, "id") or "" + return args.get("task") or "", call_id + + def _tool_message_from(reply: str, call_id: str) -> Dict[str, Any]: + return {"messages": [ToolMessage(content=reply, tool_call_id=call_id)]} + + def _worker_input(task: str) -> Dict[str, Any]: + # Pass NO explicit config so the worker inherits this node's ambient run config: + # its ``checkpoint_ns`` (``:``) is what streams the worker's + # token events under the worker node, and the distinct per-superstep namespace + # keeps repeated delegations isolated without a fresh thread_id. + return {"messages": [HumanMessage(content=task)]} + + def _last_message_content(result: Any) -> str: + messages = result.get("messages") if isinstance(result, dict) else None + if not messages: + return "" + return getattr(messages[-1], "content", "") or "" + + def _run_sync(state: Dict[str, Any]) -> Dict[str, Any]: + task, call_id = _extract_pending(state) + result = worker_graph.invoke(_worker_input(task)) + return _tool_message_from(_last_message_content(result), call_id) + + async def _run_async(state: Dict[str, Any]) -> Dict[str, Any]: + task, call_id = _extract_pending(state) + result = await worker_graph.ainvoke(_worker_input(task)) + return _tool_message_from(_last_message_content(result), call_id) + + return RunnableLambda( + func=_run_sync, + afunc=_run_async, + name=f"worker:{worker_node_name}", + ) + + +# ─── ManagerWorkers: hide the delegation protocol from astream_events ───────── + + +def _is_delegate_name(name: Any) -> bool: + """True if ``name`` is one of the synthetic ``delegate_to_`` + tool names the manager emits to route to a worker.""" + return isinstance(name, str) and name.startswith(_DELEGATE_TOOL_PREFIX) + + +def _is_delegate_tool_message(msg: Any, delegate_call_ids: "set") -> bool: + """True if ``msg`` is the worker's synthetic reply ToolMessage — i.e. a + ToolMessage answering a (now-hidden) delegation tool-call id.""" + return ( + getattr(msg, "type", None) == "tool" + and getattr(msg, "tool_call_id", None) in delegate_call_ids + ) + + +def _scrubbed_ai_message( + msg: Any, + delegate_indices: "set", + delegate_call_ids: "set", +) -> Tuple[Optional[Any], bool]: + """Return ``(scrubbed_copy_or_None, is_empty)`` for an AIMessage(Chunk), + removing every ``delegate_to_`` tool call. + + ``scrubbed_copy_or_None`` is ``None`` when the message carried no + delegation artifact (the caller emits it unchanged). ``is_empty`` is + ``True`` when, after removal, nothing renderable remains (no content and + no other tool calls) — the caller drops the event. + + Never mutates ``msg``: the same object lives in the graph's message + state, where the manager react loop relies on the delegation + tool-call / tool-result pair staying intact. ``delegate_indices`` tracks + streamed tool-call positions so argument-continuation chunks (which + carry no ``name``) are stripped too; ``delegate_call_ids`` collects the + call ids so the worker's matching ToolMessage can be dropped later. + """ + changed = False + + # Provider-native streamed tool calls (e.g. OpenAI) ride along in + # ``additional_kwargs['tool_calls']`` and stream by index with the name + # only on the opening delta — match by name or by a known delegate index. + additional = getattr(msg, "additional_kwargs", None) or {} + new_additional = additional + raw_calls = additional.get("tool_calls") + if raw_calls: + kept_raw = [] + for tc in raw_calls: + index = tc.get("index") if isinstance(tc, dict) else None + function = (tc.get("function") or {}) if isinstance(tc, dict) else {} + fname = function.get("name") + if _is_delegate_name(fname) or (not fname and index in delegate_indices): + if index is not None: + delegate_indices.add(index) + if isinstance(tc, dict) and tc.get("id"): + delegate_call_ids.add(tc["id"]) + changed = True + else: + kept_raw.append(tc) + if len(kept_raw) != len(raw_calls): + new_additional = dict(additional) + if kept_raw: + new_additional["tool_calls"] = kept_raw + else: + new_additional.pop("tool_calls", None) + + # AIMessageChunk: ``tool_call_chunks`` is the source of truth and + # ``tool_calls`` / ``invalid_tool_calls`` are *derived* from it, so we + # rebuild the chunk (which re-runs that derivation) rather than copying — + # otherwise a stale derived ``tool_calls`` entry survives the strip. + if hasattr(msg, "tool_call_chunks"): + kept_chunks = [] + for chunk in getattr(msg, "tool_call_chunks", None) or []: + cname, cindex = chunk.get("name"), chunk.get("index") + if _is_delegate_name(cname) or (cname is None and cindex in delegate_indices): + if cindex is not None: + delegate_indices.add(cindex) + if chunk.get("id"): + delegate_call_ids.add(chunk["id"]) + changed = True + else: + kept_chunks.append(chunk) + if not changed: + return None, False + scrubbed = type(msg)( + content=msg.content, + additional_kwargs=new_additional, + response_metadata=getattr(msg, "response_metadata", None) or {}, + tool_call_chunks=kept_chunks, + id=getattr(msg, "id", None), + name=getattr(msg, "name", None), + usage_metadata=getattr(msg, "usage_metadata", None), + ) + has_remaining = ( + bool(scrubbed.content) + or bool(scrubbed.tool_call_chunks) + or bool((scrubbed.additional_kwargs or {}).get("tool_calls")) + ) + return scrubbed, not has_remaining + + # Full AIMessage: ``tool_calls`` is the source of truth. + update: Dict[str, Any] = {} + for attr in ("tool_calls", "invalid_tool_calls"): + items = getattr(msg, attr, None) + if items: + kept = [] + for tc in items: + if _is_delegate_name(_tc_get(tc, "name")): + cid = _tc_get(tc, "id") + if cid: + delegate_call_ids.add(cid) + changed = True + else: + kept.append(tc) + if len(kept) != len(items): + update[attr] = kept + if new_additional is not additional: + update["additional_kwargs"] = new_additional + if not changed: + return None, False + + scrubbed = msg.model_copy(update=update) + has_remaining = ( + bool(getattr(scrubbed, "content", None)) + or bool(getattr(scrubbed, "tool_calls", None)) + or bool((getattr(scrubbed, "additional_kwargs", None) or {}).get("tool_calls")) + ) + return scrubbed, not has_remaining + + +def _scrub_payload_messages( + payload: Any, + delegate_call_ids: "set", +) -> Tuple[Any, bool]: + """For a node / state payload shaped ``{"messages": [...]}``, remove the + whole delegation protocol so it never surfaces in a consumer-facing + message snapshot: drop the worker's synthetic reply ToolMessage(s) AND + strip the synthetic ``delegate_to_`` tool calls off the manager's + AIMessage(s), dropping an AIMessage that is left empty (a pure delegation + turn). + + Stripping the tool calls — not just the ToolMessages — is what keeps a + downstream message snapshot consistent. A consumer that builds its + message history from an ``on_chain_end`` state payload (e.g. the AG-UI + MESSAGES_SNAPSHOT) reads ``tool_calls`` straight off the AIMessage; if we + dropped only the reply ToolMessages, the snapshot would carry delegate + tool calls whose results are gone, which renders as a "tool call with no + result". Messages are walked in order, so a delegation AIMessage records + its call ids before its reply ToolMessages are tested for removal. + + Returns ``(payload, drop_event)``: ``payload`` is a new dict when + anything changed (the original is never mutated), otherwise the object + passed in. ``drop_event`` is ``True`` when scrubbing empties the + ``messages`` list, so the caller drops the whole event. + """ + if not isinstance(payload, dict): + return payload, False + messages = payload.get("messages") + if not isinstance(messages, list) or not messages: + return payload, False + kept: List[Any] = [] + changed = False + for m in messages: + # The worker's reply ToolMessage — pure delegation plumbing. + if _is_delegate_tool_message(m, delegate_call_ids): + changed = True + continue + # An AIMessage may carry delegate tool calls; strip them and drop the + # message if nothing renderable remains. Non-delegation messages + # (real tool calls/results, plain content) are left untouched. + if hasattr(m, "tool_calls"): + scrubbed, is_empty = _scrubbed_ai_message(m, set(), delegate_call_ids) + if scrubbed is not None: + changed = True + if not is_empty: + kept.append(scrubbed) + continue + kept.append(m) + if not changed: + return payload, False + new_payload = dict(payload) + new_payload["messages"] = kept + return new_payload, len(kept) == 0 + + +class _DelegationEventFilter: + """Stateful scrubber for a single ``astream_events`` stream. + + Removes the synthetic ``delegate_to_`` routing protocol — the + delegation tool calls, their ``on_tool_*`` lifecycle events, and the + worker's matching reply ToolMessage — from the consumer-facing event + view. The graph's message state is never touched, so the manager react + loop still sees its well-formed tool-call / tool-result exchange. + """ + + def __init__(self) -> None: + # Streamed tool-call positions per chat-model run that belong to a + # delegation call, so argument-continuation chunks (name=None) are + # stripped along with the opening chunk. + self._delegate_indices_by_run: Dict[str, "set"] = {} + # Delegate tool-call ids seen so far, so the worker's reply + # ToolMessage can be dropped when it surfaces downstream. + self._delegate_call_ids: "set" = set() + + def scrub(self, event: Dict[str, Any]) -> Optional[Dict[str, Any]]: + etype = event.get("event") + name = event.get("name", "") + + # 1. Drop the tool lifecycle events for the delegation tools. + if etype in ("on_tool_start", "on_tool_end", "on_tool_error") and _is_delegate_name(name): + return None + + data = event.get("data") or {} + + # 2. Strip delegate tool calls from streamed / final manager AIMessages. + if etype in ("on_chat_model_stream", "on_chat_model_end"): + key = "chunk" if etype == "on_chat_model_stream" else "output" + msg = data.get(key) + if msg is not None and hasattr(msg, "tool_calls"): + run_id = event.get("run_id", "") + indices = self._delegate_indices_by_run.setdefault(run_id, set()) + scrubbed, is_empty = _scrubbed_ai_message(msg, indices, self._delegate_call_ids) + if scrubbed is not None: + # A streamed chunk that became empty is pure delegation + # plumbing — drop it. A final ``on_chat_model_end`` is kept + # (scrubbed) so consumers still get a turn-end marker. + if is_empty and etype == "on_chat_model_stream": + return None + new_data = dict(data) + new_data[key] = scrubbed + new_event = dict(event) + new_event["data"] = new_data + return new_event + return event + + # 3. Drop the worker's synthetic reply ToolMessage wherever it + # surfaces in a node payload. + new_data: Optional[Dict[str, Any]] = None + should_drop = False + for key in ("chunk", "output", "input"): + if key in data: + scrubbed_payload, drop_event = _scrub_payload_messages( + data[key], self._delegate_call_ids + ) + if scrubbed_payload is not data[key]: + if new_data is None: + new_data = dict(data) + new_data[key] = scrubbed_payload + if drop_event: + should_drop = True + if should_drop: + return None + if new_data is not None: + new_event = dict(event) + new_event["data"] = new_data + return new_event + return event + + +def _patch_hide_delegation_in_astream_events( + compiled_graph: CompiledStateGraph[Any, Any, Any], +) -> None: + """Wrap ``astream_events`` so the synthetic ``delegate_to_`` + routing protocol never reaches the consumer. + + ManagerWorkers routes by having the manager react-agent emit a + ``delegate_to_`` tool call, which the worker answers with a + ToolMessage matched to that call id. That pair is load-bearing for the + manager's react loop (it must observe a well-formed tool-call / + tool-result exchange) but it is internal plumbing the consumer should + never see as phantom tool calls. We filter only the emitted events; the + graph's message state is untouched, so the loop is unaffected. The + workers' real LLM/token events still propagate (they reach the consumer + via callback propagation through the isolated worker run), so this + strips the routing noise without hiding the workers' actual output. + """ + original_astream_events = compiled_graph.astream_events + + async def patched_astream_events(*args: Any, **kwargs: Any) -> AsyncGenerator[Any, None]: + event_filter = _DelegationEventFilter() + async for event in original_astream_events(*args, **kwargs): + if not isinstance(event, dict): + yield event + continue + # Fail open: a scrubbing bug must never tear down the stream + # (which would swallow every later event — notably the worker + # events that follow the manager's delegation turn). On error we + # emit the event unfiltered rather than dropping the rest. + try: + kept = event_filter.scrub(event) + except Exception: # noqa: BLE001 — defensive, see above + logging.getLogger("pyagentspec.adapters.langgraph").warning( + "ManagerWorkers astream_events delegation filter raised; " + "passing the event through unfiltered.", + exc_info=True, + ) + yield event + continue + if kept is not None: + yield kept + + compiled_graph.astream_events = patched_astream_events # type: ignore[assignment] + + +def _patch_with_manager_workers_execution_span( + compiled_graph: CompiledStateGraph[Any, Any, Any], + mw: AgentSpecManagerWorkers, +) -> None: + """Wrap ``stream`` / ``astream`` so each ManagerWorkers run emits a + ``ManagerWorkersExecutionSpan`` with Start/End events. Mirrors the + patches applied to Agent and Flow compiled graphs elsewhere in this + converter. + """ + original_stream = compiled_graph.stream + original_astream = compiled_graph.astream + + def _coerce_inputs(kwargs: Dict[str, Any]) -> Dict[str, Any]: + inputs = kwargs.get("input", {}) + return inputs if isinstance(inputs, dict) else {} + + def patched_stream(*args: Any, **kwargs: Any) -> Generator[Any, Any, None]: + span_name = f"ManagerWorkersExecution[{mw.name}]" + inputs = _coerce_inputs(kwargs) + with AgentSpecManagerWorkersExecutionSpan(name=span_name, managerworkers=mw) as span: + span.add_event(AgentSpecManagerWorkersExecutionStart(managerworkers=mw, inputs=inputs)) + last_chunk: Dict[str, Any] = {} + for chunk in original_stream(*args, **kwargs): + yield chunk + if isinstance(chunk, tuple) and isinstance(chunk[1], dict): + last_chunk = chunk[1] + span.add_event( + AgentSpecManagerWorkersExecutionEnd( + managerworkers=mw, + outputs={"messages": last_chunk.get("messages", [])}, + ) + ) + + async def patched_astream(*args: Any, **kwargs: Any) -> AsyncGenerator[Any, Any]: + span_name = f"ManagerWorkersExecution[{mw.name}]" + inputs = _coerce_inputs(kwargs) + span = AgentSpecManagerWorkersExecutionSpan(name=span_name, managerworkers=mw) + try: + await span.start_async() + except NotImplementedError: + span.start() + try: + start_event = AgentSpecManagerWorkersExecutionStart(managerworkers=mw, inputs=inputs) + try: + await span.add_event_async(start_event) + except NotImplementedError: + span.add_event(start_event) + last_chunk: Dict[str, Any] = {} + async for chunk in original_astream(*args, **kwargs): + yield chunk + if isinstance(chunk, tuple) and isinstance(chunk[1], dict): + last_chunk = chunk[1] + end_event = AgentSpecManagerWorkersExecutionEnd( + managerworkers=mw, + outputs={"messages": last_chunk.get("messages", [])}, + ) + try: + await span.add_event_async(end_event) + except NotImplementedError: + span.add_event(end_event) + finally: + try: + await span.end_async() + except NotImplementedError: + span.end() + + compiled_graph.stream = patched_stream # type: ignore[assignment] + compiled_graph.astream = patched_astream # type: ignore[assignment] diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index f1999aef..8619545e 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -49,6 +49,7 @@ from pyagentspec.flows.nodes import OutputMessageNode as AgentSpecOutputMessageNode from pyagentspec.flows.nodes import StartNode as AgentSpecStartNode from pyagentspec.flows.nodes import ToolNode as AgentSpecToolNode +from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers from pyagentspec.property import Property as AgentSpecProperty from pyagentspec.property import _empty_default as pyagentspec_empty_default from pyagentspec.tracing.events import NodeExecutionEnd as AgentSpecNodeExecutionEnd @@ -529,16 +530,73 @@ def _create_react_agent_with_given_input_values( ) return self._agents_cache[system_prompt] + def _create_composite_graph_with_given_input_values( + self, inputs: Dict[str, Any] + ) -> CompiledStateGraph[Any, Any]: + """Compile the node's ``ManagerWorkers`` into a runnable graph for these inputs, + cached by the rendered group-manager prompt. + + Such a graph runs over ``MessagesState`` and can't carry structured inputs to its + inner agents, so the node inputs are baked into the ``group_manager``'s + ``system_prompt`` and the now-satisfied input ports dropped, so declared == + inferred for the downstream span re-validation. A non-Agent group manager is + passed through unchanged so the converter raises its own clear error. + """ + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + + converter = AgentSpecToLangGraphConverter() + component = self.node.agent + if not isinstance(component, AgentSpecManagerWorkers): + raise TypeError( + "_create_composite_graph_with_given_input_values requires a ManagerWorkers" + ) + + entry_agent = component.group_manager + is_agent_entry = isinstance(entry_agent, AgentSpecAgent) + cache_key = ( + render_template(entry_agent.system_prompt, inputs) if is_agent_entry else component.id + ) + if cache_key not in self._agents_cache: + rendered = ( + component.model_copy( + update={ + "group_manager": entry_agent.model_copy( + update={"system_prompt": cache_key, "inputs": []} + ), + "inputs": [], + } + ) + if is_agent_entry + else component + ) + self._agents_cache[cache_key] = converter._manager_workers_convert_to_langgraph( + rendered, + tool_registry=self.tool_registry, + converted_components=self.converted_components, + checkpointer=self.checkpointer, + config=self.config, + middleware=self._middleware, + ) + return self._agents_cache[cache_key] + def _prepare_agent_and_inputs( self, inputs: Dict[str, Any], messages: Messages ) -> Tuple[CompiledStateGraph[Any, Any], Dict[str, Any]]: - agent = self._create_react_agent_with_given_input_values(inputs) # LangGraph's agent expects at least one user message to drive execution. # When an AgentNode is used with a templated system prompt and no messages are provided # by the flow, the agent can crash. To avoid this, we artificially insert an empty # user message when the message list is empty. if not messages: messages = cast(Messages, [{"role": "user", "content": ""}]) + if isinstance(self.node.agent, AgentSpecManagerWorkers): + # A ManagerWorkers flow step runs as a hierarchical graph over MessagesState: + # node inputs were baked into the group-manager's prompt, so the graph is + # driven by messages alone (not the agent's remaining_steps state). + graph = self._create_composite_graph_with_given_input_values(inputs) + return graph, {"messages": messages} + agent = self._create_react_agent_with_given_input_values(inputs) inputs |= { "remaining_steps": 20, # Get the right number of steps left "messages": messages, diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index 26c2bdeb..130ad4de 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -13,6 +13,7 @@ from typing_extensions import Self from pyagentspec.agenticcomponent import AgenticComponent +from pyagentspec.property import Property from pyagentspec.validation_helpers import model_validator_with_error_accumulation from pyagentspec.versioning import AgentSpecVersionEnum @@ -65,6 +66,24 @@ class ManagerWorkers(AgenticComponent): default=AgentSpecVersionEnum.v25_4_2, init=False, exclude=True ) + def _get_inferred_inputs(self) -> List[Property]: + """A ``ManagerWorkers`` exposes the inputs of its group manager. + + The group manager is the component that drives the conversation and whose prompt + the run-time renders, so the manager-workers component accepts exactly the inputs + the group manager accepts (e.g. the ``{{placeholder}}`` inputs of an ``Agent`` + group manager). Without this, the base default infers no inputs, so a + ``ManagerWorkers`` used as a flow ``AgentNode`` would expose no input ports and a + data-flow edge into it could not resolve. + """ + group_manager = getattr(self, "group_manager", None) + return list(getattr(group_manager, "inputs", None) or []) + + def _get_inferred_outputs(self) -> List[Property]: + """Outputs of the group manager; see :meth:`_get_inferred_inputs`.""" + group_manager = getattr(self, "group_manager", None) + return list(getattr(group_manager, "outputs", None) or []) + @model_validator_with_error_accumulation def _validate_one_or_more_workers(self) -> Self: if len(self.workers) == 0: diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py new file mode 100644 index 00000000..d38d0005 --- /dev/null +++ b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py @@ -0,0 +1,157 @@ +# Copyright © 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +"""A ManagerWorkers used as a flow step (AgentNode). + +Regression coverage for two coupled behaviours: + * ``ManagerWorkers._get_inferred_inputs`` exposes the group manager's inputs, so a + flow ``AgentNode`` wrapping a manager declares input ports and a ``DataFlowEdge`` + into it resolves at load (previously: "node does not have any input property..."). + * ``AgentNodeExecutor`` runs a ManagerWorkers node (previously: TypeError "can only + be used with AgentSpecAgent agents"), rendering the node inputs into the group + manager's prompt and returning its result. +""" + +from pyagentspec.agent import Agent +from pyagentspec.managerworkers import ManagerWorkers +from pyagentspec.property import StringProperty + + +def test_managerworkers_infers_inputs_from_group_manager_prompt() -> None: + """A ManagerWorkers exposes the group manager's prompt placeholders as inputs.""" + llm = {"name": "m", "model_id": "fake", "url": "null"} + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + cfg = OpenAiCompatibleConfig(**llm) + manager = Agent( + name="manager", + llm_config=cfg, + system_prompt="Translate the following to Arabic:\n\n{{joke}}\n\nMake {{count}} variants.", + ) + worker = Agent(name="worker", llm_config=cfg, system_prompt="You translate.") + mw = ManagerWorkers(name="mw", group_manager=manager, workers=[worker]) + + assert sorted(p.title for p in (mw.inputs or [])) == ["count", "joke"] + + +def test_managerworkers_infers_outputs_from_group_manager() -> None: + """Symmetric with inputs: a ManagerWorkers exposes the group manager's outputs, + so a flow AgentNode wrapping it can wire its result downstream (or surface it as a + leaf).""" + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + cfg = OpenAiCompatibleConfig(name="m", model_id="fake", url="null") + answer = StringProperty(title="answer") + manager = Agent( + name="manager", + llm_config=cfg, + system_prompt="Answer the question.", + outputs=[answer], + ) + worker = Agent(name="worker", llm_config=cfg, system_prompt="You help.") + mw = ManagerWorkers(name="mw", group_manager=manager, workers=[worker]) + + assert [p.title for p in (mw.outputs or [])] == ["answer"] + + +def test_managerworkers_runs_as_a_flow_step_with_data_edge_inputs() -> None: + """A ManagerWorkers flow step loads (data edge resolves) and executes offline. + + The model is stubbed (no real LLM, no delegation), so the manager produces a final + message and the manager graph routes straight to END. Asserts the flow both loads — + proving the manager node exposes the ``joke`` input the data edge targets — and runs, + surfacing the manager's answer as the node's single string output. + """ + from unittest.mock import patch + + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage + from langchain_openai import ChatOpenAI + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge + from pyagentspec.flows.flow import Flow + from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + # Final message has no tool_calls → the manager routes to END without delegating. + fake_llm = _FakeModel(responses=[AIMessage(content="لماذا...")]) + + cfg = OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null") + joke = StringProperty(title="joke") + translated = StringProperty(title="translated") + + manager = Agent( + name="manager", + llm_config=cfg, + system_prompt="Translate the following to Arabic:\n\n{{joke}}", + outputs=[translated], + ) + worker = Agent(name="worker", llm_config=cfg, system_prompt="You translate.") + mw = ManagerWorkers(name="translator", group_manager=manager, workers=[worker]) + # The manager node exposes the group manager's `joke` input, and the single + # `translated` output (inherited from the group manager) for the leaf edge. + assert [p.title for p in (mw.inputs or [])] == ["joke"] + + manager_node = AgentNode(name="manager_node", agent=mw) + start_node = StartNode(name="start", inputs=[joke]) + end_node = EndNode(name="end", outputs=[translated]) + flow = Flow( + name="flow", + start_node=start_node, + nodes=[start_node, manager_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=manager_node), + ControlFlowEdge(name="node_to_end", from_node=manager_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="joke_edge", + source_node=start_node, + source_output=joke.title, + destination_node=manager_node, + destination_input=joke.title, + ), + DataFlowEdge( + name="translated_edge", + source_node=manager_node, + source_output=translated.title, + destination_node=end_node, + destination_input=translated.title, + ), + ], + outputs=[translated], + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ): + compiled = loader.load_component(flow) + result = compiled.invoke( + { + "inputs": {"joke": "Why did the car..."}, + "messages": [{"role": "user", "content": ""}], + }, + {"configurable": {"thread_id": "managerworkers-node"}}, + ) + + assert "outputs" in result + assert result["outputs"]["translated"] == "لماذا..." diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py new file mode 100644 index 00000000..769f92c6 --- /dev/null +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -0,0 +1,1343 @@ +# Copyright © 2025 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +"""Offline tests for the LangGraph ``ManagerWorkers`` converter. + +These cover the hierarchical topology, roster prompt rendering, the +worker-isolation invariant (each worker sees only its delegated task), +and the recursive nesting case. The LLM is stubbed with +``FakeMessagesListChatModel`` so the tests run without network or model +endpoints. +""" + +from typing import Any +from unittest.mock import patch + +import pytest + +# ─── Shared helpers ────────────────────────────────────────────────────────── + + +def _llm_cfg(name: str) -> Any: + from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig + + return OpenAiCompatibleConfig(name=name, model_id="fake", url="null") + + +def _fake_manager(*ai_responses: Any) -> Any: + """A FakeMessagesListChatModel subclassed under ChatOpenAI so the + manager's react-agent treats it as an OpenAI-style chat model.""" + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_openai import ChatOpenAI + + class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): + pass + + return _FakeModel(responses=list(ai_responses)) + + +# ─── Pure-helper unit tests (no LLM) ──────────────────────────────────────── + + +def test_safe_node_name_lowercases_and_collapses_punctuation() -> None: + from pyagentspec.adapters.langgraph._managerworkers import ( + _safe_node_name, + ) + + assert _safe_node_name("Research Helper", "id-1") == "research_helper" + assert _safe_node_name("My-Worker!! v2", "id-1") == "my_worker_v2" + + +def test_safe_node_name_falls_back_to_normalized_id() -> None: + from pyagentspec.adapters.langgraph._managerworkers import ( + _safe_node_name, + ) + + # Name slugifies to empty → id used (and also normalized). + assert _safe_node_name("!!!", "sub-1") == "sub_1" + # Both empty → constant fallback. + assert _safe_node_name("", "") == "worker" + + +def test_append_workers_roster_appends_block_after_existing_prompt() -> None: + from pyagentspec.adapters.langgraph._managerworkers import ( + _append_workers_roster, + ) + + out = _append_workers_roster( + "Coordinate the team.", + [("research_helper", "Handles research"), ("drafter", "Drafts text")], + ) + assert out == ( + "Coordinate the team.\n\n" + "Available workers:\n" + "- research_helper: Handles research\n" + "- drafter: Drafts text" + ) + + +def test_append_workers_roster_flattens_multiline_descriptions() -> None: + from pyagentspec.adapters.langgraph._managerworkers import ( + _append_workers_roster, + ) + + out = _append_workers_roster( + "", + [("helper", "First line\nsecond line\n third line ")], + ) + # Whitespace flattened so the one-line-per-worker shape survives. + assert out == "Available workers:\n- helper: First line second line third line" + + +def test_route_manager_to_worker_or_end_sends_to_pending_delegation() -> None: + from langchain_core.messages import AIMessage + from langgraph.types import Send + + from pyagentspec.adapters.langgraph._managerworkers import ( + _DELEGATE_CALL_ID_KEY, + _DELEGATE_TASK_KEY, + _route_manager_to_worker_or_end, + ) + + delegating = AIMessage( + content="", + tool_calls=[{"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "c1"}], + ) + sends = _route_manager_to_worker_or_end({"messages": [delegating]}) + # One delegation → a single Send to the worker node carrying the task + # and the tool_call_id its reply must answer. + assert isinstance(sends, list) and len(sends) == 1 + assert isinstance(sends[0], Send) + assert sends[0].node == "research_helper" + assert sends[0].arg == {_DELEGATE_TASK_KEY: "hi", _DELEGATE_CALL_ID_KEY: "c1"} + + +def test_route_manager_to_worker_or_end_returns_end_when_no_delegation() -> None: + from langchain_core.messages import AIMessage + from langgraph.graph import END + + from pyagentspec.adapters.langgraph._managerworkers import ( + _route_manager_to_worker_or_end, + ) + + not_delegating = AIMessage(content="Done.", tool_calls=[]) + assert _route_manager_to_worker_or_end({"messages": [not_delegating]}) == END + assert _route_manager_to_worker_or_end({"messages": []}) == END + + +def test_route_manager_to_worker_or_end_fans_out_every_delegation() -> None: + from langchain_core.messages import AIMessage + from langgraph.types import Send + + from pyagentspec.adapters.langgraph._managerworkers import ( + _DELEGATE_CALL_ID_KEY, + _DELEGATE_TASK_KEY, + _route_manager_to_worker_or_end, + ) + + msg = AIMessage( + content="", + tool_calls=[ + {"name": "some_other_tool", "args": {}, "id": "c0"}, + {"name": "delegate_to_drafter", "args": {"task": "x"}, "id": "c1"}, + {"name": "delegate_to_research_helper", "args": {"task": "y"}, "id": "c2"}, + ], + ) + sends = _route_manager_to_worker_or_end({"messages": [msg]}) + # Every delegation gets its own Send so each tool_call_id is answered. + # The non-delegation tool call was already executed inside the manager's + # react loop and is ignored by routing. + assert all(isinstance(s, Send) for s in sends) + assert [s.node for s in sends] == ["drafter", "research_helper"] + assert [s.arg[_DELEGATE_CALL_ID_KEY] for s in sends] == ["c1", "c2"] + assert [s.arg[_DELEGATE_TASK_KEY] for s in sends] == ["x", "y"] + + +# ─── Topology test (no LLM execution; checks compiled graph shape) ────────── + + +def test_manager_workers_compiles_to_hierarchical_graph_topology() -> None: + from langchain_core.messages import AIMessage + from langgraph.checkpoint.memory import MemorySaver + from langgraph.graph import START + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.adapters.langgraph._managerworkers import ( + _MANAGER_NODE_KEY, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + manager_agent = Agent( + name="Coordinator", + description="Coordinates", + system_prompt="Coordinate the team.", + llm_config=_llm_cfg("manager_llm"), + ) + worker_a = Agent( + name="Research Helper", + description="Handles research", + system_prompt="Research.", + llm_config=_llm_cfg("worker_a_llm"), + ) + worker_b = Agent( + name="Drafter", + description="Drafts text", + system_prompt="Draft.", + llm_config=_llm_cfg("worker_b_llm"), + ) + mw = ManagerWorkers( + name="ResearchTeam", + group_manager=manager_agent, + workers=[worker_a, worker_b], + ) + + fake_llm = _fake_manager(AIMessage(content="Done.")) + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, "_llm_convert_to_langgraph", return_value=fake_llm + ): + compiled = loader.load_component(mw) + + # The compiled object is a CompiledStateGraph; its builder exposes + # the parent topology we expect. + builder = compiled.builder + assert _MANAGER_NODE_KEY in builder.nodes + assert "research_helper" in builder.nodes + assert "drafter" in builder.nodes + + # START → manager; every worker → manager (loop). + edge_pairs = {(src, dst) for src, dst in builder.edges} + assert (START, _MANAGER_NODE_KEY) in edge_pairs + assert ("research_helper", _MANAGER_NODE_KEY) in edge_pairs + assert ("drafter", _MANAGER_NODE_KEY) in edge_pairs + + # The manager → worker routing is a conditional edge (branch), not a + # plain edge — branches are stored separately on the builder. + branches = builder.branches.get(_MANAGER_NODE_KEY) or {} + assert branches, "expected a conditional branch from the manager node" + + +def test_manager_workers_renders_workers_roster_into_manager_prompt() -> None: + """The manager's system prompt gets the ``Available workers:`` block + appended so the LLM knows which delegation tool maps to which worker. + """ + from langchain_core.messages import AIMessage + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.adapters.langgraph._managerworkers import ( + _MANAGER_NODE_KEY, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + manager_agent = Agent( + name="Coordinator", + description="Coordinates", + system_prompt="Coordinate the team.", + llm_config=_llm_cfg("manager_llm"), + ) + worker = Agent( + name="Research Helper", + description="Handles research tasks", + system_prompt="Research.", + llm_config=_llm_cfg("worker_llm"), + ) + mw = ManagerWorkers( + name="Team", + group_manager=manager_agent, + workers=[worker], + ) + + fake_llm = _fake_manager(AIMessage(content="Done.")) + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, "_llm_convert_to_langgraph", return_value=fake_llm + ): + compiled = loader.load_component(mw) + + # The manager react-agent is itself a subgraph; its create_agent + # middleware stack carries the rendered system prompt as the + # first message of every turn. Walk the manager subgraph's pre-model + # hook chain to find it. + manager_subgraph = compiled.builder.nodes[_MANAGER_NODE_KEY].runnable + # `create_agent` builds a graph whose system message generation + # wraps the prompt — easier to assert by re-rendering it through the + # same helper used by the converter and checking the *intent*. + from pyagentspec.adapters.langgraph._managerworkers import ( + _append_workers_roster, + ) + + expected = _append_workers_roster( + "Coordinate the team.", + [("research_helper", "Handles research tasks")], + ) + assert "Available workers:" in expected + assert "- research_helper: Handles research tasks" in expected + # And the compiled manager carries the delegation tool the prompt + # advertises, proving the LLM has the matching contract. + tools_node = manager_subgraph.builder.nodes["tools"].runnable + assert "delegate_to_research_helper" in tools_node.tools_by_name + + +# ─── End-to-end execution test (offline, fake LLM emitting delegation) ────── + + +def test_manager_workers_delegates_and_routes_back_with_tool_message() -> None: + """End-to-end: manager LLM emits a delegate_to_ tool call, + the parent graph routes to the worker subgraph (which runs with an + isolated message context), the worker's final AIMessage content is + surfaced back to the manager as a ToolMessage matched to the + pending tool_call_id, and the manager's next turn (no tool call) + terminates the graph. This is the load-bearing path that proves the + subgraph composition actually works.""" + from langchain_core.messages import AIMessage, HumanMessage + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + manager_agent = Agent( + name="Coordinator", + description="Coordinates", + system_prompt="You coordinate.", + llm_config=_llm_cfg("manager_llm"), + ) + worker = Agent( + name="Research Helper", + description="Handles research", + system_prompt="You research.", + llm_config=_llm_cfg("worker_llm"), + ) + mw = ManagerWorkers( + name="Team", + group_manager=manager_agent, + workers=[worker], + ) + + # Manager turn 1: delegate to research_helper. + # Manager turn 2: produce final answer (no tool call → END). + manager_responses = [ + AIMessage( + content="", + tool_calls=[ + { + "name": "delegate_to_research_helper", + "args": {"task": "Look up Saturn"}, + "id": "call_1", + } + ], + ), + AIMessage(content="The worker reports: Saturn has rings."), + ] + # Worker turn 1: produce its own final answer. + worker_responses = [AIMessage(content="Saturn has rings.")] + + fake_manager = _fake_manager(*manager_responses) + fake_worker = _fake_manager(*worker_responses) + + def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: + if llm_config.name == "manager_llm": + return fake_manager + if llm_config.name == "worker_llm": + return fake_worker + raise AssertionError(f"unexpected llm_config: {llm_config.name}") + + # ``create_agent`` calls ``model.bind_tools(...)``. ``FakeMessagesListChatModel`` + # inherits ``bind_tools`` from real ``ChatOpenAI``, which calls out to + # OpenAI. Patch the class method so binding is a no-op that returns the + # same fake (preserving its response queue). + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=_dispatch, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **kw: self_obj, + ): + compiled = loader.load_component(mw) + + # Use the sync invocation path: ``FakeMessagesListChatModel`` provides + # a sync ``_generate`` (returns queued responses) but no async + # override, so MRO resolves ``_agenerate`` to the real + # ``ChatOpenAI._agenerate`` which calls the OpenAI API. The worker + # wrapper exposes both sync and async via RunnableLambda; LangGraph + # picks the sync path here. + result = compiled.invoke( + {"messages": [HumanMessage(content="Tell me about Saturn.")]}, + {"configurable": {"thread_id": "mw-1"}}, + ) + messages = result["messages"] + + # The end state should contain: user input, manager's delegation + # AIMessage, the synthesized ToolMessage (worker's reply), and the + # manager's final AIMessage. + msg_types = [type(m).__name__ for m in messages] + assert "HumanMessage" in msg_types + assert "ToolMessage" in msg_types + # Final message is the manager's terminating AIMessage. + assert isinstance(messages[-1], AIMessage) + assert "Saturn has rings" in messages[-1].content + + # And the ToolMessage carries the worker's reply matched to the + # pending delegation tool_call_id — proves the isolation wrapper + # threaded the call id through. + tool_msgs = [m for m in messages if type(m).__name__ == "ToolMessage"] + assert tool_msgs and tool_msgs[0].tool_call_id == "call_1" + assert "Saturn has rings" in tool_msgs[0].content + + +def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: + """Regression: when the manager emits SEVERAL ``delegate_to_`` + tool calls in one turn (e.g. "spin up 5 sub-agents"), every delegation + must run and be answered by its own ToolMessage matched to the + originating tool_call_id. + + Before the fix the parent graph routed only the first delegation, so the + other tool_call_ids were left unanswered — an invalid tool-call / + tool-result sequence that made the manager hallucinate the missing + replies. This asserts all three calls get matched ToolMessages. + """ + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langchain_core.messages import AIMessage, HumanMessage + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + manager_agent = Agent( + name="Coordinator", + description="Coordinates", + system_prompt="You coordinate.", + llm_config=_llm_cfg("manager_llm"), + ) + worker = Agent( + name="Sub Agent", + description="Writes poems", + system_prompt="You write poems.", + llm_config=_llm_cfg("worker_llm"), + ) + mw = ManagerWorkers(name="Team", group_manager=manager_agent, workers=[worker]) + + # Turn 1: three delegations to the SAME worker in one AIMessage. + # Turn 2: terminate (no tool call). + manager_responses = [ + AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_sub_agent", "args": {"task": "Spanish poem"}, "id": "call_1"}, + {"name": "delegate_to_sub_agent", "args": {"task": "French poem"}, "id": "call_2"}, + {"name": "delegate_to_sub_agent", "args": {"task": "German poem"}, "id": "call_3"}, + ], + ), + AIMessage(content="Here are your three poems."), + ] + # Each worker invocation pops one reply; provide enough for the fan-out. + worker_responses = [AIMessage(content=f"poem #{i}") for i in range(1, 6)] + + fake_manager = _fake_manager(*manager_responses) + fake_worker = _fake_manager(*worker_responses) + + def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: + if llm_config.name == "manager_llm": + return fake_manager + if llm_config.name == "worker_llm": + return fake_worker + raise AssertionError(f"unexpected llm_config: {llm_config.name}") + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=_dispatch, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **kw: self_obj, + ): + compiled = loader.load_component(mw) + + result = compiled.invoke( + {"messages": [HumanMessage(content="Write 3 poems via sub-agents.")]}, + {"configurable": {"thread_id": "mw-multi"}}, + ) + messages = result["messages"] + + # Every delegation tool_call_id must be answered by exactly one ToolMessage. + requested = { + tc["id"] + for m in messages + if isinstance(m, AIMessage) + for tc in (m.tool_calls or []) + if tc["name"].startswith("delegate_to_") + } + answered = [m.tool_call_id for m in messages if type(m).__name__ == "ToolMessage"] + assert requested == {"call_1", "call_2", "call_3"} + assert sorted(answered) == [ + "call_1", + "call_2", + "call_3", + ], f"unanswered delegations: {requested - set(answered)}" + # No duplicate replies, and each carries a worker poem. + assert len(answered) == 3 + tool_msgs = [m for m in messages if type(m).__name__ == "ToolMessage"] + assert all(m.content.startswith("poem #") for m in tool_msgs) + + +# ─── Recursive nesting ────────────────────────────────────────────────────── + + +def test_nested_manager_workers_compiles_recursively() -> None: + """A worker that is itself a ManagerWorkers compiles through the + same dispatch — the inner ManagerWorkers becomes a CompiledStateGraph + that the outer parent graph wires in as a subgraph node.""" + from langchain_core.messages import AIMessage + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + leaf = Agent( + name="Leaf", + description="Leaf task", + system_prompt="Leaf.", + llm_config=_llm_cfg("leaf_llm"), + ) + inner_manager = Agent( + name="InnerManager", + description="Inner", + system_prompt="Manage leaves.", + llm_config=_llm_cfg("inner_llm"), + ) + inner_mw = ManagerWorkers( + name="Inner", + group_manager=inner_manager, + workers=[leaf], + ) + outer_manager = Agent( + name="OuterManager", + description="Outer", + system_prompt="Manage subteams.", + llm_config=_llm_cfg("outer_llm"), + ) + outer_mw = ManagerWorkers( + name="Outer", + group_manager=outer_manager, + workers=[inner_mw], + ) + + fake_llm = _fake_manager(AIMessage(content="Done.")) + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, "_llm_convert_to_langgraph", return_value=fake_llm + ): + compiled = loader.load_component(outer_mw) + + # Outer parent graph has a node for the inner ManagerWorkers worker. + assert "inner" in compiled.builder.nodes + + +def test_rejects_non_agent_group_manager() -> None: + """ManagerWorkers.group_manager must be an Agent — pyagentspec allows + any AgenticComponent but the LangGraph adapter needs a chat-LLM that + emits tool_calls to decide which worker to delegate to.""" + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + # Use a nested ManagerWorkers as the group_manager — a valid + # AgenticComponent per pyagentspec validators, unsupported here. + leaf = Agent( + name="Leaf", + description="L", + system_prompt="L.", + llm_config=_llm_cfg("l"), + ) + inner_manager = Agent( + name="Inner", + description="I", + system_prompt="I.", + llm_config=_llm_cfg("i"), + ) + inner_mw = ManagerWorkers( + name="Inner", + group_manager=inner_manager, + workers=[leaf], + ) + outer_mw = ManagerWorkers( + name="Outer", + group_manager=inner_mw, + workers=[ + Agent(name="Other", description="O", system_prompt="O.", llm_config=_llm_cfg("o")), + ], + ) + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with pytest.raises(NotImplementedError, match="group_manager must be an Agent"): + loader.load_component(outer_mw) + + +# ─── Worker name collision ────────────────────────────────────────────────── + + +def test_workers_with_name_slug_collision_are_rejected() -> None: + """Two workers whose names normalize to the same node identifier + would silently overwrite each other in the parent graph; raise at + load time instead.""" + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + a = Agent(name="Helper A", description="x", system_prompt=".", llm_config=_llm_cfg("a")) + b = Agent(name="helper-a", description="x", system_prompt=".", llm_config=_llm_cfg("b")) + # Both normalize to "helper_a". + mw = ManagerWorkers( + name="T", + group_manager=Agent( + name="M", + description="m", + system_prompt=".", + llm_config=_llm_cfg("m"), + ), + workers=[a, b], + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with pytest.raises(ValueError, match="collide after normalization"): + loader.load_component(mw) + + +# ─── astream_events delegation scrubbing ───────────────────────────────────── +# +# The manager routes by emitting a ``delegate_to_`` tool call which +# the worker answers with a ToolMessage. That pair is internal plumbing; the +# consumer-facing ``astream_events`` view must not surface it as phantom tool +# calls. ``_DelegationEventFilter`` scrubs the event stream while leaving the +# graph's message state intact. + + +def test_delegation_filter_drops_delegate_tool_lifecycle_events() -> None: + from pyagentspec.adapters.langgraph._managerworkers import ( + _DelegationEventFilter, + ) + + f = _DelegationEventFilter() + for etype in ("on_tool_start", "on_tool_end", "on_tool_error"): + ev = { + "event": etype, + "name": "delegate_to_research_helper", + "run_id": "r", + "data": {}, + } + assert f.scrub(ev) is None + + # A real tool's lifecycle events pass through untouched. + real = {"event": "on_tool_start", "name": "search", "run_id": "r", "data": {}} + assert f.scrub(real) is real + + +def test_delegation_filter_strips_delegate_call_from_chat_model_end() -> None: + from langchain_core.messages import AIMessage + + from pyagentspec.adapters.langgraph._managerworkers import ( + _DelegationEventFilter, + ) + + f = _DelegationEventFilter() + msg = AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "call_1"}, + ], + ) + out = f.scrub( + {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": msg}} + ) + assert out is not None + assert out["data"]["output"].tool_calls == [] + # The original message object (which lives in graph state) is untouched. + assert msg.tool_calls and msg.tool_calls[0]["name"] == "delegate_to_research_helper" + + # A turn that mixes a delegation call with a real tool call keeps the real one. + mixed = AIMessage( + content="ok", + tool_calls=[ + {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "call_2"}, + {"name": "search", "args": {"q": "x"}, "id": "call_3"}, + ], + ) + out2 = f.scrub( + {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": mixed}} + ) + assert [tc["name"] for tc in out2["data"]["output"].tool_calls] == ["search"] + + +def test_delegation_filter_strips_streamed_delegate_tool_call_chunks() -> None: + from langchain_core.messages import AIMessageChunk + + from pyagentspec.adapters.langgraph._managerworkers import ( + _DelegationEventFilter, + ) + + f = _DelegationEventFilter() + # Opening chunk: names the delegation tool at index 0 → pure plumbing, dropped. + opening = AIMessageChunk( + content="", + tool_call_chunks=[ + { + "name": "delegate_to_research_helper", + "args": "", + "id": "call_1", + "index": 0, + "type": "tool_call_chunk", + } + ], + ) + assert ( + f.scrub( + { + "event": "on_chat_model_stream", + "name": "x", + "run_id": "r", + "data": {"chunk": opening}, + } + ) + is None + ) + + # Argument-continuation chunk: no name, same index 0 → also dropped. + cont = AIMessageChunk( + content="", + tool_call_chunks=[ + { + "name": None, + "args": '{"task":"hi"}', + "id": None, + "index": 0, + "type": "tool_call_chunk", + }, + ], + ) + assert ( + f.scrub( + {"event": "on_chat_model_stream", "name": "x", "run_id": "r", "data": {"chunk": cont}} + ) + is None + ) + + # A chunk mixing a delegation call with a real tool call keeps the real one. + mixed = AIMessageChunk( + content="", + tool_call_chunks=[ + { + "name": "delegate_to_research_helper", + "args": "", + "id": "c4", + "index": 0, + "type": "tool_call_chunk", + }, + {"name": "search", "args": "", "id": "c5", "index": 1, "type": "tool_call_chunk"}, + ], + ) + out = f.scrub( + {"event": "on_chat_model_stream", "name": "x", "run_id": "r2", "data": {"chunk": mixed}} + ) + assert out is not None + kept = out["data"]["chunk"].tool_call_chunks + assert [c["name"] for c in kept] == ["search"] + + +def test_delegation_filter_drops_worker_synthetic_tool_message() -> None: + from langchain_core.messages import AIMessage, ToolMessage + + from pyagentspec.adapters.langgraph._managerworkers import ( + _DelegationEventFilter, + ) + + f = _DelegationEventFilter() + # The manager's delegate turn first records the delegation call id. + delegate = AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "call_1"}, + ], + ) + f.scrub( + {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": delegate}} + ) + + # The worker node then emits its reply as a ToolMessage matched to call_1. + reply = ToolMessage(content="Saturn has rings.", tool_call_id="call_1") + out = f.scrub( + { + "event": "on_chain_end", + "name": "worker:research_helper", + "run_id": "w", + "data": {"output": {"messages": [reply]}}, + } + ) + assert out is None + + # A ToolMessage answering an unknown (real) tool call is preserved. + other = ToolMessage(content="x", tool_call_id="call_other") + kept = f.scrub( + { + "event": "on_chain_end", + "name": "node", + "run_id": "w2", + "data": {"output": {"messages": [other]}}, + } + ) + assert kept is not None + assert kept["data"]["output"]["messages"] == [other] + + +def test_delegation_filter_strips_delegate_calls_from_state_snapshot() -> None: + """Regression: a node/state payload (``on_chain_end``) carrying the full + ``messages`` list must surface NEITHER the delegate tool calls NOR their + reply ToolMessages. + + A consumer builds its message snapshot from this payload and reads + ``tool_calls`` straight off the AIMessage. If the filter dropped only the + reply ToolMessages but left the delegate tool calls on the AIMessage, the + snapshot would show delegate tool calls with no results — rendered as a + "tool call with no result". Real (non-delegation) tool calls and their + results must be preserved. + """ + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + + from pyagentspec.adapters.langgraph._managerworkers import ( + _DelegationEventFilter, + ) + + f = _DelegationEventFilter() + # Manager's delegation turn streams first so the filter learns the ids. + delegate_ai = AIMessage( + content="", + tool_calls=[ + {"name": "delegate_to_sub_agent", "args": {"task": "ES"}, "id": "call_1"}, + {"name": "delegate_to_sub_agent", "args": {"task": "FR"}, "id": "call_2"}, + ], + ) + f.scrub( + {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": delegate_ai}} + ) + + # The final-state snapshot carries the whole conversation, including a + # real tool call ("search") + its result that must survive. + snapshot = { + "messages": [ + HumanMessage(content="2 poems via sub-agents"), + delegate_ai, + ToolMessage(content="poem ES", tool_call_id="call_1"), + ToolMessage(content="poem FR", tool_call_id="call_2"), + AIMessage( + content="", + tool_calls=[{"name": "search", "args": {"q": "x"}, "id": "call_real"}], + ), + ToolMessage(content="search result", tool_call_id="call_real"), + AIMessage(content="Here are your poems."), + ] + } + out = f.scrub( + { + "event": "on_chain_end", + "name": "__manager__", + "run_id": "r2", + "data": {"output": snapshot}, + } + ) + assert out is not None + msgs = out["data"]["output"]["messages"] + + # No delegate tool calls and no delegate ToolMessages remain. + delegate_calls = [ + tc + for m in msgs + if isinstance(m, AIMessage) + for tc in (m.tool_calls or []) + if tc["name"].startswith("delegate_to_") + ] + assert delegate_calls == [] + tool_ids = [m.tool_call_id for m in msgs if type(m).__name__ == "ToolMessage"] + assert "call_1" not in tool_ids and "call_2" not in tool_ids + # The empty delegation AIMessage is dropped entirely. + assert delegate_ai not in msgs + + # The REAL tool call + its result are preserved and still paired. + real_calls = [tc["id"] for m in msgs if isinstance(m, AIMessage) for tc in (m.tool_calls or [])] + assert real_calls == ["call_real"] + assert "call_real" in tool_ids + # The human turn and the manager's final answer survive. + assert any(type(m).__name__ == "HumanMessage" for m in msgs) + assert msgs[-1].content == "Here are your poems." + + # The original state objects are never mutated (graph state stays intact). + assert delegate_ai.tool_calls and len(delegate_ai.tool_calls) == 2 + + +def test_delegation_filter_passes_through_real_content() -> None: + from langchain_core.messages import AIMessageChunk + + from pyagentspec.adapters.langgraph._managerworkers import ( + _DelegationEventFilter, + ) + + f = _DelegationEventFilter() + chunk = AIMessageChunk(content="Hello") + ev = {"event": "on_chat_model_stream", "name": "x", "run_id": "r", "data": {"chunk": chunk}} + out = f.scrub(ev) + # No delegation artifact → event passes through as the same object. + assert out is ev + assert out["data"]["chunk"].content == "Hello" + + +def test_delegation_filter_strips_delegate_from_invalid_tool_calls() -> None: + """A delegation call whose args failed to parse arrives in + ``invalid_tool_calls`` rather than ``tool_calls`` — it must still be + scrubbed so the consumer never sees the routing protocol.""" + from langchain_core.messages import AIMessage + + from pyagentspec.adapters.langgraph._managerworkers import _DelegationEventFilter + + f = _DelegationEventFilter() + msg = AIMessage( + content="", + invalid_tool_calls=[ + { + "name": "delegate_to_research_helper", + "args": "{bad", + "id": "call_1", + "error": "parse error", + "type": "invalid_tool_call", + } + ], + ) + out = f.scrub( + {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": msg}} + ) + assert out is not None + assert out["data"]["output"].invalid_tool_calls == [] + # The original (graph-state) message is never mutated. + assert ( + msg.invalid_tool_calls + and msg.invalid_tool_calls[0]["name"] == "delegate_to_research_helper" + ) + + +def test_delegation_filter_strips_provider_native_tool_calls_on_full_message() -> None: + """Some providers (e.g. OpenAI) carry the tool call only in + ``additional_kwargs['tool_calls']``; a delegation call there must be + stripped and its id recorded so the worker reply can later be dropped.""" + from langchain_core.messages import AIMessage, ToolMessage + + from pyagentspec.adapters.langgraph._managerworkers import _DelegationEventFilter + + f = _DelegationEventFilter() + msg = AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "function": {"name": "delegate_to_research_helper", "arguments": ""}, + "type": "function", + } + ] + }, + ) + out = f.scrub( + {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": msg}} + ) + assert out is not None + assert "tool_calls" not in (out["data"]["output"].additional_kwargs or {}) + + # Recording the id means the worker's reply ToolMessage is dropped too. + reply = ToolMessage(content="done", tool_call_id="call_1") + dropped = f.scrub( + { + "event": "on_chain_end", + "name": "worker:research_helper", + "run_id": "w", + "data": {"output": {"messages": [reply]}}, + } + ) + assert dropped is None + + +def test_delegation_filter_scrubs_input_payload_messages() -> None: + """The worker's reply ToolMessage must be dropped wherever it surfaces — + including a node's ``input`` payload, not only ``output`` / ``chunk``.""" + from langchain_core.messages import AIMessage, ToolMessage + + from pyagentspec.adapters.langgraph._managerworkers import _DelegationEventFilter + + f = _DelegationEventFilter() + delegate = AIMessage( + content="", + tool_calls=[{"name": "delegate_to_research_helper", "args": {"task": "x"}, "id": "call_1"}], + ) + f.scrub( + {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": delegate}} + ) + + reply = ToolMessage(content="done", tool_call_id="call_1") + out = f.scrub( + { + "event": "on_chain_start", + "name": "worker:research_helper", + "run_id": "w", + "data": {"input": {"messages": [reply]}}, + } + ) + # The only message was the delegate reply → payload empties → event dropped. + assert out is None + + +def test_worker_events_stream_natively_namespaced_under_worker_node() -> None: + """Regression: a worker's token events must stream under the worker + node's checkpoint namespace so a consumer can attribute them to the + sub-agent. The wrapper must inherit the ambient run config (no fresh + thread_id); a fresh thread_id detaches the worker into a top-level + ``agent:`` run with no worker prefix, which is unattributable.""" + import asyncio + + from langchain_core.language_models.fake_chat_models import ( + GenericFakeChatModel, + ) + from langchain_core.messages import AIMessage, HumanMessage + from langgraph.graph import END, START, MessagesState, StateGraph + + from pyagentspec.adapters.langgraph._managerworkers import ( + _wrap_worker_for_subgraph, + ) + + # A minimal worker compiled graph that streams some content. + wmodel = GenericFakeChatModel(messages=iter([AIMessage(content="Saturn has rings")] * 9)) + wb = StateGraph(MessagesState) + + async def _wagent(state: Any) -> Any: + return {"messages": [await wmodel.ainvoke(state["messages"])]} + + wb.add_node("agent", _wagent) + wb.add_edge(START, "agent") + wb.add_edge("agent", END) + worker_graph = wb.compile() + + # Parent: a plain manager node emits the delegate tool call, then routes + # to the wrapped worker node named "research_helper". + pb = StateGraph(MessagesState) + + def _manager(state: Any) -> Any: + return { + "messages": [ + AIMessage( + content="", + tool_calls=[ + { + "name": "delegate_to_research_helper", + "args": {"task": "Saturn"}, + "id": "c1", + } + ], + ) + ] + } + + pb.add_node("__manager__", _manager) + pb.add_node("research_helper", _wrap_worker_for_subgraph(worker_graph, "research_helper")) + pb.add_edge(START, "__manager__") + pb.add_edge("__manager__", "research_helper") + pb.add_edge("research_helper", END) + parent = pb.compile() + + async def _collect() -> Any: + namespaces = [] + async for ev in parent.astream_events( + {"messages": [HumanMessage(content="hi")]}, + {"configurable": {"thread_id": "t"}}, + version="v2", + ): + if ev["event"] == "on_chat_model_stream": + ns = (ev.get("metadata") or {}).get("langgraph_checkpoint_ns", "") + namespaces.append(ns) + return namespaces + + namespaces = asyncio.run(_collect()) + assert namespaces, "expected the worker to emit token-stream events" + # Every worker token event is namespaced under the worker node, so a + # consumer can attribute the stream to the sub-agent. + assert all(ns.startswith("research_helper:") for ns in namespaces), namespaces + + +def test_patched_astream_events_fails_open_on_filter_error() -> None: + """A bug in the delegation filter must never tear down the stream and + swallow later events (e.g. the worker events that follow the manager's + delegation turn). On a scrub error the event is passed through.""" + import asyncio + + from pyagentspec.adapters.langgraph._managerworkers import ( + _DelegationEventFilter, + _patch_hide_delegation_in_astream_events, + ) + + class _FakeGraph: + async def astream_events(self, *a: Any, **k: Any) -> Any: + yield {"event": "on_chat_model_stream", "run_id": "boom", "name": "x", "data": {}} + yield { + "event": "on_chat_model_stream", + "run_id": "ok", + "name": "x", + "data": {"chunk": "worker-token"}, + } + + def _explode(self: Any, event: Any) -> Any: + if event.get("run_id") == "boom": + raise RuntimeError("kaboom") + return event + + graph = _FakeGraph() + _patch_hide_delegation_in_astream_events(graph) + + async def _collect() -> Any: + out = [] + with patch.object(_DelegationEventFilter, "scrub", new=_explode): + async for ev in graph.astream_events(): + out.append(ev) + return out + + events = asyncio.run(_collect()) + # Both events survive: the one that raised is passed through unfiltered, + # and the later (worker) event is still delivered. + assert [e["run_id"] for e in events] == ["boom", "ok"] + + +def test_manager_workers_patches_astream_events() -> None: + """The compiled ManagerWorkers graph has its ``astream_events`` wrapped + with the delegation scrubber.""" + from langchain_core.language_models.fake_chat_models import ( + FakeMessagesListChatModel, + ) + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import ( + AgentSpecToLangGraphConverter, + ) + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers + + mw = ManagerWorkers( + name="Team", + group_manager=Agent( + name="Coordinator", + description="c", + system_prompt=".", + llm_config=_llm_cfg("manager_llm"), + ), + workers=[ + Agent( + name="Research Helper", + description="r", + system_prompt=".", + llm_config=_llm_cfg("worker_llm"), + ), + ], + ) + + def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: + return _fake_manager(*[]) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=_dispatch, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **kw: self_obj, + ): + compiled = loader.load_component(mw) + + assert getattr(compiled.astream_events, "__name__", "") == "patched_astream_events" + + +# ─── Shared low-level helper unit tests (no LLM) ───────────────────────────── + + +def test_normalize_identifier_lowercases_collapses_and_strips() -> None: + """The single normalization used for both worker node names and + ``transfer_to_`` tool names.""" + from pyagentspec.adapters.langgraph._managerworkers import _normalize_identifier + + assert _normalize_identifier("Research Helper") == "research_helper" + assert _normalize_identifier("My-Worker!! v2") == "my_worker_v2" + # Punctuation-only / empty slugify to the empty string (callers add a fallback). + assert _normalize_identifier("!!!") == "" + assert _normalize_identifier("") == "" + + +def test_messages_of_reads_dict_and_object_state() -> None: + """The delegation tool receives state as a dict or an attribute-bearing + object depending on the langgraph injection path.""" + from langchain_core.messages import AIMessage + + from pyagentspec.adapters.langgraph._managerworkers import _messages_of + + msg = AIMessage(content="hi") + assert _messages_of({"messages": [msg]}) == [msg] + assert _messages_of({"messages": None}) == [] + assert _messages_of({}) == [] + + class _State: + messages = [msg] + + assert _messages_of(_State()) == [msg] + + class _Empty: + pass + + assert _messages_of(_Empty()) == [] + + +def test_surface_to_parent_command_projects_messages_with_no_goto() -> None: + """The placeholder tool's body: break to the parent graph, project the + subgraph messages, carry no ``goto`` (routing is the parent's job).""" + from langchain_core.messages import AIMessage + from langgraph.types import Command + + from pyagentspec.adapters.langgraph._managerworkers import _surface_to_parent_command + + m1, m2 = AIMessage(content="a"), AIMessage(content="b") + cmd = _surface_to_parent_command({"messages": [m1, m2]}) + + assert isinstance(cmd, Command) + assert cmd.graph == Command.PARENT + assert cmd.goto == () # no goto — the parent graph decides where to go + assert cmd.update == {"messages": [m1, m2]} + + +def test_delegation_tool_exposes_expected_name_and_description() -> None: + """The placeholder tool the manager's LLM addresses by name.""" + from pyagentspec.adapters.langgraph._managerworkers import _make_worker_delegation_tool + + delegate = _make_worker_delegation_tool("research_helper") + assert delegate.name == "delegate_to_research_helper" + assert "research_helper" in delegate.description + + +# ─── _wrap_worker_for_subgraph: pending-delegation extraction (no LLM) ──────── + + +def _echo_worker_graph(reply: str = "WORKER REPLY") -> Any: + """A trivial worker CompiledStateGraph whose only node returns a fixed + AIMessage — enough to exercise the wrapper without an LLM.""" + from langchain_core.messages import AIMessage + from langgraph.graph import END, START, MessagesState, StateGraph + + wb = StateGraph(MessagesState) + wb.add_node("agent", lambda state: {"messages": [AIMessage(content=reply)]}) + wb.add_edge(START, "agent") + wb.add_edge("agent", END) + return wb.compile() + + +def test_wrap_worker_uses_send_payload_task_and_call_id() -> None: + """Fan-out path: the routing edge's ``Send`` payload carries the task and + the originating tool_call_id directly, so the worker reply ToolMessage is + matched to that call.""" + from langchain_core.messages import ToolMessage + + from pyagentspec.adapters.langgraph._managerworkers import ( + _DELEGATE_CALL_ID_KEY, + _DELEGATE_TASK_KEY, + _wrap_worker_for_subgraph, + ) + + node = _wrap_worker_for_subgraph(_echo_worker_graph("DONE"), "research_helper") + out = node.invoke({_DELEGATE_TASK_KEY: "do it", _DELEGATE_CALL_ID_KEY: "call_9"}) + + (reply,) = out["messages"] + assert isinstance(reply, ToolMessage) + assert reply.content == "DONE" + assert reply.tool_call_id == "call_9" + + +def test_wrap_worker_recovers_task_from_manager_message_on_direct_edge() -> None: + """Direct-edge path (no Send payload): the task and call id are recovered + from the manager's last AIMessage delegation tool call.""" + from langchain_core.messages import AIMessage, ToolMessage + + from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph + + node = _wrap_worker_for_subgraph(_echo_worker_graph("ANSWER"), "research_helper") + manager_ai = AIMessage( + content="", + tool_calls=[{"name": "delegate_to_research_helper", "args": {"task": "T"}, "id": "c1"}], + ) + out = node.invoke({"messages": [manager_ai]}) + + (reply,) = out["messages"] + assert isinstance(reply, ToolMessage) + assert reply.content == "ANSWER" + assert reply.tool_call_id == "c1" + + +def test_wrap_worker_raises_on_empty_manager_state() -> None: + from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph + + node = _wrap_worker_for_subgraph(_echo_worker_graph(), "research_helper") + with pytest.raises(RuntimeError, match="empty manager state"): + node.invoke({"messages": []}) + + +def test_wrap_worker_raises_when_no_matching_delegation_call() -> None: + from langchain_core.messages import AIMessage + + from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph + + node = _wrap_worker_for_subgraph(_echo_worker_graph(), "research_helper") + not_for_me = AIMessage( + content="", + tool_calls=[{"name": "delegate_to_other", "args": {"task": "x"}, "id": "c1"}], + ) + with pytest.raises(RuntimeError, match="delegate_to_research_helper"): + node.invoke({"messages": [not_for_me]}) From 249333bb8e0466312740ef320f7e9ebc9da810f2 Mon Sep 17 00:00:00 2001 From: Salah Date: Sun, 26 Jul 2026 00:46:45 +0400 Subject: [PATCH 05/17] refactor(adapters/langgraph): dedupe execution-span patching, tidy ManagerWorkers Quality pass over the ManagerWorkers adapter. No behaviour change intended. Wrapping stream/astream in an execution span was already duplicated between the Agent and Flow paths, and ManagerWorkers added a third near-verbatim copy. Extract one `patch_with_execution_span` into `_execution_span.py`, parameterized by span and event factories, and route all three through it. The `except NotImplementedError` fallback ladder for the async span protocol was written 12 times; it is now one helper. Move the flow-step compile onto `AgentNodeExecutor` as `_create_manager_workers_with_given_input_values`, next to its react-agent twin. It was living in `_managerworkers.py` while importing the converter and taking the executor's private cache as a parameter. Replace the six per-worker closures in `_wrap_worker_for_subgraph` with module-level helpers and a `_WorkerSubgraphNode` holding only the graph, so a long-lived node no longer pins the whole factory frame. Memoize `_make_worker_delegation_tool`, which depends only on the node name yet re-ran the `@tool` decorator on every compile. In the converter, collapse the parallel worker list and dict into one list of pairs, hoist the repeated conversion kwargs, and use the module's existing lazy `langgraph_graph` instead of a local `MessagesState` import. Drop `is_delegation_event`: no callers, not exported, unreachable through any supported import path. `is_delegation_tool_name` stays. Tests: collapse six copies of the loader and double-patch stack into one `_load_with_fake_llms` helper, and drop assertions in the roster test that compared a locally computed string against itself. Add an `allow_llm_config_construction` fixture so tests that only build an LLM config and stub the conversion are not skipped by the blanket SKIP_LLM_TESTS guard. This un-skips the three flow-step tests that the guard was hiding. --- .../adapters/langgraph/_execution_span.py | 88 ++ .../adapters/langgraph/_langgraphconverter.py | 323 ++----- .../adapters/langgraph/_managerworkers.py | 654 +++------------ .../adapters/langgraph/_node_execution.py | 101 ++- pyagentspec/src/pyagentspec/managerworkers.py | 21 +- pyagentspec/tests/adapters/conftest.py | 37 + .../flows/test_managerworkers_node.py | 19 +- .../adapters/langgraph/test_managerworkers.py | 794 +++--------------- 8 files changed, 533 insertions(+), 1504 deletions(-) create mode 100644 pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py new file mode 100644 index 00000000..21817f46 --- /dev/null +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py @@ -0,0 +1,88 @@ +# Copyright © 2025, 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +"""Wrap a compiled graph's ``stream``/``astream`` in an Agent Spec execution span. + +Agent, Flow and ManagerWorkers graphs all need the same wrapper: open a span, emit a +Start event carrying the invocation inputs, replay the chunks the underlying stream +yields while remembering the last state chunk, then emit an End event built from that +final state. Only the span class and the two event payloads differ, so they come in +as factories. + +``invoke``/``ainvoke`` need no patch of their own; they go through ``stream``/ +``astream`` internally. +""" + +from typing import Any, AsyncGenerator, Callable, Dict, Generator + +from pyagentspec.adapters.langgraph._types import CompiledStateGraph + + +def _invocation_inputs(kwargs: Dict[str, Any]) -> Dict[str, Any]: + """The ``input=`` argument of the patched call, or ``{}`` when it isn't a dict.""" + inputs = kwargs.get("input", {}) + return inputs if isinstance(inputs, dict) else {} + + +def _final_state(chunk: Any, so_far: Any) -> Any: + """Fold one streamed chunk into the running "last state seen". + + State arrives as ``(namespace, state)`` tuples; other chunk shapes aren't + something to build the End event from, so they leave the fold untouched. + """ + return chunk[1] if isinstance(chunk, tuple) else so_far + + +async def _async_or_sync(async_call: Callable[..., Any], sync_call: Callable[..., Any], *args: Any): + """Await ``async_call``, falling back to ``sync_call`` for spans that don't + implement the async half of the tracing protocol.""" + try: + await async_call(*args) + except NotImplementedError: + sync_call(*args) + + +def patch_with_execution_span( + compiled_graph: CompiledStateGraph[Any, Any, Any], + *, + make_span: Callable[[], Any], + make_start_event: Callable[[Dict[str, Any]], Any], + make_end_event: Callable[[Dict[str, Any]], Any], +) -> None: + """Monkey-patch ``compiled_graph.stream`` / ``.astream`` to run inside a span. + + ``make_start_event`` receives the invocation inputs; ``make_end_event`` + receives the final state chunk (``{}`` when the run produced none). + """ + original_stream = compiled_graph.stream + original_astream = compiled_graph.astream + + def patched_stream(*args: Any, **kwargs: Any) -> Generator[Any, Any, None]: + with make_span() as span: + span.add_event(make_start_event(_invocation_inputs(kwargs))) + state: Any = {} + for chunk in original_stream(*args, **kwargs): + yield chunk + state = _final_state(chunk, state) + span.add_event(make_end_event(state if isinstance(state, dict) else {})) + + async def patched_astream(*args: Any, **kwargs: Any) -> AsyncGenerator[Any, Any]: + span = make_span() + await _async_or_sync(span.start_async, span.start) + try: + start_event = make_start_event(_invocation_inputs(kwargs)) + await _async_or_sync(span.add_event_async, span.add_event, start_event) + state: Any = {} + async for chunk in original_astream(*args, **kwargs): + yield chunk + state = _final_state(chunk, state) + end_event = make_end_event(state if isinstance(state, dict) else {}) + await _async_or_sync(span.add_event_async, span.add_event, end_event) + finally: + await _async_or_sync(span.end_async, span.end) + + compiled_graph.stream = patched_stream # type: ignore[assignment] + compiled_graph.astream = patched_astream # type: ignore[assignment] diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 4a09ce1b..59aee1d0 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -12,11 +12,9 @@ from typing import ( TYPE_CHECKING, Any, - AsyncGenerator, Awaitable, Callable, Dict, - Generator, List, Optional, Tuple, @@ -37,11 +35,11 @@ _build_type_from_schema, create_pydantic_model_from_properties, ) +from pyagentspec.adapters.langgraph._execution_span import patch_with_execution_span from pyagentspec.adapters.langgraph._managerworkers import ( _MANAGER_NODE_KEY, _append_workers_roster, _make_worker_delegation_tool, - _patch_hide_delegation_in_astream_events, _patch_with_manager_workers_execution_span, _route_manager_to_worker_or_end, _safe_node_name, @@ -80,6 +78,7 @@ AgentSpecToolCallbackHandler, ) from pyagentspec.agent import Agent as AgentSpecAgent +from pyagentspec.agenticcomponent import AgenticComponent as AgentSpecAgenticComponent from pyagentspec.flows.edges import ControlFlowEdge as AgentSpecControlFlowEdge from pyagentspec.flows.edges import DataFlowEdge as AgentSpecDataFlowEdge from pyagentspec.flows.flow import Flow as AgentSpecFlow @@ -495,87 +494,18 @@ def _find_property(properties: List[AgentSpecProperty], name: str) -> AgentSpecP "Prefer invoke/stream or upgrade to Python 3.11+ for ainvoke/astream." ) - # To enable flow execution traces monkey patch all the functions that invoke the compiled graph - - original_stream = compiled_graph.stream - - def patch_with_flow_execution_span(*args: Any, **kwargs: Any) -> Generator[Any, Any, None]: - span_name = f"FlowExecution[{flow.name}]" - inputs = kwargs.get("input", {}) - if not isinstance(inputs, dict): - inputs = {} - with AgentSpecFlowExecutionSpan(name=span_name, flow=flow) as span: - span.add_event(AgentSpecFlowExecutionStart(flow=flow, inputs=inputs)) - original_result: dict[str, Any] | Any = {} - result: dict[str, Any] - # This is going to patch stream and astream, that return iterators and yield chunks - for chunk in original_stream(*args, **kwargs): - yield chunk - if isinstance(chunk, tuple): - original_result = chunk[1] - if not isinstance(original_result, dict): - result = {} - else: - result = original_result - span.add_event( - AgentSpecFlowExecutionEnd( - flow=flow, - outputs=result.get("outputs", {}), - branch_selected=result.get("node_execution_details", {}).get("branch", ""), - ) - ) - - original_astream = compiled_graph.astream - - async def patch_async_with_flow_execution_span( - *args: Any, **kwargs: Any - ) -> AsyncGenerator[Any, Any]: - span_name = f"FlowExecution[{flow.name}]" - inputs = kwargs.get("input", {}) - if not isinstance(inputs, dict): - inputs = {} - span = AgentSpecFlowExecutionSpan(name=span_name, flow=flow) - try: - await span.start_async() - except NotImplementedError: - span.start() - try: - try: - await span.add_event_async( - AgentSpecFlowExecutionStart(flow=flow, inputs=inputs) - ) - except NotImplementedError: - span.add_event(AgentSpecFlowExecutionStart(flow=flow, inputs=inputs)) - original_result: dict[str, Any] | Any = {} - result: dict[str, Any] - # This is going to patch stream and astream, that return iterators and yield chunks - async for chunk in original_astream(*args, **kwargs): - yield chunk - if isinstance(chunk, tuple): - original_result = chunk[1] - if not isinstance(original_result, dict): - result = {} - else: - result = original_result - span_end_event = AgentSpecFlowExecutionEnd( - flow=flow, - outputs=result.get("outputs", {}), - branch_selected=result.get("node_execution_details", {}).get("branch", ""), - ) - try: - await span.add_event_async(span_end_event) - except NotImplementedError: - span.add_event(span_end_event) - finally: - try: - await span.end_async() - except NotImplementedError: - span.end() - - # Monkey patch invocation functions to inject tracing - # No need to patch `(a)invoke` as the internally use `(a)stream` - compiled_graph.stream = patch_with_flow_execution_span # type: ignore - compiled_graph.astream = patch_async_with_flow_execution_span # type: ignore + patch_with_execution_span( + compiled_graph, + make_span=lambda: AgentSpecFlowExecutionSpan( + name=f"FlowExecution[{flow.name}]", flow=flow + ), + make_start_event=lambda inputs: AgentSpecFlowExecutionStart(flow=flow, inputs=inputs), + make_end_event=lambda result: AgentSpecFlowExecutionEnd( + flow=flow, + outputs=result.get("outputs", {}), + branch_selected=result.get("node_execution_details", {}).get("branch", ""), + ), + ) return compiled_graph def _node_convert_to_langgraph( @@ -1156,77 +1086,61 @@ def _manager_workers_convert_to_langgraph( Topology:: - ┌─ delegate_to_w1 ─→ worker_1 ─┐ + ┌─ delegate_to_w1 ─→ worker_1 ─┐ START → manager ┤ ├→ manager (loop) - └─ delegate_to_w2 ─→ worker_2 ─┘ - │ - └─ no tool_call ─→ END - - Each worker is recursively converted into a ``CompiledStateGraph`` - and wired in as a *subgraph node*, so ``astream_events`` exposes - the parent/child boundary (``subgraph=True``) for tracing and SSE - streaming. The manager is a react-agent given one synthetic - ``delegate_to_`` tool per worker; the parent graph's - conditional edge inspects the manager's last AIMessage to choose - the next node, then the worker node runs in an isolated message - context and emits a ``ToolMessage`` matched to the pending - delegation tool-call id. Recursive ``ManagerWorkers`` (workers - that are themselves ``ManagerWorkers``) compose for free through - ``self.convert(...)``. + └─ delegate_to_w2 ─→ worker_2 ─┘ + │ + └─ no tool_call ─→ END + + The manager is a react-agent holding one synthetic ``delegate_to_`` + tool per worker. The parent graph's conditional edge inspects its last + AIMessage to pick the next node, and the worker node runs in an isolated + message context and answers with a ``ToolMessage`` matched to the pending + delegation id. + + Workers are converted recursively and wired in as subgraph nodes, so + ``astream_events`` still exposes the parent/child boundary + (``subgraph=True``) for tracing and SSE streaming. Workers that are + themselves ``ManagerWorkers`` compose through ``self.convert(...)``. """ if not isinstance(mw.group_manager, AgentSpecAgent): - # Pyagentspec allows any AgenticComponent as group_manager, - # but the manager has to *decide* which worker to delegate to, - # which means it needs a chat-LLM that emits tool_calls. Today - # only Agent (and SpecializedAgent, a subclass) does that — a - # Flow / Swarm / nested ManagerWorkers as the group_manager - # doesn't have a "tool-call to delegate" output shape we can - # route on. + # The manager has to decide which worker to delegate to, so it needs a + # chat-LLM that emits tool_calls. Only Agent (and its SpecializedAgent + # subclass) has that shape; a Flow, Swarm or nested ManagerWorkers gives + # us nothing to route on. raise NotImplementedError( f"ManagerWorkers.group_manager must be an Agent for LangGraph " f"conversion; got {type(mw.group_manager).__name__}." ) - worker_node_names: List[str] = [ - _safe_node_name(worker.name, fallback_id=worker.id) for worker in mw.workers + named_workers: List[Tuple[str, AgentSpecAgenticComponent]] = [ + (_safe_node_name(worker.name, fallback_id=worker.id), worker) for worker in mw.workers ] + worker_node_names = [node_name for node_name, _ in named_workers] if len(set(worker_node_names)) != len(worker_node_names): raise ValueError( "ManagerWorkers worker names collide after normalization: " f"{worker_node_names}. Give each worker a unique name." ) - # 1. Recursively compile each worker as its own CompiledStateGraph. - worker_graphs: Dict[str, CompiledStateGraph[Any, Any, Any]] = {} - for worker, node_name in zip(mw.workers, worker_node_names): - worker_graphs[node_name] = self.convert( - worker, - tool_registry=tool_registry, - converted_components=converted_components, - checkpointer=checkpointer, - config=config, - middleware=middleware, - ) + conversion_kwargs: Dict[str, Any] = { + "tool_registry": tool_registry, + "converted_components": converted_components, + "checkpointer": checkpointer, + "config": config, + "middleware": middleware, + } - # 2. Render the workers roster into the manager's system prompt - # so the LLM knows which delegation tool maps to which worker. + # The roster tells the LLM which delegation tool maps to which worker. manager_agent = mw.group_manager rendered_prompt = _append_workers_roster( manager_agent.system_prompt, - [ - (node_name, worker.description or "") - for worker, node_name in zip(mw.workers, worker_node_names) - ], + [(node_name, worker.description or "") for node_name, worker in named_workers], ) - # 3. Synthesize one delegation tool per worker. The tool body is a - # placeholder — the parent graph intercepts the manager's tool - # call before it executes and routes to the worker node. - delegation_tools: List[Any] = [ - _make_worker_delegation_tool(node_name) for node_name in worker_node_names - ] - - # 4. Compile the manager as a react-agent with the delegation tools. + # The delegation tools do execute inside the react loop: their body returns a + # Command(graph=PARENT), which is how the call escapes the react subgraph so + # the conditional edge below can route on it. manager_graph = self._create_react_agent_with_given_info( name=manager_agent.name, system_prompt=rendered_prompt, @@ -1236,52 +1150,33 @@ def _manager_workers_convert_to_langgraph( toolboxes=manager_agent.toolboxes, inputs=manager_agent.inputs or [], outputs=manager_agent.outputs or [], - tool_registry=tool_registry, - converted_components=converted_components, - checkpointer=checkpointer, - config=config, - middleware=middleware, - additional_langgraph_tools=delegation_tools, + additional_langgraph_tools=[ + _make_worker_delegation_tool(node_name) for node_name in worker_node_names + ], + **conversion_kwargs, ) - # 5. Compose the parent StateGraph. The manager and every worker - # are CompiledStateGraphs added as subgraph nodes; LangGraph's - # streaming surfaces them with ``subgraph=True``. - from langgraph.graph import MessagesState # local: optional dep - - manager_node_key = _MANAGER_NODE_KEY - builder = StateGraph(MessagesState) - builder.add_node(manager_node_key, manager_graph) - for node_name, worker_graph in worker_graphs.items(): - builder.add_node( - node_name, - _wrap_worker_for_subgraph(worker_graph, node_name), - ) + # Manager and workers all go in as compiled subgraph nodes, which is what + # makes LangGraph stream them with ``subgraph=True``. + builder = StateGraph(langgraph_graph.MessagesState) + builder.add_node(_MANAGER_NODE_KEY, manager_graph) + for node_name, worker in named_workers: + worker_graph = self.convert(worker, **conversion_kwargs) + builder.add_node(node_name, _wrap_worker_for_subgraph(worker_graph, node_name)) + builder.add_edge(node_name, _MANAGER_NODE_KEY) - # Path-map covers delegate-to-worker and the END branch so langgraph - # can statically validate the routing. - routing_path_map: Dict[str, str] = {node_name: node_name for node_name in worker_node_names} - routing_path_map[langgraph_graph.END] = langgraph_graph.END - - builder.add_edge(langgraph_graph.START, manager_node_key) + builder.add_edge(langgraph_graph.START, _MANAGER_NODE_KEY) builder.add_conditional_edges( - manager_node_key, + _MANAGER_NODE_KEY, _route_manager_to_worker_or_end, - routing_path_map, + # The path map covers every worker plus END, so langgraph can validate + # the routing statically. + {node_name: node_name for node_name in worker_node_names} + | {langgraph_graph.END: langgraph_graph.END}, ) - for node_name in worker_node_names: - builder.add_edge(node_name, manager_node_key) compiled_graph = builder.compile(checkpointer=checkpointer, name=mw.name) - - # 6. Tracing — wrap stream/astream so ManagerWorkersExecutionSpan - # surrounds each run. Mirrors the patches applied to Agent and - # Flow graphs above. _patch_with_manager_workers_execution_span(compiled_graph, mw) - # Hide the delegate_to_ routing protocol from the - # astream_events view (tool calls, their tool lifecycle events, and - # the worker's synthetic reply ToolMessage) without touching state. - _patch_hide_delegation_in_astream_events(compiled_graph) return compiled_graph def _create_react_agent_with_given_info( @@ -1374,81 +1269,17 @@ def _create_react_agent_with_given_info( **create_agent_kwargs ) - # To enable flow execution traces monkey patch all the functions that invoke the compiled graph - - original_stream = compiled_graph.stream - - def patch_with_agent_execution_span(*args: Any, **kwargs: Any) -> Generator[Any, Any, Any]: - span_name = f"AgentExecution[{agent.name}]" - inputs = kwargs.get("input", {}) - if not isinstance(inputs, dict): - inputs = {} - with AgentSpecAgentExecutionSpan(name=span_name, agent=agent) as span: - span.add_event(AgentSpecAgentExecutionStart(agent=agent, inputs=inputs)) - original_result: dict[str, Any] | Any = {} - result: dict[str, Any] - # This is going to patch stream and astream, that return iterators and yield chunks - for chunk in original_stream(*args, **kwargs): - yield chunk - if isinstance(chunk, tuple): - original_result = chunk[1] - if not isinstance(original_result, dict): - result = {} - else: - result = original_result - outputs = extract_outputs_from_invoke_result(result, agent.outputs or []) - span.add_event(AgentSpecAgentExecutionEnd(agent=agent, outputs=outputs)) - - original_astream = compiled_graph.astream - - async def patch_async_with_agent_execution_span( - *args: Any, **kwargs: Any - ) -> AsyncGenerator[Any, Any]: - span_name = f"AgentExecution[{agent.name}]" - inputs = kwargs.get("input", {}) - if not isinstance(inputs, dict): - inputs = {} - span = AgentSpecAgentExecutionSpan(name=span_name, agent=agent) - try: - await span.start_async() - except NotImplementedError: - span.start() - try: - try: - await span.add_event_async( - AgentSpecAgentExecutionStart(agent=agent, inputs=inputs) - ) - except NotImplementedError: - span.add_event(AgentSpecAgentExecutionStart(agent=agent, inputs=inputs)) - original_result: dict[str, Any] | Any = {} - result: dict[str, Any] - # This is going to patch stream and astream, that return iterators and yield chunks - async for chunk in original_astream(*args, **kwargs): - yield chunk - if isinstance(chunk, tuple): - original_result = chunk[1] - if not isinstance(original_result, dict): - result = {} - else: - result = original_result - - outputs = extract_outputs_from_invoke_result(result, agent.outputs or []) - try: - await span.add_event_async( - AgentSpecAgentExecutionEnd(agent=agent, outputs=outputs) - ) - except NotImplementedError: - span.add_event(AgentSpecAgentExecutionEnd(agent=agent, outputs=outputs)) - finally: - try: - await span.end_async() - except NotImplementedError: - span.end() - - # Monkey patch invocation functions to inject tracing - # No need to patch `(a)invoke` as they internally use `(a)stream` - compiled_graph.stream = patch_with_agent_execution_span # type: ignore - compiled_graph.astream = patch_async_with_agent_execution_span # type: ignore + patch_with_execution_span( + compiled_graph, + make_span=lambda: AgentSpecAgentExecutionSpan( + name=f"AgentExecution[{agent.name}]", agent=agent + ), + make_start_event=lambda inputs: AgentSpecAgentExecutionStart(agent=agent, inputs=inputs), + make_end_event=lambda result: AgentSpecAgentExecutionEnd( + agent=agent, + outputs=extract_outputs_from_invoke_result(result, agent.outputs or []), + ), + ) return compiled_graph def _agent_convert_to_langgraph( diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py index d63d24db..971fb2ae 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py @@ -4,19 +4,22 @@ # (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. -"""ManagerWorkers LangGraph compilation helpers. +"""Helpers for compiling a ``ManagerWorkers`` into LangGraph. -Module-level building blocks for compiling a ``ManagerWorkers`` into LangGraph. -The ``AgentSpecToLangGraphConverter`` method -``_manager_workers_convert_to_langgraph`` orchestrates these helpers; the -helpers themselves are pure functions with no dependency on the converter, -which is why they live here rather than bloating the converter module. +``AgentSpecToLangGraphConverter._manager_workers_convert_to_langgraph`` orchestrates +these; they live here to keep the converter module from growing further. + +Nothing hides the routing protocol. ``delegate_to_`` calls stream like any +other tool call, because which worker got which task is usually the most useful thing +a run reports. Consumers that would rather not render it can filter on +:func:`is_delegation_tool_name`. """ -import logging import re -from typing import Any, AsyncGenerator, Dict, Generator, List, Optional, Tuple +from functools import lru_cache +from typing import Any, Dict, List, Tuple +from pyagentspec.adapters.langgraph._execution_span import patch_with_execution_span from pyagentspec.adapters.langgraph._types import CompiledStateGraph, langgraph_graph from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers from pyagentspec.tracing.events import ( @@ -29,76 +32,65 @@ ManagerWorkersExecutionSpan as AgentSpecManagerWorkersExecutionSpan, ) -# ─── ManagerWorkers helpers ────────────────────────────────────────────────── - -# Node key for the manager subgraph in the ManagerWorkers parent StateGraph. -# Chosen so it cannot collide with a normalized worker node name (which is -# always lowercase + [a-z0-9_]). +# Cannot collide with a normalized worker node name, which is always [a-z0-9_]. _MANAGER_NODE_KEY = "__manager__" -# Prefix the manager's LLM uses to address a delegation tool. The suffix is -# the normalized worker node name. -_DELEGATE_TOOL_PREFIX = "delegate_to_" +#: Prefix the manager's LLM uses to address a delegation tool, suffixed with the +#: normalized worker node name. Public so consumers can recognize the protocol. +DELEGATE_TOOL_PREFIX = "delegate_to_" -# Keys carried on the per-delegation ``Send`` payload from the manager's -# routing edge to a worker node, so a worker run knows which task it was -# given and which ``tool_call_id`` its reply ToolMessage must answer. This -# is what lets one manager turn delegate to several workers at once: each -# delegation routes as its own ``Send`` and is answered independently. +# Carried on the per-delegation ``Send`` payload so a worker run knows its task and +# which ``tool_call_id`` its reply must answer. Routing per delegation instead of off +# shared state lets one manager turn delegate to several workers at once. _DELEGATE_TASK_KEY = "__delegate_task__" _DELEGATE_CALL_ID_KEY = "__delegate_tool_call_id__" -# Collapses any run of whitespace to a single space so multi-line worker -# descriptions stay on one roster line. _WHITESPACE_RE = re.compile(r"\s+") +def is_delegation_tool_name(name: Any) -> bool: + """True for the synthetic ``delegate_to_`` tool names a manager emits.""" + return isinstance(name, str) and name.startswith(DELEGATE_TOOL_PREFIX) + + def _normalize_identifier(s: str) -> str: - """Lowercase, collapse non-alphanumerics to underscores, strip surrounding - underscores. The single source of truth for turning a spec name into an - ASCII identifier, so a worker node name and the ``delegate_to_`` - tool name addressing it always agree.""" - return re.sub(r"[^a-z0-9]+", "_", (s or "").lower()).strip("_") + """Lowercase, collapse non-alphanumerics to underscores, strip leading/trailing ones.""" + return re.sub(r"[^a-z0-9]+", "_", s.lower()).strip("_") def _safe_node_name(name: str, fallback_id: str) -> str: """Normalize a worker name into a LangGraph node identifier. - LangGraph node names must be hashable strings; in practice we want - ASCII-friendly identifiers that also work as Python attribute-ish - names (the LLM is going to see ``delegate_to_`` as a tool - name and needs to be able to emit it reliably). We normalize via - :func:`_normalize_identifier`, and fall back to the (component) id — - normalized the same way — if the name yields an empty string. Falling - through both transforms keeps node names internally consistent - regardless of which input wins. + The LLM sees ``delegate_to_`` as a tool name and has to emit it + reliably, so node names stay ASCII identifiers. Falls back to the component id, + normalized the same way, when the name slugifies to nothing. """ return _normalize_identifier(name) or _normalize_identifier(fallback_id) or "worker" def _tc_get(tool_call: Any, key: str) -> Any: - """Read ``key`` off a tool call that may be a dict or a pydantic-style - object (langchain emits either depending on the message source).""" + """Read ``key`` off a tool call, which langchain emits as a dict or an object + depending on the message source.""" if isinstance(tool_call, dict): return tool_call.get(key) return getattr(tool_call, key, None) def _messages_of(state: Any) -> List[Any]: - """Read the ``messages`` list off a state that may be a dict or an - attribute-bearing object (langgraph injects either into a tool).""" + """Read ``messages`` off a state, which langgraph injects as a dict or an object.""" if isinstance(state, dict): return list(state.get("messages") or []) return list(getattr(state, "messages", []) or []) def _surface_to_parent_command(state: Any) -> Any: - """The delegation tool's body: break out of the manager's react loop and - project the subgraph's messages — including the AIMessage carrying the - triggering tool call — onto the PARENT state, carrying **no** ``goto`` - (routing is the parent graph's job). The ``add_messages`` reducer dedupes - by id, so re-surfacing existing messages is a no-op. Modelled on - ``langgraph_swarm.create_handoff_tool``.""" + """Break out of the manager's react loop, projecting the subgraph's messages onto + the parent state (including the AIMessage carrying the triggering tool call). + + Carries no ``goto``: routing is the parent graph's job. The ``add_messages`` + reducer dedupes by id, so re-surfacing existing messages is a no-op. Modelled on + ``langgraph_swarm.create_handoff_tool``. + """ from langgraph.types import Command return Command(graph=Command.PARENT, update={"messages": _messages_of(state)}) @@ -108,12 +100,10 @@ def _append_workers_roster( system_prompt: str, entries: List[Tuple[str, str]], ) -> str: - """Prepend the manager's system prompt with an ``Available workers:`` - roster block listing ``- : `` per worker. + """Append an ``Available workers:`` block listing ``- : ``. - Each description has whitespace flattened so multi-line descriptions - don't corrupt the one-line-per-worker block shape that the LLM relies - on for routing. + Descriptions are flattened to one line each, since the LLM routes off the block's + one-line-per-worker shape. """ if not entries: return system_prompt @@ -124,12 +114,19 @@ def _append_workers_roster( return f"{system_prompt}\n\n{roster}" if system_prompt else roster +@lru_cache(maxsize=256) def _make_worker_delegation_tool(worker_node_name: str) -> Any: """Build the ``delegate_to_`` tool the manager's LLM emits to route to a - worker. The body carries **no** ``goto`` — routing fans out one ``Send`` per - delegation (:func:`_route_manager_to_worker_or_end`); a ``goto`` here would - collapse multiple same-turn delegations into one parent Command, leaving the other + worker. + + The body carries no ``goto``. Routing fans out one ``Send`` per delegation in + :func:`_route_manager_to_worker_or_end`; a ``goto`` here would collapse several + same-turn delegations into one parent Command and leave the other ``tool_call_id``s unanswered. + + Memoized because the tool depends only on the node name and holds no per-graph + state. Without it every compile re-runs the ``@tool`` decorator, which costs a + ``get_type_hints`` pass and a pydantic args-schema build. """ from typing import Annotated @@ -137,41 +134,35 @@ def _make_worker_delegation_tool(worker_node_name: str) -> Any: from langgraph.prebuilt import InjectedState from langgraph.types import Command - tool_name = f"{_DELEGATE_TOOL_PREFIX}{worker_node_name}" + tool_name = f"{DELEGATE_TOOL_PREFIX}{worker_node_name}" + description = ( + f"Delegate a task to the {worker_node_name} worker and receive " + f"its response. Use this when the task fits the worker's " + f"described capability." + ) - @tool(tool_name) + @tool(tool_name, description=description) def _delegate( task: str, state: Annotated[Any, InjectedState], tool_call_id: Annotated[str, InjectedToolCallId], ) -> Command: - """Delegate a task to the named worker and wait for its reply. - - ``task`` is the natural-language instruction the worker should - execute. The worker runs in its own isolated message context; - only this ``task`` is forwarded as the worker's first message. - """ - del task, tool_call_id # recovered from the surfaced AIMessage by the routing edge + # Declared for the LLM-facing schema but unused here: executing is only how the + # call escapes the react subgraph, and the routing edge recovers both off the + # surfaced AIMessage's tool_calls. + del task, tool_call_id return _surface_to_parent_command(state) - _delegate.description = ( - f"Delegate a task to the {worker_node_name} worker and receive " - f"its response. Use this when the task fits the worker's " - f"described capability." - ) return _delegate def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: - """Inspect the manager's last AIMessage and route the parent graph: one - ``Send`` per ``delegate_to_`` tool call, or ``END`` when the manager - emitted none. - - A single manager turn may emit several ``delegate_to_`` calls; each gets its - own ``Send`` carrying the ``task`` + ``tool_call_id``, so every call is answered - independently (an unanswered delegation breaks the manager's next-turn - tool-call/result sequence). Multiple ``Send``s to one worker run independently; plain - tool calls already ran inside the manager's react loop. + """Route the parent graph off the manager's last AIMessage: one ``Send`` per + ``delegate_to_`` tool call, or ``END`` when it emitted none. + + Every delegation gets its own ``Send`` carrying the task and ``tool_call_id``, so + each is answered independently. An unanswered one breaks the manager's next-turn + tool-call/result sequence. Plain tool calls already ran inside the react loop. """ from langgraph.types import Send @@ -183,11 +174,11 @@ def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: sends = [] for tc in tool_calls: name = _tc_get(tc, "name") - if _is_delegate_name(name): + if is_delegation_tool_name(name): args = _tc_get(tc, "args") or {} sends.append( Send( - name[len(_DELEGATE_TOOL_PREFIX) :], + name[len(DELEGATE_TOOL_PREFIX) :], { _DELEGATE_TASK_KEY: args.get("task") or "", _DELEGATE_CALL_ID_KEY: _tc_get(tc, "id") or "", @@ -197,465 +188,86 @@ def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: return sends or langgraph_graph.END -def _wrap_worker_for_subgraph( - worker_graph: CompiledStateGraph[Any, Any, Any], - worker_node_name: str, -) -> Any: - """Wrap a worker subgraph so it runs with an isolated ``messages`` - context (the delegation task only) and its final reply comes back as - a ToolMessage matched to the manager's pending delegation tool-call. - - This is what makes a ManagerWorkers parent graph hierarchical rather - than a shared-state Swarm: workers do NOT see each other's messages, - and only one message — the manager's chosen task — is forwarded to - each worker run. The worker's last AIMessage content is captured as - the ToolMessage content so the manager's react-agent loop sees a - well-formed tool response on the next turn. - - Returns a ``RunnableLambda`` exposing both sync (``func``) and async - (``afunc``) entrypoints — LangGraph picks the right one based on - whether the parent graph is invoked via ``invoke`` or ``ainvoke``. +def _worker_input(state: Dict[str, Any]) -> Dict[str, Any]: + """The single-message context a worker run starts from. + + Passes no explicit config, so the worker inherits this node's ambient run config. + Its ``checkpoint_ns`` (``:``) streams the worker's token + events under the worker node, and the per-superstep namespace keeps repeated + delegations isolated without a fresh thread_id. """ - from langchain_core.messages import HumanMessage, ToolMessage + from langchain_core.messages import HumanMessage - from pyagentspec.adapters.langgraph._types import RunnableLambda + return {"messages": [HumanMessage(content=state.get(_DELEGATE_TASK_KEY) or "")]} - delegate_tool_name = f"{_DELEGATE_TOOL_PREFIX}{worker_node_name}" - - def _extract_pending(state: Dict[str, Any]) -> Tuple[str, str]: - # Fan-out path: the routing edge's ``Send`` payload carries this - # delegation's task and its originating tool_call_id directly, so a - # single manager turn can delegate to this worker more than once - # without the runs colliding on a shared "first pending call". - if isinstance(state, dict) and _DELEGATE_CALL_ID_KEY in state: - return ( - state.get(_DELEGATE_TASK_KEY) or "", - state.get(_DELEGATE_CALL_ID_KEY) or "", - ) - # Direct-edge path (a worker wired in without Send): recover task + - # id from the manager's last AIMessage. Only the first matching call - # is recoverable this way, which is why routing prefers Send. - messages = state.get("messages") or [] - if not messages: - raise RuntimeError(f"Worker '{worker_node_name}' was invoked with empty manager state.") - last_ai = messages[-1] - tool_calls = getattr(last_ai, "tool_calls", None) or [] - pending_call = next( - (tc for tc in tool_calls if _tc_get(tc, "name") == delegate_tool_name), - None, - ) - if pending_call is None: - raise RuntimeError( - f"Worker '{worker_node_name}' was routed to but the manager's " - f"last message has no '{delegate_tool_name}' tool call." - ) - args = _tc_get(pending_call, "args") or {} - call_id = _tc_get(pending_call, "id") or "" - return args.get("task") or "", call_id - - def _tool_message_from(reply: str, call_id: str) -> Dict[str, Any]: - return {"messages": [ToolMessage(content=reply, tool_call_id=call_id)]} - - def _worker_input(task: str) -> Dict[str, Any]: - # Pass NO explicit config so the worker inherits this node's ambient run config: - # its ``checkpoint_ns`` (``:``) is what streams the worker's - # token events under the worker node, and the distinct per-superstep namespace - # keeps repeated delegations isolated without a fresh thread_id. - return {"messages": [HumanMessage(content=task)]} - - def _last_message_content(result: Any) -> str: - messages = result.get("messages") if isinstance(result, dict) else None - if not messages: - return "" - return getattr(messages[-1], "content", "") or "" - - def _run_sync(state: Dict[str, Any]) -> Dict[str, Any]: - task, call_id = _extract_pending(state) - result = worker_graph.invoke(_worker_input(task)) - return _tool_message_from(_last_message_content(result), call_id) - - async def _run_async(state: Dict[str, Any]) -> Dict[str, Any]: - task, call_id = _extract_pending(state) - result = await worker_graph.ainvoke(_worker_input(task)) - return _tool_message_from(_last_message_content(result), call_id) - - return RunnableLambda( - func=_run_sync, - afunc=_run_async, - name=f"worker:{worker_node_name}", - ) +def _worker_reply(state: Dict[str, Any], result: Any) -> Dict[str, Any]: + """The worker's last message, as a ToolMessage answering this delegation.""" + from langchain_core.messages import ToolMessage -# ─── ManagerWorkers: hide the delegation protocol from astream_events ───────── + messages = result.get("messages") if isinstance(result, dict) else None + content = (getattr(messages[-1], "content", "") if messages else "") or "" + return { + "messages": [ + ToolMessage(content=content, tool_call_id=state.get(_DELEGATE_CALL_ID_KEY) or "") + ] + } -def _is_delegate_name(name: Any) -> bool: - """True if ``name`` is one of the synthetic ``delegate_to_`` - tool names the manager emits to route to a worker.""" - return isinstance(name, str) and name.startswith(_DELEGATE_TOOL_PREFIX) +class _WorkerSubgraphNode: + """Runs one worker subgraph as a node of the ManagerWorkers parent graph. + Hierarchical rather than shared-state like a Swarm: workers never see each other's + messages, and each run is handed only the manager's chosen task. The worker's last + message comes back as the ToolMessage content, so the manager's react loop sees a + well-formed tool response on its next turn. -def _is_delegate_tool_message(msg: Any, delegate_call_ids: "set") -> bool: - """True if ``msg`` is the worker's synthetic reply ToolMessage — i.e. a - ToolMessage answering a (now-hidden) delegation tool-call id.""" - return ( - getattr(msg, "type", None) == "tool" - and getattr(msg, "tool_call_id", None) in delegate_call_ids - ) + A class rather than a closure, so the long-lived node holds only the graph instead + of keeping a whole factory frame alive. + """ + __slots__ = ("_graph",) -def _scrubbed_ai_message( - msg: Any, - delegate_indices: "set", - delegate_call_ids: "set", -) -> Tuple[Optional[Any], bool]: - """Return ``(scrubbed_copy_or_None, is_empty)`` for an AIMessage(Chunk), - removing every ``delegate_to_`` tool call. - - ``scrubbed_copy_or_None`` is ``None`` when the message carried no - delegation artifact (the caller emits it unchanged). ``is_empty`` is - ``True`` when, after removal, nothing renderable remains (no content and - no other tool calls) — the caller drops the event. - - Never mutates ``msg``: the same object lives in the graph's message - state, where the manager react loop relies on the delegation - tool-call / tool-result pair staying intact. ``delegate_indices`` tracks - streamed tool-call positions so argument-continuation chunks (which - carry no ``name``) are stripped too; ``delegate_call_ids`` collects the - call ids so the worker's matching ToolMessage can be dropped later. - """ - changed = False - - # Provider-native streamed tool calls (e.g. OpenAI) ride along in - # ``additional_kwargs['tool_calls']`` and stream by index with the name - # only on the opening delta — match by name or by a known delegate index. - additional = getattr(msg, "additional_kwargs", None) or {} - new_additional = additional - raw_calls = additional.get("tool_calls") - if raw_calls: - kept_raw = [] - for tc in raw_calls: - index = tc.get("index") if isinstance(tc, dict) else None - function = (tc.get("function") or {}) if isinstance(tc, dict) else {} - fname = function.get("name") - if _is_delegate_name(fname) or (not fname and index in delegate_indices): - if index is not None: - delegate_indices.add(index) - if isinstance(tc, dict) and tc.get("id"): - delegate_call_ids.add(tc["id"]) - changed = True - else: - kept_raw.append(tc) - if len(kept_raw) != len(raw_calls): - new_additional = dict(additional) - if kept_raw: - new_additional["tool_calls"] = kept_raw - else: - new_additional.pop("tool_calls", None) - - # AIMessageChunk: ``tool_call_chunks`` is the source of truth and - # ``tool_calls`` / ``invalid_tool_calls`` are *derived* from it, so we - # rebuild the chunk (which re-runs that derivation) rather than copying — - # otherwise a stale derived ``tool_calls`` entry survives the strip. - if hasattr(msg, "tool_call_chunks"): - kept_chunks = [] - for chunk in getattr(msg, "tool_call_chunks", None) or []: - cname, cindex = chunk.get("name"), chunk.get("index") - if _is_delegate_name(cname) or (cname is None and cindex in delegate_indices): - if cindex is not None: - delegate_indices.add(cindex) - if chunk.get("id"): - delegate_call_ids.add(chunk["id"]) - changed = True - else: - kept_chunks.append(chunk) - if not changed: - return None, False - scrubbed = type(msg)( - content=msg.content, - additional_kwargs=new_additional, - response_metadata=getattr(msg, "response_metadata", None) or {}, - tool_call_chunks=kept_chunks, - id=getattr(msg, "id", None), - name=getattr(msg, "name", None), - usage_metadata=getattr(msg, "usage_metadata", None), - ) - has_remaining = ( - bool(scrubbed.content) - or bool(scrubbed.tool_call_chunks) - or bool((scrubbed.additional_kwargs or {}).get("tool_calls")) - ) - return scrubbed, not has_remaining - - # Full AIMessage: ``tool_calls`` is the source of truth. - update: Dict[str, Any] = {} - for attr in ("tool_calls", "invalid_tool_calls"): - items = getattr(msg, attr, None) - if items: - kept = [] - for tc in items: - if _is_delegate_name(_tc_get(tc, "name")): - cid = _tc_get(tc, "id") - if cid: - delegate_call_ids.add(cid) - changed = True - else: - kept.append(tc) - if len(kept) != len(items): - update[attr] = kept - if new_additional is not additional: - update["additional_kwargs"] = new_additional - if not changed: - return None, False - - scrubbed = msg.model_copy(update=update) - has_remaining = ( - bool(getattr(scrubbed, "content", None)) - or bool(getattr(scrubbed, "tool_calls", None)) - or bool((getattr(scrubbed, "additional_kwargs", None) or {}).get("tool_calls")) - ) - return scrubbed, not has_remaining - - -def _scrub_payload_messages( - payload: Any, - delegate_call_ids: "set", -) -> Tuple[Any, bool]: - """For a node / state payload shaped ``{"messages": [...]}``, remove the - whole delegation protocol so it never surfaces in a consumer-facing - message snapshot: drop the worker's synthetic reply ToolMessage(s) AND - strip the synthetic ``delegate_to_`` tool calls off the manager's - AIMessage(s), dropping an AIMessage that is left empty (a pure delegation - turn). - - Stripping the tool calls — not just the ToolMessages — is what keeps a - downstream message snapshot consistent. A consumer that builds its - message history from an ``on_chain_end`` state payload (e.g. the AG-UI - MESSAGES_SNAPSHOT) reads ``tool_calls`` straight off the AIMessage; if we - dropped only the reply ToolMessages, the snapshot would carry delegate - tool calls whose results are gone, which renders as a "tool call with no - result". Messages are walked in order, so a delegation AIMessage records - its call ids before its reply ToolMessages are tested for removal. - - Returns ``(payload, drop_event)``: ``payload`` is a new dict when - anything changed (the original is never mutated), otherwise the object - passed in. ``drop_event`` is ``True`` when scrubbing empties the - ``messages`` list, so the caller drops the whole event. - """ - if not isinstance(payload, dict): - return payload, False - messages = payload.get("messages") - if not isinstance(messages, list) or not messages: - return payload, False - kept: List[Any] = [] - changed = False - for m in messages: - # The worker's reply ToolMessage — pure delegation plumbing. - if _is_delegate_tool_message(m, delegate_call_ids): - changed = True - continue - # An AIMessage may carry delegate tool calls; strip them and drop the - # message if nothing renderable remains. Non-delegation messages - # (real tool calls/results, plain content) are left untouched. - if hasattr(m, "tool_calls"): - scrubbed, is_empty = _scrubbed_ai_message(m, set(), delegate_call_ids) - if scrubbed is not None: - changed = True - if not is_empty: - kept.append(scrubbed) - continue - kept.append(m) - if not changed: - return payload, False - new_payload = dict(payload) - new_payload["messages"] = kept - return new_payload, len(kept) == 0 - - -class _DelegationEventFilter: - """Stateful scrubber for a single ``astream_events`` stream. - - Removes the synthetic ``delegate_to_`` routing protocol — the - delegation tool calls, their ``on_tool_*`` lifecycle events, and the - worker's matching reply ToolMessage — from the consumer-facing event - view. The graph's message state is never touched, so the manager react - loop still sees its well-formed tool-call / tool-result exchange. - """ + def __init__(self, worker_graph: CompiledStateGraph[Any, Any, Any]) -> None: + self._graph = worker_graph - def __init__(self) -> None: - # Streamed tool-call positions per chat-model run that belong to a - # delegation call, so argument-continuation chunks (name=None) are - # stripped along with the opening chunk. - self._delegate_indices_by_run: Dict[str, "set"] = {} - # Delegate tool-call ids seen so far, so the worker's reply - # ToolMessage can be dropped when it surfaces downstream. - self._delegate_call_ids: "set" = set() - - def scrub(self, event: Dict[str, Any]) -> Optional[Dict[str, Any]]: - etype = event.get("event") - name = event.get("name", "") - - # 1. Drop the tool lifecycle events for the delegation tools. - if etype in ("on_tool_start", "on_tool_end", "on_tool_error") and _is_delegate_name(name): - return None - - data = event.get("data") or {} - - # 2. Strip delegate tool calls from streamed / final manager AIMessages. - if etype in ("on_chat_model_stream", "on_chat_model_end"): - key = "chunk" if etype == "on_chat_model_stream" else "output" - msg = data.get(key) - if msg is not None and hasattr(msg, "tool_calls"): - run_id = event.get("run_id", "") - indices = self._delegate_indices_by_run.setdefault(run_id, set()) - scrubbed, is_empty = _scrubbed_ai_message(msg, indices, self._delegate_call_ids) - if scrubbed is not None: - # A streamed chunk that became empty is pure delegation - # plumbing — drop it. A final ``on_chat_model_end`` is kept - # (scrubbed) so consumers still get a turn-end marker. - if is_empty and etype == "on_chat_model_stream": - return None - new_data = dict(data) - new_data[key] = scrubbed - new_event = dict(event) - new_event["data"] = new_data - return new_event - return event - - # 3. Drop the worker's synthetic reply ToolMessage wherever it - # surfaces in a node payload. - new_data: Optional[Dict[str, Any]] = None - should_drop = False - for key in ("chunk", "output", "input"): - if key in data: - scrubbed_payload, drop_event = _scrub_payload_messages( - data[key], self._delegate_call_ids - ) - if scrubbed_payload is not data[key]: - if new_data is None: - new_data = dict(data) - new_data[key] = scrubbed_payload - if drop_event: - should_drop = True - if should_drop: - return None - if new_data is not None: - new_event = dict(event) - new_event["data"] = new_data - return new_event - return event - - -def _patch_hide_delegation_in_astream_events( - compiled_graph: CompiledStateGraph[Any, Any, Any], -) -> None: - """Wrap ``astream_events`` so the synthetic ``delegate_to_`` - routing protocol never reaches the consumer. - - ManagerWorkers routes by having the manager react-agent emit a - ``delegate_to_`` tool call, which the worker answers with a - ToolMessage matched to that call id. That pair is load-bearing for the - manager's react loop (it must observe a well-formed tool-call / - tool-result exchange) but it is internal plumbing the consumer should - never see as phantom tool calls. We filter only the emitted events; the - graph's message state is untouched, so the loop is unaffected. The - workers' real LLM/token events still propagate (they reach the consumer - via callback propagation through the isolated worker run), so this - strips the routing noise without hiding the workers' actual output. + def run(self, state: Dict[str, Any]) -> Dict[str, Any]: + return _worker_reply(state, self._graph.invoke(_worker_input(state))) + + async def arun(self, state: Dict[str, Any]) -> Dict[str, Any]: + return _worker_reply(state, await self._graph.ainvoke(_worker_input(state))) + + +def _wrap_worker_for_subgraph( + worker_graph: CompiledStateGraph[Any, Any, Any], + worker_node_name: str, +) -> Any: + """Wrap a worker subgraph as a node exposing sync and async entrypoints. + + LangGraph picks between them depending on whether the parent graph was invoked + via ``invoke`` or ``ainvoke``. """ - original_astream_events = compiled_graph.astream_events - - async def patched_astream_events(*args: Any, **kwargs: Any) -> AsyncGenerator[Any, None]: - event_filter = _DelegationEventFilter() - async for event in original_astream_events(*args, **kwargs): - if not isinstance(event, dict): - yield event - continue - # Fail open: a scrubbing bug must never tear down the stream - # (which would swallow every later event — notably the worker - # events that follow the manager's delegation turn). On error we - # emit the event unfiltered rather than dropping the rest. - try: - kept = event_filter.scrub(event) - except Exception: # noqa: BLE001 — defensive, see above - logging.getLogger("pyagentspec.adapters.langgraph").warning( - "ManagerWorkers astream_events delegation filter raised; " - "passing the event through unfiltered.", - exc_info=True, - ) - yield event - continue - if kept is not None: - yield kept + from pyagentspec.adapters.langgraph._types import RunnableLambda - compiled_graph.astream_events = patched_astream_events # type: ignore[assignment] + node = _WorkerSubgraphNode(worker_graph) + return RunnableLambda(func=node.run, afunc=node.arun, name=f"worker:{worker_node_name}") def _patch_with_manager_workers_execution_span( compiled_graph: CompiledStateGraph[Any, Any, Any], mw: AgentSpecManagerWorkers, ) -> None: - """Wrap ``stream`` / ``astream`` so each ManagerWorkers run emits a - ``ManagerWorkersExecutionSpan`` with Start/End events. Mirrors the - patches applied to Agent and Flow compiled graphs elsewhere in this - converter. - """ - original_stream = compiled_graph.stream - original_astream = compiled_graph.astream - - def _coerce_inputs(kwargs: Dict[str, Any]) -> Dict[str, Any]: - inputs = kwargs.get("input", {}) - return inputs if isinstance(inputs, dict) else {} - - def patched_stream(*args: Any, **kwargs: Any) -> Generator[Any, Any, None]: - span_name = f"ManagerWorkersExecution[{mw.name}]" - inputs = _coerce_inputs(kwargs) - with AgentSpecManagerWorkersExecutionSpan(name=span_name, managerworkers=mw) as span: - span.add_event(AgentSpecManagerWorkersExecutionStart(managerworkers=mw, inputs=inputs)) - last_chunk: Dict[str, Any] = {} - for chunk in original_stream(*args, **kwargs): - yield chunk - if isinstance(chunk, tuple) and isinstance(chunk[1], dict): - last_chunk = chunk[1] - span.add_event( - AgentSpecManagerWorkersExecutionEnd( - managerworkers=mw, - outputs={"messages": last_chunk.get("messages", [])}, - ) - ) - - async def patched_astream(*args: Any, **kwargs: Any) -> AsyncGenerator[Any, Any]: - span_name = f"ManagerWorkersExecution[{mw.name}]" - inputs = _coerce_inputs(kwargs) - span = AgentSpecManagerWorkersExecutionSpan(name=span_name, managerworkers=mw) - try: - await span.start_async() - except NotImplementedError: - span.start() - try: - start_event = AgentSpecManagerWorkersExecutionStart(managerworkers=mw, inputs=inputs) - try: - await span.add_event_async(start_event) - except NotImplementedError: - span.add_event(start_event) - last_chunk: Dict[str, Any] = {} - async for chunk in original_astream(*args, **kwargs): - yield chunk - if isinstance(chunk, tuple) and isinstance(chunk[1], dict): - last_chunk = chunk[1] - end_event = AgentSpecManagerWorkersExecutionEnd( - managerworkers=mw, - outputs={"messages": last_chunk.get("messages", [])}, - ) - try: - await span.add_event_async(end_event) - except NotImplementedError: - span.add_event(end_event) - finally: - try: - await span.end_async() - except NotImplementedError: - span.end() - - compiled_graph.stream = patched_stream # type: ignore[assignment] - compiled_graph.astream = patched_astream # type: ignore[assignment] + """Wrap ``stream``/``astream`` so each run emits a ``ManagerWorkersExecutionSpan``, + using the same patcher as the Agent and Flow graphs.""" + patch_with_execution_span( + compiled_graph, + make_span=lambda: AgentSpecManagerWorkersExecutionSpan( + name=f"ManagerWorkersExecution[{mw.name}]", managerworkers=mw + ), + make_start_event=lambda inputs: AgentSpecManagerWorkersExecutionStart( + managerworkers=mw, inputs=inputs + ), + make_end_event=lambda result: AgentSpecManagerWorkersExecutionEnd( + managerworkers=mw, outputs={"messages": result.get("messages", [])} + ), + ) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index 8619545e..f8fb156e 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -500,6 +500,16 @@ def __init__( self._middleware: List[Any] = list(middleware or []) self._agents_cache: Dict[str, CompiledStateGraph[Any, Any]] = {} + def _conversion_kwargs(self) -> Dict[str, Any]: + """The converter arguments every compile from this executor passes through.""" + return { + "tool_registry": self.tool_registry, + "converted_components": self.converted_components, + "checkpointer": self.checkpointer, + "config": self.config, + "middleware": self._middleware, + } + def _create_react_agent_with_given_input_values( self, inputs: Dict[str, Any] ) -> CompiledStateGraph[Any, Any]: @@ -522,64 +532,49 @@ def _create_react_agent_with_given_input_values( toolboxes=agentspec_component.toolboxes, inputs=agentspec_component.inputs or [], outputs=agentspec_component.outputs or [], - tool_registry=self.tool_registry, - converted_components=self.converted_components, - checkpointer=self.checkpointer, - config=self.config, - middleware=self._middleware, + **self._conversion_kwargs(), ) return self._agents_cache[system_prompt] - def _create_composite_graph_with_given_input_values( - self, inputs: Dict[str, Any] + def _create_manager_workers_with_given_input_values( + self, component: AgentSpecManagerWorkers, inputs: Dict[str, Any] ) -> CompiledStateGraph[Any, Any]: - """Compile the node's ``ManagerWorkers`` into a runnable graph for these inputs, - cached by the rendered group-manager prompt. - - Such a graph runs over ``MessagesState`` and can't carry structured inputs to its - inner agents, so the node inputs are baked into the ``group_manager``'s - ``system_prompt`` and the now-satisfied input ports dropped, so declared == - inferred for the downstream span re-validation. A non-Agent group manager is - passed through unchanged so the converter raises its own clear error. + """Compile a ``ManagerWorkers`` that this node runs as a flow step. + + The graph runs over ``MessagesState``, which can't carry structured inputs + inward to the group manager, so the node inputs are rendered into its + ``system_prompt`` and the satisfied ports dropped from both the manager and + the component. That keeps declared and inferred ports equal for the + downstream span re-validation, and the graph runs on messages alone. + + Cached by rendered prompt, the same key + :meth:`_create_react_agent_with_given_input_values` uses. """ - from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, - ) + from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter converter = AgentSpecToLangGraphConverter() - component = self.node.agent - if not isinstance(component, AgentSpecManagerWorkers): - raise TypeError( - "_create_composite_graph_with_given_input_values requires a ManagerWorkers" + entry_agent = component.group_manager + if not isinstance(entry_agent, AgentSpecAgent): + # Not routable. Nothing to render or cache, and the converter owns the + # error message for this case. + return converter._manager_workers_convert_to_langgraph( + component, **self._conversion_kwargs() ) - entry_agent = component.group_manager - is_agent_entry = isinstance(entry_agent, AgentSpecAgent) - cache_key = ( - render_template(entry_agent.system_prompt, inputs) if is_agent_entry else component.id - ) - if cache_key not in self._agents_cache: - rendered = ( - component.model_copy( - update={ - "group_manager": entry_agent.model_copy( - update={"system_prompt": cache_key, "inputs": []} - ), - "inputs": [], - } - ) - if is_agent_entry - else component + system_prompt = render_template(entry_agent.system_prompt, inputs) + if system_prompt not in self._agents_cache: + rendered = component.model_copy( + update={ + "group_manager": entry_agent.model_copy( + update={"system_prompt": system_prompt, "inputs": []} + ), + "inputs": [], + } ) - self._agents_cache[cache_key] = converter._manager_workers_convert_to_langgraph( - rendered, - tool_registry=self.tool_registry, - converted_components=self.converted_components, - checkpointer=self.checkpointer, - config=self.config, - middleware=self._middleware, + self._agents_cache[system_prompt] = converter._manager_workers_convert_to_langgraph( + rendered, **self._conversion_kwargs() ) - return self._agents_cache[cache_key] + return self._agents_cache[system_prompt] def _prepare_agent_and_inputs( self, inputs: Dict[str, Any], messages: Messages @@ -590,11 +585,13 @@ def _prepare_agent_and_inputs( # user message when the message list is empty. if not messages: messages = cast(Messages, [{"role": "user", "content": ""}]) - if isinstance(self.node.agent, AgentSpecManagerWorkers): - # A ManagerWorkers flow step runs as a hierarchical graph over MessagesState: - # node inputs were baked into the group-manager's prompt, so the graph is - # driven by messages alone (not the agent's remaining_steps state). - graph = self._create_composite_graph_with_given_input_values(inputs) + agentspec_component = self.node.agent + if isinstance(agentspec_component, AgentSpecManagerWorkers): + # Inputs were baked into the group-manager's prompt, so this graph runs on + # messages alone rather than the agent's remaining_steps state. + graph = self._create_manager_workers_with_given_input_values( + agentspec_component, inputs + ) return graph, {"messages": messages} agent = self._create_react_agent_with_given_input_values(inputs) inputs |= { diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index 130ad4de..8b4c3b32 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -69,20 +69,21 @@ class ManagerWorkers(AgenticComponent): def _get_inferred_inputs(self) -> List[Property]: """A ``ManagerWorkers`` exposes the inputs of its group manager. - The group manager is the component that drives the conversation and whose prompt - the run-time renders, so the manager-workers component accepts exactly the inputs - the group manager accepts (e.g. the ``{{placeholder}}`` inputs of an ``Agent`` - group manager). Without this, the base default infers no inputs, so a - ``ManagerWorkers`` used as a flow ``AgentNode`` would expose no input ports and a - data-flow edge into it could not resolve. + The group manager drives the conversation and is the component whose prompt the + runtime renders, so the group accepts exactly the inputs the manager accepts + (for an ``Agent`` manager, its ``{{placeholder}}`` inputs). The base default + infers none, which would leave a ``ManagerWorkers`` used as a flow ``AgentNode`` + with no input ports for a data-flow edge to resolve against. + + The ``hasattr`` guard matches :meth:`Flow._get_inferred_inputs` and + :meth:`AgentNode._get_inferred_inputs`: error-accumulating validators can run + this against a partially-constructed model with no ``group_manager`` assigned. """ - group_manager = getattr(self, "group_manager", None) - return list(getattr(group_manager, "inputs", None) or []) + return (self.group_manager.inputs or []) if hasattr(self, "group_manager") else [] def _get_inferred_outputs(self) -> List[Property]: """Outputs of the group manager; see :meth:`_get_inferred_inputs`.""" - group_manager = getattr(self, "group_manager", None) - return list(getattr(group_manager, "outputs", None) or []) + return (self.group_manager.outputs or []) if hasattr(self, "group_manager") else [] @model_validator_with_error_accumulation def _validate_one_or_more_workers(self) -> Self: diff --git a/pyagentspec/tests/adapters/conftest.py b/pyagentspec/tests/adapters/conftest.py index d5732212..b999abc7 100644 --- a/pyagentspec/tests/adapters/conftest.py +++ b/pyagentspec/tests/adapters/conftest.py @@ -6,6 +6,8 @@ import os import ssl +from contextlib import ExitStack +from importlib import import_module from pathlib import Path from typing import Any from unittest.mock import patch @@ -112,6 +114,41 @@ def _skip(*_args, **_kwargs): p.stop() +def _resolve(dotted: str) -> Any: + module_path, _, attr = dotted.rpartition(".") + module_path, _, cls_name = module_path.rpartition(".") + return getattr(getattr(import_module(module_path), cls_name), attr) + + +# Captured at conftest import, before any session fixture starts patching, so these +# are the genuine constructors rather than a skip stub. +_REAL_LLM_INITS = {dotted: _resolve(dotted) for dotted in LLM_MOCKED_METHODS} + + +@pytest.fixture +def allow_llm_config_construction(): + """Opt out of the blanket ``SKIP_LLM_TESTS=1`` construction guard. + + That guard skips a test the moment it constructs an LLM config. Right for tests + that go on to call a model, wrong for tests that only need a config object and + stub the conversion: those should run offline, and instead they skip silently in + CI, leaving the code path they cover unverified. + + Restores the real constructors for one test, overriding the guards in both this + conftest and ``tests/conftest.py``. + + Only request this from a test that provably never reaches a model endpoint. + """ + if not should_skip_llm_test(): + # Nothing patched the constructors, so there is nothing to restore. + yield + return + with ExitStack() as stack: + for dotted, real in _REAL_LLM_INITS.items(): + stack.enter_context(patch(dotted, new=real)) + yield + + @pytest.fixture(scope="package") def json_server(json_server_port: int): api_server = Path(__file__).parent / "api_server.py" diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py index d38d0005..35ea5e5a 100644 --- a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py +++ b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py @@ -15,11 +15,20 @@ manager's prompt and returning its result. """ +import pytest + from pyagentspec.agent import Agent from pyagentspec.managerworkers import ManagerWorkers from pyagentspec.property import StringProperty +@pytest.fixture(autouse=True) +def _offline(allow_llm_config_construction: None) -> None: + """These tests only need an LLM *config* object: the two inference tests never + convert at all, and the flow-step test stubs the chat model. Without this the + SKIP_LLM_TESTS guard skips all three and the flow-step path goes unverified.""" + + def test_managerworkers_infers_inputs_from_group_manager_prompt() -> None: """A ManagerWorkers exposes the group manager's prompt placeholders as inputs.""" llm = {"name": "m", "model_id": "fake", "url": "null"} @@ -58,12 +67,12 @@ def test_managerworkers_infers_outputs_from_group_manager() -> None: def test_managerworkers_runs_as_a_flow_step_with_data_edge_inputs() -> None: - """A ManagerWorkers flow step loads (data edge resolves) and executes offline. + """A ManagerWorkers flow step loads with its data edge resolved, and executes. - The model is stubbed (no real LLM, no delegation), so the manager produces a final - message and the manager graph routes straight to END. Asserts the flow both loads — - proving the manager node exposes the ``joke`` input the data edge targets — and runs, - surfacing the manager's answer as the node's single string output. + The model is stubbed, so there is no delegation: the manager produces a final + message and routes straight to END. Loading proves the manager node exposes the + ``joke`` input the data edge targets; running proves the manager's answer comes + back as the node's single string output. """ from unittest.mock import patch diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index 769f92c6..0f9f28d2 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -21,6 +21,13 @@ # ─── Shared helpers ────────────────────────────────────────────────────────── +@pytest.fixture(autouse=True) +def _offline(allow_llm_config_construction: None) -> None: + """Every test in this module stubs the chat model (``_fake_manager`` or an + explicitly patched ``_llm_convert_to_langgraph``) and never reaches an + endpoint, so the SKIP_LLM_TESTS construction guard would only hide them.""" + + def _llm_cfg(name: str) -> Any: from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig @@ -39,6 +46,39 @@ class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): return _FakeModel(responses=list(ai_responses)) +def _load_with_fake_llms(mw: Any, default: Any = None, **fakes_by_llm_name: Any) -> Any: + """Compile ``mw`` offline, answering each LLM config with a queued fake. + + Keys are ``llm_config.name``; ``default`` answers any config not named. + + ``create_agent`` calls ``model.bind_tools(...)``, and ``FakeMessagesListChatModel`` + inherits ``bind_tools`` from the real ``ChatOpenAI``, which calls out to OpenAI. + Binding is stubbed to return the same fake, preserving its response queue. + """ + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter + + def _dispatch(_self: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: + fake = fakes_by_llm_name.get(llm_config.name, default) + if fake is None: + raise AssertionError(f"unexpected llm_config: {llm_config.name}") + return fake + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=_dispatch, + ), patch.object( + FakeMessagesListChatModel, "bind_tools", new=lambda self_obj, *a, **kw: self_obj + ): + return loader.load_component(mw) + + # ─── Pure-helper unit tests (no LLM) ──────────────────────────────────────── @@ -161,13 +201,8 @@ def test_route_manager_to_worker_or_end_fans_out_every_delegation() -> None: def test_manager_workers_compiles_to_hierarchical_graph_topology() -> None: from langchain_core.messages import AIMessage - from langgraph.checkpoint.memory import MemorySaver from langgraph.graph import START - from pyagentspec.adapters.langgraph import AgentSpecLoader - from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, - ) from pyagentspec.adapters.langgraph._managerworkers import ( _MANAGER_NODE_KEY, ) @@ -198,15 +233,8 @@ def test_manager_workers_compiles_to_hierarchical_graph_topology() -> None: workers=[worker_a, worker_b], ) - fake_llm = _fake_manager(AIMessage(content="Done.")) - loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) - with patch.object( - AgentSpecToLangGraphConverter, "_llm_convert_to_langgraph", return_value=fake_llm - ): - compiled = loader.load_component(mw) + compiled = _load_with_fake_llms(mw, default=_fake_manager(AIMessage(content="Done."))) - # The compiled object is a CompiledStateGraph; its builder exposes - # the parent topology we expect. builder = compiled.builder assert _MANAGER_NODE_KEY in builder.nodes assert "research_helper" in builder.nodes @@ -218,23 +246,19 @@ def test_manager_workers_compiles_to_hierarchical_graph_topology() -> None: assert ("research_helper", _MANAGER_NODE_KEY) in edge_pairs assert ("drafter", _MANAGER_NODE_KEY) in edge_pairs - # The manager → worker routing is a conditional edge (branch), not a - # plain edge — branches are stored separately on the builder. + # Manager → worker is a conditional edge, and branches live separately from + # plain edges on the builder. branches = builder.branches.get(_MANAGER_NODE_KEY) or {} assert branches, "expected a conditional branch from the manager node" -def test_manager_workers_renders_workers_roster_into_manager_prompt() -> None: - """The manager's system prompt gets the ``Available workers:`` block - appended so the LLM knows which delegation tool maps to which worker. +def test_manager_workers_registers_a_delegation_tool_per_worker() -> None: + """Each worker gets a ``delegate_to_`` tool on the manager, matching the + ``Available workers:`` roster the converter renders into its system prompt (the + roster text itself is covered by the ``_append_workers_roster`` unit tests). """ from langchain_core.messages import AIMessage - from langgraph.checkpoint.memory import MemorySaver - from pyagentspec.adapters.langgraph import AgentSpecLoader - from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, - ) from pyagentspec.adapters.langgraph._managerworkers import ( _MANAGER_NODE_KEY, ) @@ -259,33 +283,11 @@ def test_manager_workers_renders_workers_roster_into_manager_prompt() -> None: workers=[worker], ) - fake_llm = _fake_manager(AIMessage(content="Done.")) - loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) - with patch.object( - AgentSpecToLangGraphConverter, "_llm_convert_to_langgraph", return_value=fake_llm - ): - compiled = loader.load_component(mw) + compiled = _load_with_fake_llms(mw, default=_fake_manager(AIMessage(content="Done."))) - # The manager react-agent is itself a subgraph; its create_agent - # middleware stack carries the rendered system prompt as the - # first message of every turn. Walk the manager subgraph's pre-model - # hook chain to find it. + # The manager react-agent is itself a subgraph; the delegation tool the roster + # advertises is registered on its tools node, so the LLM has the matching contract. manager_subgraph = compiled.builder.nodes[_MANAGER_NODE_KEY].runnable - # `create_agent` builds a graph whose system message generation - # wraps the prompt — easier to assert by re-rendering it through the - # same helper used by the converter and checking the *intent*. - from pyagentspec.adapters.langgraph._managerworkers import ( - _append_workers_roster, - ) - - expected = _append_workers_roster( - "Coordinate the team.", - [("research_helper", "Handles research tasks")], - ) - assert "Available workers:" in expected - assert "- research_helper: Handles research tasks" in expected - # And the compiled manager carries the delegation tool the prompt - # advertises, proving the LLM has the matching contract. tools_node = manager_subgraph.builder.nodes["tools"].runnable assert "delegate_to_research_helper" in tools_node.tools_by_name @@ -294,20 +296,15 @@ def test_manager_workers_renders_workers_roster_into_manager_prompt() -> None: def test_manager_workers_delegates_and_routes_back_with_tool_message() -> None: - """End-to-end: manager LLM emits a delegate_to_ tool call, - the parent graph routes to the worker subgraph (which runs with an - isolated message context), the worker's final AIMessage content is - surfaced back to the manager as a ToolMessage matched to the - pending tool_call_id, and the manager's next turn (no tool call) - terminates the graph. This is the load-bearing path that proves the - subgraph composition actually works.""" + """End to end, the path that proves subgraph composition works. + + The manager emits a delegate_to_ call, the parent graph routes to the + worker subgraph in an isolated message context, the worker's answer comes back + as a ToolMessage matched to the pending tool_call_id, and the manager's next + turn terminates the graph. + """ from langchain_core.messages import AIMessage, HumanMessage - from langgraph.checkpoint.memory import MemorySaver - from pyagentspec.adapters.langgraph import AgentSpecLoader - from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, - ) from pyagentspec.agent import Agent from pyagentspec.managerworkers import ManagerWorkers @@ -347,62 +344,29 @@ def test_manager_workers_delegates_and_routes_back_with_tool_message() -> None: # Worker turn 1: produce its own final answer. worker_responses = [AIMessage(content="Saturn has rings.")] - fake_manager = _fake_manager(*manager_responses) - fake_worker = _fake_manager(*worker_responses) - - def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: - if llm_config.name == "manager_llm": - return fake_manager - if llm_config.name == "worker_llm": - return fake_worker - raise AssertionError(f"unexpected llm_config: {llm_config.name}") - - # ``create_agent`` calls ``model.bind_tools(...)``. ``FakeMessagesListChatModel`` - # inherits ``bind_tools`` from real ``ChatOpenAI``, which calls out to - # OpenAI. Patch the class method so binding is a no-op that returns the - # same fake (preserving its response queue). - from langchain_core.language_models.fake_chat_models import ( - FakeMessagesListChatModel, + compiled = _load_with_fake_llms( + mw, + manager_llm=_fake_manager(*manager_responses), + worker_llm=_fake_manager(*worker_responses), ) - loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) - with patch.object( - AgentSpecToLangGraphConverter, - "_llm_convert_to_langgraph", - autospec=True, - side_effect=_dispatch, - ), patch.object( - FakeMessagesListChatModel, - "bind_tools", - new=lambda self_obj, *a, **kw: self_obj, - ): - compiled = loader.load_component(mw) - - # Use the sync invocation path: ``FakeMessagesListChatModel`` provides - # a sync ``_generate`` (returns queued responses) but no async - # override, so MRO resolves ``_agenerate`` to the real - # ``ChatOpenAI._agenerate`` which calls the OpenAI API. The worker - # wrapper exposes both sync and async via RunnableLambda; LangGraph - # picks the sync path here. + # Sync invocation only. FakeMessagesListChatModel overrides ``_generate`` but not + # ``_agenerate``, so the async path would resolve up the MRO to the real + # ``ChatOpenAI._agenerate`` and call OpenAI. result = compiled.invoke( {"messages": [HumanMessage(content="Tell me about Saturn.")]}, {"configurable": {"thread_id": "mw-1"}}, ) messages = result["messages"] - # The end state should contain: user input, manager's delegation - # AIMessage, the synthesized ToolMessage (worker's reply), and the - # manager's final AIMessage. msg_types = [type(m).__name__ for m in messages] assert "HumanMessage" in msg_types assert "ToolMessage" in msg_types - # Final message is the manager's terminating AIMessage. assert isinstance(messages[-1], AIMessage) assert "Saturn has rings" in messages[-1].content - # And the ToolMessage carries the worker's reply matched to the - # pending delegation tool_call_id — proves the isolation wrapper - # threaded the call id through. + # Matching the pending delegation id proves the isolation wrapper threaded the + # call id through. tool_msgs = [m for m in messages if type(m).__name__ == "ToolMessage"] assert tool_msgs and tool_msgs[0].tool_call_id == "call_1" assert "Saturn has rings" in tool_msgs[0].content @@ -414,21 +378,12 @@ def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: must run and be answered by its own ToolMessage matched to the originating tool_call_id. - Before the fix the parent graph routed only the first delegation, so the - other tool_call_ids were left unanswered — an invalid tool-call / - tool-result sequence that made the manager hallucinate the missing - replies. This asserts all three calls get matched ToolMessages. + Before the fix the parent graph routed only the first delegation and left the + other tool_call_ids unanswered. That is an invalid tool-call/tool-result + sequence, and the manager hallucinated the missing replies. """ - from langchain_core.language_models.fake_chat_models import ( - FakeMessagesListChatModel, - ) from langchain_core.messages import AIMessage, HumanMessage - from langgraph.checkpoint.memory import MemorySaver - from pyagentspec.adapters.langgraph import AgentSpecLoader - from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, - ) from pyagentspec.agent import Agent from pyagentspec.managerworkers import ManagerWorkers @@ -462,28 +417,11 @@ def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: # Each worker invocation pops one reply; provide enough for the fan-out. worker_responses = [AIMessage(content=f"poem #{i}") for i in range(1, 6)] - fake_manager = _fake_manager(*manager_responses) - fake_worker = _fake_manager(*worker_responses) - - def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: - if llm_config.name == "manager_llm": - return fake_manager - if llm_config.name == "worker_llm": - return fake_worker - raise AssertionError(f"unexpected llm_config: {llm_config.name}") - - loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) - with patch.object( - AgentSpecToLangGraphConverter, - "_llm_convert_to_langgraph", - autospec=True, - side_effect=_dispatch, - ), patch.object( - FakeMessagesListChatModel, - "bind_tools", - new=lambda self_obj, *a, **kw: self_obj, - ): - compiled = loader.load_component(mw) + compiled = _load_with_fake_llms( + mw, + manager_llm=_fake_manager(*manager_responses), + worker_llm=_fake_manager(*worker_responses), + ) result = compiled.invoke( {"messages": [HumanMessage(content="Write 3 poems via sub-agents.")]}, @@ -516,16 +454,10 @@ def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: def test_nested_manager_workers_compiles_recursively() -> None: - """A worker that is itself a ManagerWorkers compiles through the - same dispatch — the inner ManagerWorkers becomes a CompiledStateGraph - that the outer parent graph wires in as a subgraph node.""" + """A worker that is itself a ManagerWorkers compiles through the same dispatch, + becoming a CompiledStateGraph the outer parent graph wires in as a subgraph node.""" from langchain_core.messages import AIMessage - from langgraph.checkpoint.memory import MemorySaver - from pyagentspec.adapters.langgraph import AgentSpecLoader - from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, - ) from pyagentspec.agent import Agent from pyagentspec.managerworkers import ManagerWorkers @@ -558,29 +490,23 @@ def test_nested_manager_workers_compiles_recursively() -> None: workers=[inner_mw], ) - fake_llm = _fake_manager(AIMessage(content="Done.")) - loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) - with patch.object( - AgentSpecToLangGraphConverter, "_llm_convert_to_langgraph", return_value=fake_llm - ): - compiled = loader.load_component(outer_mw) + compiled = _load_with_fake_llms(outer_mw, default=_fake_manager(AIMessage(content="Done."))) # Outer parent graph has a node for the inner ManagerWorkers worker. assert "inner" in compiled.builder.nodes def test_rejects_non_agent_group_manager() -> None: - """ManagerWorkers.group_manager must be an Agent — pyagentspec allows - any AgenticComponent but the LangGraph adapter needs a chat-LLM that - emits tool_calls to decide which worker to delegate to.""" + """group_manager must be an Agent. Pyagentspec accepts any AgenticComponent, but + the adapter needs a chat-LLM emitting tool_calls to decide where to delegate.""" from langgraph.checkpoint.memory import MemorySaver from pyagentspec.adapters.langgraph import AgentSpecLoader from pyagentspec.agent import Agent from pyagentspec.managerworkers import ManagerWorkers - # Use a nested ManagerWorkers as the group_manager — a valid - # AgenticComponent per pyagentspec validators, unsupported here. + # A nested ManagerWorkers as group_manager: valid per the pyagentspec + # validators, unsupported here. leaf = Agent( name="Leaf", description="L", @@ -642,394 +568,6 @@ def test_workers_with_name_slug_collision_are_rejected() -> None: loader.load_component(mw) -# ─── astream_events delegation scrubbing ───────────────────────────────────── -# -# The manager routes by emitting a ``delegate_to_`` tool call which -# the worker answers with a ToolMessage. That pair is internal plumbing; the -# consumer-facing ``astream_events`` view must not surface it as phantom tool -# calls. ``_DelegationEventFilter`` scrubs the event stream while leaving the -# graph's message state intact. - - -def test_delegation_filter_drops_delegate_tool_lifecycle_events() -> None: - from pyagentspec.adapters.langgraph._managerworkers import ( - _DelegationEventFilter, - ) - - f = _DelegationEventFilter() - for etype in ("on_tool_start", "on_tool_end", "on_tool_error"): - ev = { - "event": etype, - "name": "delegate_to_research_helper", - "run_id": "r", - "data": {}, - } - assert f.scrub(ev) is None - - # A real tool's lifecycle events pass through untouched. - real = {"event": "on_tool_start", "name": "search", "run_id": "r", "data": {}} - assert f.scrub(real) is real - - -def test_delegation_filter_strips_delegate_call_from_chat_model_end() -> None: - from langchain_core.messages import AIMessage - - from pyagentspec.adapters.langgraph._managerworkers import ( - _DelegationEventFilter, - ) - - f = _DelegationEventFilter() - msg = AIMessage( - content="", - tool_calls=[ - {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "call_1"}, - ], - ) - out = f.scrub( - {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": msg}} - ) - assert out is not None - assert out["data"]["output"].tool_calls == [] - # The original message object (which lives in graph state) is untouched. - assert msg.tool_calls and msg.tool_calls[0]["name"] == "delegate_to_research_helper" - - # A turn that mixes a delegation call with a real tool call keeps the real one. - mixed = AIMessage( - content="ok", - tool_calls=[ - {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "call_2"}, - {"name": "search", "args": {"q": "x"}, "id": "call_3"}, - ], - ) - out2 = f.scrub( - {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": mixed}} - ) - assert [tc["name"] for tc in out2["data"]["output"].tool_calls] == ["search"] - - -def test_delegation_filter_strips_streamed_delegate_tool_call_chunks() -> None: - from langchain_core.messages import AIMessageChunk - - from pyagentspec.adapters.langgraph._managerworkers import ( - _DelegationEventFilter, - ) - - f = _DelegationEventFilter() - # Opening chunk: names the delegation tool at index 0 → pure plumbing, dropped. - opening = AIMessageChunk( - content="", - tool_call_chunks=[ - { - "name": "delegate_to_research_helper", - "args": "", - "id": "call_1", - "index": 0, - "type": "tool_call_chunk", - } - ], - ) - assert ( - f.scrub( - { - "event": "on_chat_model_stream", - "name": "x", - "run_id": "r", - "data": {"chunk": opening}, - } - ) - is None - ) - - # Argument-continuation chunk: no name, same index 0 → also dropped. - cont = AIMessageChunk( - content="", - tool_call_chunks=[ - { - "name": None, - "args": '{"task":"hi"}', - "id": None, - "index": 0, - "type": "tool_call_chunk", - }, - ], - ) - assert ( - f.scrub( - {"event": "on_chat_model_stream", "name": "x", "run_id": "r", "data": {"chunk": cont}} - ) - is None - ) - - # A chunk mixing a delegation call with a real tool call keeps the real one. - mixed = AIMessageChunk( - content="", - tool_call_chunks=[ - { - "name": "delegate_to_research_helper", - "args": "", - "id": "c4", - "index": 0, - "type": "tool_call_chunk", - }, - {"name": "search", "args": "", "id": "c5", "index": 1, "type": "tool_call_chunk"}, - ], - ) - out = f.scrub( - {"event": "on_chat_model_stream", "name": "x", "run_id": "r2", "data": {"chunk": mixed}} - ) - assert out is not None - kept = out["data"]["chunk"].tool_call_chunks - assert [c["name"] for c in kept] == ["search"] - - -def test_delegation_filter_drops_worker_synthetic_tool_message() -> None: - from langchain_core.messages import AIMessage, ToolMessage - - from pyagentspec.adapters.langgraph._managerworkers import ( - _DelegationEventFilter, - ) - - f = _DelegationEventFilter() - # The manager's delegate turn first records the delegation call id. - delegate = AIMessage( - content="", - tool_calls=[ - {"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "call_1"}, - ], - ) - f.scrub( - {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": delegate}} - ) - - # The worker node then emits its reply as a ToolMessage matched to call_1. - reply = ToolMessage(content="Saturn has rings.", tool_call_id="call_1") - out = f.scrub( - { - "event": "on_chain_end", - "name": "worker:research_helper", - "run_id": "w", - "data": {"output": {"messages": [reply]}}, - } - ) - assert out is None - - # A ToolMessage answering an unknown (real) tool call is preserved. - other = ToolMessage(content="x", tool_call_id="call_other") - kept = f.scrub( - { - "event": "on_chain_end", - "name": "node", - "run_id": "w2", - "data": {"output": {"messages": [other]}}, - } - ) - assert kept is not None - assert kept["data"]["output"]["messages"] == [other] - - -def test_delegation_filter_strips_delegate_calls_from_state_snapshot() -> None: - """Regression: a node/state payload (``on_chain_end``) carrying the full - ``messages`` list must surface NEITHER the delegate tool calls NOR their - reply ToolMessages. - - A consumer builds its message snapshot from this payload and reads - ``tool_calls`` straight off the AIMessage. If the filter dropped only the - reply ToolMessages but left the delegate tool calls on the AIMessage, the - snapshot would show delegate tool calls with no results — rendered as a - "tool call with no result". Real (non-delegation) tool calls and their - results must be preserved. - """ - from langchain_core.messages import AIMessage, HumanMessage, ToolMessage - - from pyagentspec.adapters.langgraph._managerworkers import ( - _DelegationEventFilter, - ) - - f = _DelegationEventFilter() - # Manager's delegation turn streams first so the filter learns the ids. - delegate_ai = AIMessage( - content="", - tool_calls=[ - {"name": "delegate_to_sub_agent", "args": {"task": "ES"}, "id": "call_1"}, - {"name": "delegate_to_sub_agent", "args": {"task": "FR"}, "id": "call_2"}, - ], - ) - f.scrub( - {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": delegate_ai}} - ) - - # The final-state snapshot carries the whole conversation, including a - # real tool call ("search") + its result that must survive. - snapshot = { - "messages": [ - HumanMessage(content="2 poems via sub-agents"), - delegate_ai, - ToolMessage(content="poem ES", tool_call_id="call_1"), - ToolMessage(content="poem FR", tool_call_id="call_2"), - AIMessage( - content="", - tool_calls=[{"name": "search", "args": {"q": "x"}, "id": "call_real"}], - ), - ToolMessage(content="search result", tool_call_id="call_real"), - AIMessage(content="Here are your poems."), - ] - } - out = f.scrub( - { - "event": "on_chain_end", - "name": "__manager__", - "run_id": "r2", - "data": {"output": snapshot}, - } - ) - assert out is not None - msgs = out["data"]["output"]["messages"] - - # No delegate tool calls and no delegate ToolMessages remain. - delegate_calls = [ - tc - for m in msgs - if isinstance(m, AIMessage) - for tc in (m.tool_calls or []) - if tc["name"].startswith("delegate_to_") - ] - assert delegate_calls == [] - tool_ids = [m.tool_call_id for m in msgs if type(m).__name__ == "ToolMessage"] - assert "call_1" not in tool_ids and "call_2" not in tool_ids - # The empty delegation AIMessage is dropped entirely. - assert delegate_ai not in msgs - - # The REAL tool call + its result are preserved and still paired. - real_calls = [tc["id"] for m in msgs if isinstance(m, AIMessage) for tc in (m.tool_calls or [])] - assert real_calls == ["call_real"] - assert "call_real" in tool_ids - # The human turn and the manager's final answer survive. - assert any(type(m).__name__ == "HumanMessage" for m in msgs) - assert msgs[-1].content == "Here are your poems." - - # The original state objects are never mutated (graph state stays intact). - assert delegate_ai.tool_calls and len(delegate_ai.tool_calls) == 2 - - -def test_delegation_filter_passes_through_real_content() -> None: - from langchain_core.messages import AIMessageChunk - - from pyagentspec.adapters.langgraph._managerworkers import ( - _DelegationEventFilter, - ) - - f = _DelegationEventFilter() - chunk = AIMessageChunk(content="Hello") - ev = {"event": "on_chat_model_stream", "name": "x", "run_id": "r", "data": {"chunk": chunk}} - out = f.scrub(ev) - # No delegation artifact → event passes through as the same object. - assert out is ev - assert out["data"]["chunk"].content == "Hello" - - -def test_delegation_filter_strips_delegate_from_invalid_tool_calls() -> None: - """A delegation call whose args failed to parse arrives in - ``invalid_tool_calls`` rather than ``tool_calls`` — it must still be - scrubbed so the consumer never sees the routing protocol.""" - from langchain_core.messages import AIMessage - - from pyagentspec.adapters.langgraph._managerworkers import _DelegationEventFilter - - f = _DelegationEventFilter() - msg = AIMessage( - content="", - invalid_tool_calls=[ - { - "name": "delegate_to_research_helper", - "args": "{bad", - "id": "call_1", - "error": "parse error", - "type": "invalid_tool_call", - } - ], - ) - out = f.scrub( - {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": msg}} - ) - assert out is not None - assert out["data"]["output"].invalid_tool_calls == [] - # The original (graph-state) message is never mutated. - assert ( - msg.invalid_tool_calls - and msg.invalid_tool_calls[0]["name"] == "delegate_to_research_helper" - ) - - -def test_delegation_filter_strips_provider_native_tool_calls_on_full_message() -> None: - """Some providers (e.g. OpenAI) carry the tool call only in - ``additional_kwargs['tool_calls']``; a delegation call there must be - stripped and its id recorded so the worker reply can later be dropped.""" - from langchain_core.messages import AIMessage, ToolMessage - - from pyagentspec.adapters.langgraph._managerworkers import _DelegationEventFilter - - f = _DelegationEventFilter() - msg = AIMessage( - content="", - additional_kwargs={ - "tool_calls": [ - { - "index": 0, - "id": "call_1", - "function": {"name": "delegate_to_research_helper", "arguments": ""}, - "type": "function", - } - ] - }, - ) - out = f.scrub( - {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": msg}} - ) - assert out is not None - assert "tool_calls" not in (out["data"]["output"].additional_kwargs or {}) - - # Recording the id means the worker's reply ToolMessage is dropped too. - reply = ToolMessage(content="done", tool_call_id="call_1") - dropped = f.scrub( - { - "event": "on_chain_end", - "name": "worker:research_helper", - "run_id": "w", - "data": {"output": {"messages": [reply]}}, - } - ) - assert dropped is None - - -def test_delegation_filter_scrubs_input_payload_messages() -> None: - """The worker's reply ToolMessage must be dropped wherever it surfaces — - including a node's ``input`` payload, not only ``output`` / ``chunk``.""" - from langchain_core.messages import AIMessage, ToolMessage - - from pyagentspec.adapters.langgraph._managerworkers import _DelegationEventFilter - - f = _DelegationEventFilter() - delegate = AIMessage( - content="", - tool_calls=[{"name": "delegate_to_research_helper", "args": {"task": "x"}, "id": "call_1"}], - ) - f.scrub( - {"event": "on_chat_model_end", "name": "x", "run_id": "r", "data": {"output": delegate}} - ) - - reply = ToolMessage(content="done", tool_call_id="call_1") - out = f.scrub( - { - "event": "on_chain_start", - "name": "worker:research_helper", - "run_id": "w", - "data": {"input": {"messages": [reply]}}, - } - ) - # The only message was the delegate reply → payload empties → event dropped. - assert out is None - - def test_worker_events_stream_natively_namespaced_under_worker_node() -> None: """Regression: a worker's token events must stream under the worker node's checkpoint namespace so a consumer can attribute them to the @@ -1106,100 +644,6 @@ async def _collect() -> Any: assert all(ns.startswith("research_helper:") for ns in namespaces), namespaces -def test_patched_astream_events_fails_open_on_filter_error() -> None: - """A bug in the delegation filter must never tear down the stream and - swallow later events (e.g. the worker events that follow the manager's - delegation turn). On a scrub error the event is passed through.""" - import asyncio - - from pyagentspec.adapters.langgraph._managerworkers import ( - _DelegationEventFilter, - _patch_hide_delegation_in_astream_events, - ) - - class _FakeGraph: - async def astream_events(self, *a: Any, **k: Any) -> Any: - yield {"event": "on_chat_model_stream", "run_id": "boom", "name": "x", "data": {}} - yield { - "event": "on_chat_model_stream", - "run_id": "ok", - "name": "x", - "data": {"chunk": "worker-token"}, - } - - def _explode(self: Any, event: Any) -> Any: - if event.get("run_id") == "boom": - raise RuntimeError("kaboom") - return event - - graph = _FakeGraph() - _patch_hide_delegation_in_astream_events(graph) - - async def _collect() -> Any: - out = [] - with patch.object(_DelegationEventFilter, "scrub", new=_explode): - async for ev in graph.astream_events(): - out.append(ev) - return out - - events = asyncio.run(_collect()) - # Both events survive: the one that raised is passed through unfiltered, - # and the later (worker) event is still delivered. - assert [e["run_id"] for e in events] == ["boom", "ok"] - - -def test_manager_workers_patches_astream_events() -> None: - """The compiled ManagerWorkers graph has its ``astream_events`` wrapped - with the delegation scrubber.""" - from langchain_core.language_models.fake_chat_models import ( - FakeMessagesListChatModel, - ) - from langgraph.checkpoint.memory import MemorySaver - - from pyagentspec.adapters.langgraph import AgentSpecLoader - from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, - ) - from pyagentspec.agent import Agent - from pyagentspec.managerworkers import ManagerWorkers - - mw = ManagerWorkers( - name="Team", - group_manager=Agent( - name="Coordinator", - description="c", - system_prompt=".", - llm_config=_llm_cfg("manager_llm"), - ), - workers=[ - Agent( - name="Research Helper", - description="r", - system_prompt=".", - llm_config=_llm_cfg("worker_llm"), - ), - ], - ) - - def _dispatch(self_obj: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: - return _fake_manager(*[]) - - loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) - with patch.object( - AgentSpecToLangGraphConverter, - "_llm_convert_to_langgraph", - autospec=True, - side_effect=_dispatch, - ), patch.object( - FakeMessagesListChatModel, - "bind_tools", - new=lambda self_obj, *a, **kw: self_obj, - ): - compiled = loader.load_component(mw) - - assert getattr(compiled.astream_events, "__name__", "") == "patched_astream_events" - - # ─── Shared low-level helper unit tests (no LLM) ───────────────────────────── @@ -1251,7 +695,7 @@ def test_surface_to_parent_command_projects_messages_with_no_goto() -> None: assert isinstance(cmd, Command) assert cmd.graph == Command.PARENT - assert cmd.goto == () # no goto — the parent graph decides where to go + assert cmd.goto == () # no goto; the parent graph decides where to go assert cmd.update == {"messages": [m1, m2]} @@ -1268,8 +712,8 @@ def test_delegation_tool_exposes_expected_name_and_description() -> None: def _echo_worker_graph(reply: str = "WORKER REPLY") -> Any: - """A trivial worker CompiledStateGraph whose only node returns a fixed - AIMessage — enough to exercise the wrapper without an LLM.""" + """A worker CompiledStateGraph whose only node returns a fixed AIMessage, enough + to exercise the wrapper without an LLM.""" from langchain_core.messages import AIMessage from langgraph.graph import END, START, MessagesState, StateGraph @@ -1301,43 +745,53 @@ def test_wrap_worker_uses_send_payload_task_and_call_id() -> None: assert reply.tool_call_id == "call_9" -def test_wrap_worker_recovers_task_from_manager_message_on_direct_edge() -> None: - """Direct-edge path (no Send payload): the task and call id are recovered - from the manager's last AIMessage delegation tool call.""" - from langchain_core.messages import AIMessage, ToolMessage +# ─── Delegation visibility: the public consumer-side filter ────────────────── - from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph - node = _wrap_worker_for_subgraph(_echo_worker_graph("ANSWER"), "research_helper") - manager_ai = AIMessage( - content="", - tool_calls=[{"name": "delegate_to_research_helper", "args": {"task": "T"}, "id": "c1"}], +def test_is_delegation_tool_name_matches_only_the_synthetic_prefix() -> None: + from pyagentspec.adapters.langgraph._managerworkers import ( + DELEGATE_TOOL_PREFIX, + is_delegation_tool_name, ) - out = node.invoke({"messages": [manager_ai]}) - - (reply,) = out["messages"] - assert isinstance(reply, ToolMessage) - assert reply.content == "ANSWER" - assert reply.tool_call_id == "c1" + assert DELEGATE_TOOL_PREFIX == "delegate_to_" + assert is_delegation_tool_name("delegate_to_research_helper") + assert not is_delegation_tool_name("get_weather") + # A real tool merely *containing* the prefix mid-name is not a delegation. + assert not is_delegation_tool_name("please_delegate_to_someone") + assert not is_delegation_tool_name(None) + assert not is_delegation_tool_name(123) -def test_wrap_worker_raises_on_empty_manager_state() -> None: - from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph - node = _wrap_worker_for_subgraph(_echo_worker_graph(), "research_helper") - with pytest.raises(RuntimeError, match="empty manager state"): - node.invoke({"messages": []}) +def test_manager_workers_leaves_astream_events_unwrapped() -> None: + """The delegation protocol is deliberately visible: nothing wraps + ``astream_events`` to scrub it. Only stream/astream are patched, for the + ManagerWorkersExecutionSpan.""" + from pyagentspec.agent import Agent + from pyagentspec.managerworkers import ManagerWorkers -def test_wrap_worker_raises_when_no_matching_delegation_call() -> None: - from langchain_core.messages import AIMessage + mw = ManagerWorkers( + name="Team", + group_manager=Agent( + name="Coordinator", + description="c", + system_prompt=".", + llm_config=_llm_cfg("manager_llm"), + ), + workers=[ + Agent( + name="Research Helper", + description="r", + system_prompt=".", + llm_config=_llm_cfg("worker_llm"), + ), + ], + ) - from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph + compiled = _load_with_fake_llms(mw, default=_fake_manager()) - node = _wrap_worker_for_subgraph(_echo_worker_graph(), "research_helper") - not_for_me = AIMessage( - content="", - tool_calls=[{"name": "delegate_to_other", "args": {"task": "x"}, "id": "c1"}], - ) - with pytest.raises(RuntimeError, match="delegate_to_research_helper"): - node.invoke({"messages": [not_for_me]}) + assert getattr(compiled.astream_events, "__name__", "") != "patched_astream_events" + # The execution-span patches are still applied. + assert getattr(compiled.stream, "__name__", "") == "patched_stream" + assert getattr(compiled.astream, "__name__", "") == "patched_astream" From f6adf29433ed04fdaf4bdb0d0d70dd53b4b1e2b6 Mon Sep 17 00:00:00 2001 From: Salah Date: Sun, 2 Aug 2026 16:25:01 +0400 Subject: [PATCH 06/17] refactor(adapters/langgraph): align ManagerWorkers code and tests with codebase style Trim reviewer-directed comments to codebase density; drop the lru_cache on the delegation-tool factory, the _WorkerSubgraphNode class (now closures) and the dict-or-object tool-call shim. Rewrite the tests to repo conventions: module-level pyagentspec imports, a shared _agent() helper, pytestmark instead of an empty autouse fixture, no section dividers, and behavior-level coverage instead of micro-tests of private helpers. Also fix a real bug the flow-step test exposed: the ManagerWorkers parent graph runs over MessagesState, so a structured_response can never reach the node executor and any declared output raised ValueError. A ManagerWorkers flow step now answers its single string output with the manager's final message, and rejects other output shapes with NotImplementedError. --- .../adapters/langgraph/_execution_span.py | 12 +- .../adapters/langgraph/_langgraphconverter.py | 29 +- .../adapters/langgraph/_managerworkers.py | 155 ++--- .../adapters/langgraph/_node_execution.py | 22 +- pyagentspec/src/pyagentspec/managerworkers.py | 18 +- pyagentspec/tests/adapters/conftest.py | 15 +- .../flows/test_managerworkers_node.py | 66 +- .../adapters/langgraph/test_managerworkers.py | 567 ++++-------------- 8 files changed, 216 insertions(+), 668 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py index 21817f46..cb22b199 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py @@ -7,13 +7,11 @@ """Wrap a compiled graph's ``stream``/``astream`` in an Agent Spec execution span. Agent, Flow and ManagerWorkers graphs all need the same wrapper: open a span, emit a -Start event carrying the invocation inputs, replay the chunks the underlying stream -yields while remembering the last state chunk, then emit an End event built from that -final state. Only the span class and the two event payloads differ, so they come in -as factories. - -``invoke``/``ainvoke`` need no patch of their own; they go through ``stream``/ -``astream`` internally. +Start event carrying the invocation inputs, yield the chunks the underlying stream +produces while remembering the last state chunk, then emit an End event built from +that final state. Only the span class and the two event payloads differ, so they come +in as factories. ``invoke``/``ainvoke`` need no patch; they use ``stream``/``astream`` +internally. """ from typing import Any, AsyncGenerator, Callable, Dict, Generator diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 59aee1d0..a98733d8 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -1093,21 +1093,15 @@ def _manager_workers_convert_to_langgraph( └─ no tool_call ─→ END The manager is a react-agent holding one synthetic ``delegate_to_`` - tool per worker. The parent graph's conditional edge inspects its last - AIMessage to pick the next node, and the worker node runs in an isolated - message context and answers with a ``ToolMessage`` matched to the pending - delegation id. - - Workers are converted recursively and wired in as subgraph nodes, so - ``astream_events`` still exposes the parent/child boundary - (``subgraph=True``) for tracing and SSE streaming. Workers that are - themselves ``ManagerWorkers`` compose through ``self.convert(...)``. + tool per worker. A conditional edge routes each delegation to its worker, + which runs in an isolated message context and answers with a ``ToolMessage`` + matched to the pending delegation id. Workers are converted recursively and + wired in as subgraph nodes, so ``astream_events`` still exposes the + parent/child boundary (``subgraph=True``) for tracing and streaming. """ if not isinstance(mw.group_manager, AgentSpecAgent): - # The manager has to decide which worker to delegate to, so it needs a - # chat-LLM that emits tool_calls. Only Agent (and its SpecializedAgent - # subclass) has that shape; a Flow, Swarm or nested ManagerWorkers gives - # us nothing to route on. + # Delegation is routed off the manager's tool_calls, so the manager needs + # a chat-LLM; a Flow, Swarm or nested ManagerWorkers gives nothing to route on. raise NotImplementedError( f"ManagerWorkers.group_manager must be an Agent for LangGraph " f"conversion; got {type(mw.group_manager).__name__}." @@ -1138,9 +1132,8 @@ def _manager_workers_convert_to_langgraph( [(node_name, worker.description or "") for node_name, worker in named_workers], ) - # The delegation tools do execute inside the react loop: their body returns a - # Command(graph=PARENT), which is how the call escapes the react subgraph so - # the conditional edge below can route on it. + # The delegation tools execute inside the react loop: their Command(graph=PARENT) + # is how the call escapes the subgraph so the conditional edge below can route on it. manager_graph = self._create_react_agent_with_given_info( name=manager_agent.name, system_prompt=rendered_prompt, @@ -1274,7 +1267,9 @@ def _create_react_agent_with_given_info( make_span=lambda: AgentSpecAgentExecutionSpan( name=f"AgentExecution[{agent.name}]", agent=agent ), - make_start_event=lambda inputs: AgentSpecAgentExecutionStart(agent=agent, inputs=inputs), + make_start_event=lambda inputs: AgentSpecAgentExecutionStart( + agent=agent, inputs=inputs + ), make_end_event=lambda result: AgentSpecAgentExecutionEnd( agent=agent, outputs=extract_outputs_from_invoke_result(result, agent.outputs or []), diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py index 971fb2ae..c331722e 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py @@ -4,19 +4,15 @@ # (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. -"""Helpers for compiling a ``ManagerWorkers`` into LangGraph. +"""Helpers for compiling a ``ManagerWorkers`` into LangGraph, orchestrated by +``AgentSpecToLangGraphConverter._manager_workers_convert_to_langgraph``. -``AgentSpecToLangGraphConverter._manager_workers_convert_to_langgraph`` orchestrates -these; they live here to keep the converter module from growing further. - -Nothing hides the routing protocol. ``delegate_to_`` calls stream like any -other tool call, because which worker got which task is usually the most useful thing -a run reports. Consumers that would rather not render it can filter on +The delegation protocol is visible on purpose: ``delegate_to_`` calls stream +like any other tool call. Consumers that would rather not render them can filter on :func:`is_delegation_tool_name`. """ import re -from functools import lru_cache from typing import Any, Dict, List, Tuple from pyagentspec.adapters.langgraph._execution_span import patch_with_execution_span @@ -35,13 +31,13 @@ # Cannot collide with a normalized worker node name, which is always [a-z0-9_]. _MANAGER_NODE_KEY = "__manager__" -#: Prefix the manager's LLM uses to address a delegation tool, suffixed with the -#: normalized worker node name. Public so consumers can recognize the protocol. +#: Prefix of the synthetic ``delegate_to_`` tool names the manager's LLM uses +#: to address a worker. Public so consumers can recognize the protocol. DELEGATE_TOOL_PREFIX = "delegate_to_" -# Carried on the per-delegation ``Send`` payload so a worker run knows its task and -# which ``tool_call_id`` its reply must answer. Routing per delegation instead of off -# shared state lets one manager turn delegate to several workers at once. +# Keys of the per-delegation ``Send`` payload: the task to run, and the tool_call_id +# the worker's reply must answer. Routing per delegation (instead of off shared state) +# lets one manager turn delegate to several workers at once. _DELEGATE_TASK_KEY = "__delegate_task__" _DELEGATE_CALL_ID_KEY = "__delegate_tool_call_id__" @@ -61,21 +57,13 @@ def _normalize_identifier(s: str) -> str: def _safe_node_name(name: str, fallback_id: str) -> str: """Normalize a worker name into a LangGraph node identifier. - The LLM sees ``delegate_to_`` as a tool name and has to emit it - reliably, so node names stay ASCII identifiers. Falls back to the component id, - normalized the same way, when the name slugifies to nothing. + The LLM has to emit ``delegate_to_`` reliably as a tool name, so node + names stay ASCII identifiers. Falls back to the normalized component id when the + name slugifies to nothing. """ return _normalize_identifier(name) or _normalize_identifier(fallback_id) or "worker" -def _tc_get(tool_call: Any, key: str) -> Any: - """Read ``key`` off a tool call, which langchain emits as a dict or an object - depending on the message source.""" - if isinstance(tool_call, dict): - return tool_call.get(key) - return getattr(tool_call, key, None) - - def _messages_of(state: Any) -> List[Any]: """Read ``messages`` off a state, which langgraph injects as a dict or an object.""" if isinstance(state, dict): @@ -83,23 +71,7 @@ def _messages_of(state: Any) -> List[Any]: return list(getattr(state, "messages", []) or []) -def _surface_to_parent_command(state: Any) -> Any: - """Break out of the manager's react loop, projecting the subgraph's messages onto - the parent state (including the AIMessage carrying the triggering tool call). - - Carries no ``goto``: routing is the parent graph's job. The ``add_messages`` - reducer dedupes by id, so re-surfacing existing messages is a no-op. Modelled on - ``langgraph_swarm.create_handoff_tool``. - """ - from langgraph.types import Command - - return Command(graph=Command.PARENT, update={"messages": _messages_of(state)}) - - -def _append_workers_roster( - system_prompt: str, - entries: List[Tuple[str, str]], -) -> str: +def _append_workers_roster(system_prompt: str, entries: List[Tuple[str, str]]) -> str: """Append an ``Available workers:`` block listing ``- : ``. Descriptions are flattened to one line each, since the LLM routes off the block's @@ -114,19 +86,15 @@ def _append_workers_roster( return f"{system_prompt}\n\n{roster}" if system_prompt else roster -@lru_cache(maxsize=256) def _make_worker_delegation_tool(worker_node_name: str) -> Any: """Build the ``delegate_to_`` tool the manager's LLM emits to route to a worker. - The body carries no ``goto``. Routing fans out one ``Send`` per delegation in - :func:`_route_manager_to_worker_or_end`; a ``goto`` here would collapse several - same-turn delegations into one parent Command and leave the other - ``tool_call_id``s unanswered. - - Memoized because the tool depends only on the node name and holds no per-graph - state. Without it every compile re-runs the ``@tool`` decorator, which costs a - ``get_type_hints`` pass and a pydantic args-schema build. + Executing the tool is only how the call escapes the react subgraph: its body + surfaces the subgraph messages to the parent with ``Command(graph=PARENT)`` and no + ``goto``. Routing stays in :func:`_route_manager_to_worker_or_end`; a ``goto`` here + would collapse several same-turn delegations into one parent Command and leave the + other ``tool_call_id``s unanswered. """ from typing import Annotated @@ -136,9 +104,8 @@ def _make_worker_delegation_tool(worker_node_name: str) -> Any: tool_name = f"{DELEGATE_TOOL_PREFIX}{worker_node_name}" description = ( - f"Delegate a task to the {worker_node_name} worker and receive " - f"its response. Use this when the task fits the worker's " - f"described capability." + f"Delegate a task to the {worker_node_name} worker and receive its response. " + f"Use this when the task fits the worker's described capability." ) @tool(tool_name, description=description) @@ -146,12 +113,12 @@ def _delegate( task: str, state: Annotated[Any, InjectedState], tool_call_id: Annotated[str, InjectedToolCallId], - ) -> Command: - # Declared for the LLM-facing schema but unused here: executing is only how the - # call escapes the react subgraph, and the routing edge recovers both off the - # surfaced AIMessage's tool_calls. + ) -> Any: + # task and tool_call_id are declared for the LLM-facing schema; the routing + # edge recovers both off the surfaced AIMessage's tool_calls. The + # add_messages reducer dedupes by id, so re-surfacing messages is a no-op. del task, tool_call_id - return _surface_to_parent_command(state) + return Command(graph=Command.PARENT, update={"messages": _messages_of(state)}) return _delegate @@ -160,28 +127,25 @@ def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: """Route the parent graph off the manager's last AIMessage: one ``Send`` per ``delegate_to_`` tool call, or ``END`` when it emitted none. - Every delegation gets its own ``Send`` carrying the task and ``tool_call_id``, so - each is answered independently. An unanswered one breaks the manager's next-turn - tool-call/result sequence. Plain tool calls already ran inside the react loop. + Every delegation gets its own ``Send``, so each tool_call_id is answered + independently; an unanswered one breaks the manager's next-turn tool-call/result + sequence. Plain tool calls already ran inside the react loop. """ from langgraph.types import Send messages = state.get("messages") or [] - if not messages: - return langgraph_graph.END - last = messages[-1] - tool_calls = getattr(last, "tool_calls", None) or [] + last = messages[-1] if messages else None sends = [] - for tc in tool_calls: - name = _tc_get(tc, "name") + for tool_call in getattr(last, "tool_calls", None) or []: + name = tool_call.get("name") if is_delegation_tool_name(name): - args = _tc_get(tc, "args") or {} + args = tool_call.get("args") or {} sends.append( Send( name[len(DELEGATE_TOOL_PREFIX) :], { _DELEGATE_TASK_KEY: args.get("task") or "", - _DELEGATE_CALL_ID_KEY: _tc_get(tc, "id") or "", + _DELEGATE_CALL_ID_KEY: tool_call.get("id") or "", }, ) ) @@ -189,13 +153,7 @@ def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: def _worker_input(state: Dict[str, Any]) -> Dict[str, Any]: - """The single-message context a worker run starts from. - - Passes no explicit config, so the worker inherits this node's ambient run config. - Its ``checkpoint_ns`` (``:``) streams the worker's token - events under the worker node, and the per-superstep namespace keeps repeated - delegations isolated without a fresh thread_id. - """ + """The single-message context a worker run starts from.""" from langchain_core.messages import HumanMessage return {"messages": [HumanMessage(content=state.get(_DELEGATE_TASK_KEY) or "")]} @@ -214,51 +172,34 @@ def _worker_reply(state: Dict[str, Any], result: Any) -> Dict[str, Any]: } -class _WorkerSubgraphNode: - """Runs one worker subgraph as a node of the ManagerWorkers parent graph. - - Hierarchical rather than shared-state like a Swarm: workers never see each other's - messages, and each run is handed only the manager's chosen task. The worker's last - message comes back as the ToolMessage content, so the manager's react loop sees a - well-formed tool response on its next turn. - - A class rather than a closure, so the long-lived node holds only the graph instead - of keeping a whole factory frame alive. - """ - - __slots__ = ("_graph",) - - def __init__(self, worker_graph: CompiledStateGraph[Any, Any, Any]) -> None: - self._graph = worker_graph - - def run(self, state: Dict[str, Any]) -> Dict[str, Any]: - return _worker_reply(state, self._graph.invoke(_worker_input(state))) - - async def arun(self, state: Dict[str, Any]) -> Dict[str, Any]: - return _worker_reply(state, await self._graph.ainvoke(_worker_input(state))) - - def _wrap_worker_for_subgraph( worker_graph: CompiledStateGraph[Any, Any, Any], worker_node_name: str, ) -> Any: - """Wrap a worker subgraph as a node exposing sync and async entrypoints. + """Wrap a worker subgraph as a node of the ManagerWorkers parent graph. - LangGraph picks between them depending on whether the parent graph was invoked - via ``invoke`` or ``ainvoke``. + Hierarchical rather than shared-state like a Swarm: each run is handed only the + manager's chosen task, and the worker's answer comes back as a ToolMessage so the + manager's react loop sees a well-formed tool response on its next turn. The worker + is invoked with no explicit config and inherits this node's ambient run config, + which streams its token events under the worker node's checkpoint namespace. """ from pyagentspec.adapters.langgraph._types import RunnableLambda - node = _WorkerSubgraphNode(worker_graph) - return RunnableLambda(func=node.run, afunc=node.arun, name=f"worker:{worker_node_name}") + def run(state: Dict[str, Any]) -> Dict[str, Any]: + return _worker_reply(state, worker_graph.invoke(_worker_input(state))) + + async def arun(state: Dict[str, Any]) -> Dict[str, Any]: + return _worker_reply(state, await worker_graph.ainvoke(_worker_input(state))) + + return RunnableLambda(func=run, afunc=arun, name=f"worker:{worker_node_name}") def _patch_with_manager_workers_execution_span( compiled_graph: CompiledStateGraph[Any, Any, Any], mw: AgentSpecManagerWorkers, ) -> None: - """Wrap ``stream``/``astream`` so each run emits a ``ManagerWorkersExecutionSpan``, - using the same patcher as the Agent and Flow graphs.""" + """Wrap ``stream``/``astream`` so each run emits a ``ManagerWorkersExecutionSpan``.""" patch_with_execution_span( compiled_graph, make_span=lambda: AgentSpecManagerWorkersExecutionSpan( diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index f8fb156e..f3df8873 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -543,20 +543,15 @@ def _create_manager_workers_with_given_input_values( The graph runs over ``MessagesState``, which can't carry structured inputs inward to the group manager, so the node inputs are rendered into its - ``system_prompt`` and the satisfied ports dropped from both the manager and - the component. That keeps declared and inferred ports equal for the - downstream span re-validation, and the graph runs on messages alone. - - Cached by rendered prompt, the same key - :meth:`_create_react_agent_with_given_input_values` uses. + ``system_prompt`` and the satisfied ports dropped. Cached by rendered prompt, + the same key :meth:`_create_react_agent_with_given_input_values` uses. """ from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter converter = AgentSpecToLangGraphConverter() entry_agent = component.group_manager if not isinstance(entry_agent, AgentSpecAgent): - # Not routable. Nothing to render or cache, and the converter owns the - # error message for this case. + # Nothing to render or cache; the converter owns the error for this case. return converter._manager_workers_convert_to_langgraph( component, **self._conversion_kwargs() ) @@ -609,6 +604,17 @@ def _format_agent_result(self, result: Dict[str, Any]) -> ExecuteOutput: ] return {}, NodeExecutionDetails(generated_messages=generated_messages) + if isinstance(self.node.agent, AgentSpecManagerWorkers): + # The hierarchical graph runs over MessagesState, which cannot carry a + # structured_response outward: the manager's final message is the result. + outputs = self.node.outputs + if len(outputs) != 1 or outputs[0].type != "string": + raise NotImplementedError( + "A ManagerWorkers flow step supports a single string output; " + f"node `{self.node.name}` declares {[o.title for o in outputs]}." + ) + return {outputs[0].title: result["messages"][-1].content}, NodeExecutionDetails() + outputs = extract_outputs_from_invoke_result(result, self.node.outputs or []) return outputs, NodeExecutionDetails() diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index 8b4c3b32..f89853db 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -67,22 +67,14 @@ class ManagerWorkers(AgenticComponent): ) def _get_inferred_inputs(self) -> List[Property]: - """A ``ManagerWorkers`` exposes the inputs of its group manager. - - The group manager drives the conversation and is the component whose prompt the - runtime renders, so the group accepts exactly the inputs the manager accepts - (for an ``Agent`` manager, its ``{{placeholder}}`` inputs). The base default - infers none, which would leave a ``ManagerWorkers`` used as a flow ``AgentNode`` - with no input ports for a data-flow edge to resolve against. - - The ``hasattr`` guard matches :meth:`Flow._get_inferred_inputs` and - :meth:`AgentNode._get_inferred_inputs`: error-accumulating validators can run - this against a partially-constructed model with no ``group_manager`` assigned. - """ + # The group manager drives the conversation and is the component whose prompt + # the runtime renders, so the group accepts exactly the inputs the manager + # accepts. The hasattr guard matches Flow._get_inferred_inputs: validators can + # run this against a partially-constructed model with no group_manager yet. return (self.group_manager.inputs or []) if hasattr(self, "group_manager") else [] def _get_inferred_outputs(self) -> List[Property]: - """Outputs of the group manager; see :meth:`_get_inferred_inputs`.""" + # Symmetric with the inferred inputs: the group manager's outputs. return (self.group_manager.outputs or []) if hasattr(self, "group_manager") else [] @model_validator_with_error_accumulation diff --git a/pyagentspec/tests/adapters/conftest.py b/pyagentspec/tests/adapters/conftest.py index b999abc7..8b5714ba 100644 --- a/pyagentspec/tests/adapters/conftest.py +++ b/pyagentspec/tests/adapters/conftest.py @@ -127,17 +127,10 @@ def _resolve(dotted: str) -> Any: @pytest.fixture def allow_llm_config_construction(): - """Opt out of the blanket ``SKIP_LLM_TESTS=1`` construction guard. - - That guard skips a test the moment it constructs an LLM config. Right for tests - that go on to call a model, wrong for tests that only need a config object and - stub the conversion: those should run offline, and instead they skip silently in - CI, leaving the code path they cover unverified. - - Restores the real constructors for one test, overriding the guards in both this - conftest and ``tests/conftest.py``. - - Only request this from a test that provably never reaches a model endpoint. + """ + Opt out of the SKIP_LLM_TESTS=1 construction guard for one test, restoring the + real LLM config constructors. Only request this from a test that stubs the model + and never reaches an endpoint; such tests should run offline instead of skipping. """ if not should_skip_llm_test(): # Nothing patched the constructors, so there is nothing to restore. diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py index 35ea5e5a..db551602 100644 --- a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py +++ b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py @@ -4,37 +4,28 @@ # (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. -"""A ManagerWorkers used as a flow step (AgentNode). - -Regression coverage for two coupled behaviours: - * ``ManagerWorkers._get_inferred_inputs`` exposes the group manager's inputs, so a - flow ``AgentNode`` wrapping a manager declares input ports and a ``DataFlowEdge`` - into it resolves at load (previously: "node does not have any input property..."). - * ``AgentNodeExecutor`` runs a ManagerWorkers node (previously: TypeError "can only - be used with AgentSpecAgent agents"), rendering the node inputs into the group - manager's prompt and returning its result. -""" +from unittest.mock import patch import pytest from pyagentspec.agent import Agent +from pyagentspec.llms import OpenAiCompatibleConfig from pyagentspec.managerworkers import ManagerWorkers from pyagentspec.property import StringProperty +# These tests only need an LLM config object and stub the chat model, so they run +# offline even under SKIP_LLM_TESTS=1. +pytestmark = pytest.mark.usefixtures("allow_llm_config_construction") -@pytest.fixture(autouse=True) -def _offline(allow_llm_config_construction: None) -> None: - """These tests only need an LLM *config* object: the two inference tests never - convert at all, and the flow-step test stubs the chat model. Without this the - SKIP_LLM_TESTS guard skips all three and the flow-step path goes unverified.""" +def _llm_config() -> OpenAiCompatibleConfig: + return OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null") -def test_managerworkers_infers_inputs_from_group_manager_prompt() -> None: - """A ManagerWorkers exposes the group manager's prompt placeholders as inputs.""" - llm = {"name": "m", "model_id": "fake", "url": "null"} - from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig - cfg = OpenAiCompatibleConfig(**llm) +def test_managerworkers_infers_inputs_from_group_manager_prompt() -> None: + """A ManagerWorkers exposes the group manager's prompt placeholders as inputs, so + a flow AgentNode wrapping it declares input ports a DataFlowEdge can resolve.""" + cfg = _llm_config() manager = Agent( name="manager", llm_config=cfg, @@ -47,18 +38,13 @@ def test_managerworkers_infers_inputs_from_group_manager_prompt() -> None: def test_managerworkers_infers_outputs_from_group_manager() -> None: - """Symmetric with inputs: a ManagerWorkers exposes the group manager's outputs, - so a flow AgentNode wrapping it can wire its result downstream (or surface it as a - leaf).""" - from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig - - cfg = OpenAiCompatibleConfig(name="m", model_id="fake", url="null") - answer = StringProperty(title="answer") + """Symmetric with inputs: a ManagerWorkers exposes the group manager's outputs.""" + cfg = _llm_config() manager = Agent( name="manager", llm_config=cfg, system_prompt="Answer the question.", - outputs=[answer], + outputs=[StringProperty(title="answer")], ) worker = Agent(name="worker", llm_config=cfg, system_prompt="You help.") mw = ManagerWorkers(name="mw", group_manager=manager, workers=[worker]) @@ -67,36 +53,27 @@ def test_managerworkers_infers_outputs_from_group_manager() -> None: def test_managerworkers_runs_as_a_flow_step_with_data_edge_inputs() -> None: - """A ManagerWorkers flow step loads with its data edge resolved, and executes. - - The model is stubbed, so there is no delegation: the manager produces a final - message and routes straight to END. Loading proves the manager node exposes the - ``joke`` input the data edge targets; running proves the manager's answer comes - back as the node's single string output. - """ - from unittest.mock import patch - + """A ManagerWorkers flow step loads with its data edge resolved and executes: + loading proves the node exposes the ``joke`` input the edge targets, running + proves the manager's answer comes back as the node's single string output.""" from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel from langchain_core.messages import AIMessage from langchain_openai import ChatOpenAI from langgraph.checkpoint.memory import MemorySaver from pyagentspec.adapters.langgraph import AgentSpecLoader - from pyagentspec.adapters.langgraph._langgraphconverter import ( - AgentSpecToLangGraphConverter, - ) + from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge from pyagentspec.flows.flow import Flow from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode - from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): pass - # Final message has no tool_calls → the manager routes to END without delegating. + # The final message has no tool_calls → the manager routes to END without delegating. fake_llm = _FakeModel(responses=[AIMessage(content="لماذا...")]) - cfg = OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null") + cfg = _llm_config() joke = StringProperty(title="joke") translated = StringProperty(title="translated") @@ -108,8 +85,6 @@ class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): ) worker = Agent(name="worker", llm_config=cfg, system_prompt="You translate.") mw = ManagerWorkers(name="translator", group_manager=manager, workers=[worker]) - # The manager node exposes the group manager's `joke` input, and the single - # `translated` output (inherited from the group manager) for the leaf edge. assert [p.title for p in (mw.inputs or [])] == ["joke"] manager_node = AgentNode(name="manager_node", agent=mw) @@ -162,5 +137,4 @@ class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): {"configurable": {"thread_id": "managerworkers-node"}}, ) - assert "outputs" in result assert result["outputs"]["translated"] == "لماذا..." diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index 0f9f28d2..da90d28c 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -1,42 +1,35 @@ -# Copyright © 2025 Oracle and/or its affiliates. +# Copyright © 2026 Oracle and/or its affiliates. # # This software is under the Apache License 2.0 # (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. -"""Offline tests for the LangGraph ``ManagerWorkers`` converter. - -These cover the hierarchical topology, roster prompt rendering, the -worker-isolation invariant (each worker sees only its delegated task), -and the recursive nesting case. The LLM is stubbed with -``FakeMessagesListChatModel`` so the tests run without network or model -endpoints. -""" - from typing import Any from unittest.mock import patch import pytest -# ─── Shared helpers ────────────────────────────────────────────────────────── - +from pyagentspec.agent import Agent +from pyagentspec.llms import OpenAiCompatibleConfig +from pyagentspec.managerworkers import ManagerWorkers -@pytest.fixture(autouse=True) -def _offline(allow_llm_config_construction: None) -> None: - """Every test in this module stubs the chat model (``_fake_manager`` or an - explicitly patched ``_llm_convert_to_langgraph``) and never reaches an - endpoint, so the SKIP_LLM_TESTS construction guard would only hide them.""" +# Every test stubs the chat model and never reaches an endpoint, so they run offline +# even under SKIP_LLM_TESTS=1. +pytestmark = pytest.mark.usefixtures("allow_llm_config_construction") -def _llm_cfg(name: str) -> Any: - from pyagentspec.llms.openaicompatibleconfig import OpenAiCompatibleConfig - - return OpenAiCompatibleConfig(name=name, model_id="fake", url="null") +def _agent(name: str, llm_name: str, description: str = "", system_prompt: str = ".") -> Agent: + return Agent( + name=name, + description=description, + system_prompt=system_prompt, + llm_config=OpenAiCompatibleConfig(name=llm_name, model_id="fake", url="null"), + ) -def _fake_manager(*ai_responses: Any) -> Any: - """A FakeMessagesListChatModel subclassed under ChatOpenAI so the - manager's react-agent treats it as an OpenAI-style chat model.""" +def _fake_llm(*ai_responses: Any) -> Any: + """A FakeMessagesListChatModel subclassed under ChatOpenAI so the react-agent + treats it as an OpenAI-style chat model.""" from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel from langchain_openai import ChatOpenAI @@ -47,13 +40,11 @@ class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): def _load_with_fake_llms(mw: Any, default: Any = None, **fakes_by_llm_name: Any) -> Any: - """Compile ``mw`` offline, answering each LLM config with a queued fake. + """Compile ``mw`` offline, answering each LLM config (keyed by ``llm_config.name``) + with a queued fake; ``default`` answers any config not named. - Keys are ``llm_config.name``; ``default`` answers any config not named. - - ``create_agent`` calls ``model.bind_tools(...)``, and ``FakeMessagesListChatModel`` - inherits ``bind_tools`` from the real ``ChatOpenAI``, which calls out to OpenAI. - Binding is stubbed to return the same fake, preserving its response queue. + ``bind_tools`` is stubbed to return the same fake because + ``FakeMessagesListChatModel`` inherits it from ``ChatOpenAI``, which calls OpenAI. """ from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel from langgraph.checkpoint.memory import MemorySaver @@ -79,33 +70,18 @@ def _dispatch(_self: Any, llm_config: Any, *args: Any, **kwargs: Any) -> Any: return loader.load_component(mw) -# ─── Pure-helper unit tests (no LLM) ──────────────────────────────────────── - - -def test_safe_node_name_lowercases_and_collapses_punctuation() -> None: - from pyagentspec.adapters.langgraph._managerworkers import ( - _safe_node_name, - ) +def test_safe_node_name_normalizes_and_falls_back() -> None: + from pyagentspec.adapters.langgraph._managerworkers import _safe_node_name assert _safe_node_name("Research Helper", "id-1") == "research_helper" assert _safe_node_name("My-Worker!! v2", "id-1") == "my_worker_v2" - - -def test_safe_node_name_falls_back_to_normalized_id() -> None: - from pyagentspec.adapters.langgraph._managerworkers import ( - _safe_node_name, - ) - - # Name slugifies to empty → id used (and also normalized). + # Name slugifies to empty → normalized id; both empty → constant fallback. assert _safe_node_name("!!!", "sub-1") == "sub_1" - # Both empty → constant fallback. assert _safe_node_name("", "") == "worker" -def test_append_workers_roster_appends_block_after_existing_prompt() -> None: - from pyagentspec.adapters.langgraph._managerworkers import ( - _append_workers_roster, - ) +def test_append_workers_roster_renders_one_line_per_worker() -> None: + from pyagentspec.adapters.langgraph._managerworkers import _append_workers_roster out = _append_workers_roster( "Coordinate the team.", @@ -117,58 +93,23 @@ def test_append_workers_roster_appends_block_after_existing_prompt() -> None: "- research_helper: Handles research\n" "- drafter: Drafts text" ) - - -def test_append_workers_roster_flattens_multiline_descriptions() -> None: - from pyagentspec.adapters.langgraph._managerworkers import ( - _append_workers_roster, - ) - - out = _append_workers_roster( - "", - [("helper", "First line\nsecond line\n third line ")], - ) - # Whitespace flattened so the one-line-per-worker shape survives. + # Multiline descriptions are flattened so the one-line-per-worker shape survives. + out = _append_workers_roster("", [("helper", "First line\nsecond line\n third line ")]) assert out == "Available workers:\n- helper: First line second line third line" -def test_route_manager_to_worker_or_end_sends_to_pending_delegation() -> None: - from langchain_core.messages import AIMessage - from langgraph.types import Send - - from pyagentspec.adapters.langgraph._managerworkers import ( - _DELEGATE_CALL_ID_KEY, - _DELEGATE_TASK_KEY, - _route_manager_to_worker_or_end, - ) - - delegating = AIMessage( - content="", - tool_calls=[{"name": "delegate_to_research_helper", "args": {"task": "hi"}, "id": "c1"}], - ) - sends = _route_manager_to_worker_or_end({"messages": [delegating]}) - # One delegation → a single Send to the worker node carrying the task - # and the tool_call_id its reply must answer. - assert isinstance(sends, list) and len(sends) == 1 - assert isinstance(sends[0], Send) - assert sends[0].node == "research_helper" - assert sends[0].arg == {_DELEGATE_TASK_KEY: "hi", _DELEGATE_CALL_ID_KEY: "c1"} - - def test_route_manager_to_worker_or_end_returns_end_when_no_delegation() -> None: from langchain_core.messages import AIMessage from langgraph.graph import END - from pyagentspec.adapters.langgraph._managerworkers import ( - _route_manager_to_worker_or_end, - ) + from pyagentspec.adapters.langgraph._managerworkers import _route_manager_to_worker_or_end not_delegating = AIMessage(content="Done.", tool_calls=[]) assert _route_manager_to_worker_or_end({"messages": [not_delegating]}) == END assert _route_manager_to_worker_or_end({"messages": []}) == END -def test_route_manager_to_worker_or_end_fans_out_every_delegation() -> None: +def test_route_manager_to_worker_or_end_fans_out_one_send_per_delegation() -> None: from langchain_core.messages import AIMessage from langgraph.types import Send @@ -187,53 +128,31 @@ def test_route_manager_to_worker_or_end_fans_out_every_delegation() -> None: ], ) sends = _route_manager_to_worker_or_end({"messages": [msg]}) - # Every delegation gets its own Send so each tool_call_id is answered. - # The non-delegation tool call was already executed inside the manager's + # Every delegation gets its own Send carrying the task and the tool_call_id its + # reply must answer. The non-delegation tool call already ran inside the manager's # react loop and is ignored by routing. assert all(isinstance(s, Send) for s in sends) assert [s.node for s in sends] == ["drafter", "research_helper"] - assert [s.arg[_DELEGATE_CALL_ID_KEY] for s in sends] == ["c1", "c2"] assert [s.arg[_DELEGATE_TASK_KEY] for s in sends] == ["x", "y"] - - -# ─── Topology test (no LLM execution; checks compiled graph shape) ────────── + assert [s.arg[_DELEGATE_CALL_ID_KEY] for s in sends] == ["c1", "c2"] def test_manager_workers_compiles_to_hierarchical_graph_topology() -> None: from langchain_core.messages import AIMessage from langgraph.graph import START - from pyagentspec.adapters.langgraph._managerworkers import ( - _MANAGER_NODE_KEY, - ) - from pyagentspec.agent import Agent - from pyagentspec.managerworkers import ManagerWorkers - - manager_agent = Agent( - name="Coordinator", - description="Coordinates", - system_prompt="Coordinate the team.", - llm_config=_llm_cfg("manager_llm"), - ) - worker_a = Agent( - name="Research Helper", - description="Handles research", - system_prompt="Research.", - llm_config=_llm_cfg("worker_a_llm"), - ) - worker_b = Agent( - name="Drafter", - description="Drafts text", - system_prompt="Draft.", - llm_config=_llm_cfg("worker_b_llm"), - ) + from pyagentspec.adapters.langgraph._managerworkers import _MANAGER_NODE_KEY + mw = ManagerWorkers( name="ResearchTeam", - group_manager=manager_agent, - workers=[worker_a, worker_b], + group_manager=_agent("Coordinator", "manager_llm", system_prompt="Coordinate the team."), + workers=[ + _agent("Research Helper", "worker_a_llm", description="Handles research"), + _agent("Drafter", "worker_b_llm", description="Drafts text"), + ], ) - compiled = _load_with_fake_llms(mw, default=_fake_manager(AIMessage(content="Done."))) + compiled = _load_with_fake_llms(mw, default=_fake_llm(AIMessage(content="Done."))) builder = compiled.builder assert _MANAGER_NODE_KEY in builder.nodes @@ -246,88 +165,43 @@ def test_manager_workers_compiles_to_hierarchical_graph_topology() -> None: assert ("research_helper", _MANAGER_NODE_KEY) in edge_pairs assert ("drafter", _MANAGER_NODE_KEY) in edge_pairs - # Manager → worker is a conditional edge, and branches live separately from - # plain edges on the builder. - branches = builder.branches.get(_MANAGER_NODE_KEY) or {} - assert branches, "expected a conditional branch from the manager node" + # Manager → worker is a conditional edge. + assert builder.branches.get(_MANAGER_NODE_KEY) def test_manager_workers_registers_a_delegation_tool_per_worker() -> None: - """Each worker gets a ``delegate_to_`` tool on the manager, matching the - ``Available workers:`` roster the converter renders into its system prompt (the - roster text itself is covered by the ``_append_workers_roster`` unit tests). - """ from langchain_core.messages import AIMessage - from pyagentspec.adapters.langgraph._managerworkers import ( - _MANAGER_NODE_KEY, - ) - from pyagentspec.agent import Agent - from pyagentspec.managerworkers import ManagerWorkers - - manager_agent = Agent( - name="Coordinator", - description="Coordinates", - system_prompt="Coordinate the team.", - llm_config=_llm_cfg("manager_llm"), - ) - worker = Agent( - name="Research Helper", - description="Handles research tasks", - system_prompt="Research.", - llm_config=_llm_cfg("worker_llm"), - ) + from pyagentspec.adapters.langgraph._managerworkers import _MANAGER_NODE_KEY + mw = ManagerWorkers( name="Team", - group_manager=manager_agent, - workers=[worker], + group_manager=_agent("Coordinator", "manager_llm"), + workers=[_agent("Research Helper", "worker_llm", description="Handles research tasks")], ) - compiled = _load_with_fake_llms(mw, default=_fake_manager(AIMessage(content="Done."))) + compiled = _load_with_fake_llms(mw, default=_fake_llm(AIMessage(content="Done."))) - # The manager react-agent is itself a subgraph; the delegation tool the roster - # advertises is registered on its tools node, so the LLM has the matching contract. + # The delegation tool the roster advertises is registered on the manager + # react-agent's tools node, so the LLM has the matching contract. manager_subgraph = compiled.builder.nodes[_MANAGER_NODE_KEY].runnable tools_node = manager_subgraph.builder.nodes["tools"].runnable assert "delegate_to_research_helper" in tools_node.tools_by_name -# ─── End-to-end execution test (offline, fake LLM emitting delegation) ────── - - def test_manager_workers_delegates_and_routes_back_with_tool_message() -> None: - """End to end, the path that proves subgraph composition works. - - The manager emits a delegate_to_ call, the parent graph routes to the - worker subgraph in an isolated message context, the worker's answer comes back - as a ToolMessage matched to the pending tool_call_id, and the manager's next - turn terminates the graph. - """ + """The manager delegates, the worker runs in an isolated message context and its + answer comes back as a ToolMessage matched to the pending tool_call_id, and the + manager's next turn terminates the graph.""" from langchain_core.messages import AIMessage, HumanMessage - from pyagentspec.agent import Agent - from pyagentspec.managerworkers import ManagerWorkers - - manager_agent = Agent( - name="Coordinator", - description="Coordinates", - system_prompt="You coordinate.", - llm_config=_llm_cfg("manager_llm"), - ) - worker = Agent( - name="Research Helper", - description="Handles research", - system_prompt="You research.", - llm_config=_llm_cfg("worker_llm"), - ) mw = ManagerWorkers( name="Team", - group_manager=manager_agent, - workers=[worker], + group_manager=_agent("Coordinator", "manager_llm", system_prompt="You coordinate."), + workers=[_agent("Research Helper", "worker_llm", description="Handles research")], ) - # Manager turn 1: delegate to research_helper. - # Manager turn 2: produce final answer (no tool call → END). + # Manager turn 1: delegate. Manager turn 2: final answer (no tool call → END). manager_responses = [ AIMessage( content="", @@ -341,68 +215,43 @@ def test_manager_workers_delegates_and_routes_back_with_tool_message() -> None: ), AIMessage(content="The worker reports: Saturn has rings."), ] - # Worker turn 1: produce its own final answer. worker_responses = [AIMessage(content="Saturn has rings.")] compiled = _load_with_fake_llms( mw, - manager_llm=_fake_manager(*manager_responses), - worker_llm=_fake_manager(*worker_responses), + manager_llm=_fake_llm(*manager_responses), + worker_llm=_fake_llm(*worker_responses), ) - # Sync invocation only. FakeMessagesListChatModel overrides ``_generate`` but not - # ``_agenerate``, so the async path would resolve up the MRO to the real - # ``ChatOpenAI._agenerate`` and call OpenAI. + # Sync invocation only: FakeMessagesListChatModel overrides ``_generate`` but not + # ``_agenerate``, so the async path would resolve to ``ChatOpenAI._agenerate`` + # and call OpenAI. result = compiled.invoke( {"messages": [HumanMessage(content="Tell me about Saturn.")]}, {"configurable": {"thread_id": "mw-1"}}, ) messages = result["messages"] - msg_types = [type(m).__name__ for m in messages] - assert "HumanMessage" in msg_types - assert "ToolMessage" in msg_types assert isinstance(messages[-1], AIMessage) assert "Saturn has rings" in messages[-1].content - - # Matching the pending delegation id proves the isolation wrapper threaded the - # call id through. tool_msgs = [m for m in messages if type(m).__name__ == "ToolMessage"] assert tool_msgs and tool_msgs[0].tool_call_id == "call_1" assert "Saturn has rings" in tool_msgs[0].content def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: - """Regression: when the manager emits SEVERAL ``delegate_to_`` - tool calls in one turn (e.g. "spin up 5 sub-agents"), every delegation - must run and be answered by its own ToolMessage matched to the - originating tool_call_id. - - Before the fix the parent graph routed only the first delegation and left the - other tool_call_ids unanswered. That is an invalid tool-call/tool-result - sequence, and the manager hallucinated the missing replies. - """ + """When one manager turn emits several delegations, each must be answered by its + own ToolMessage matched to the originating tool_call_id; an unanswered one is an + invalid tool-call/result sequence the manager would hallucinate around.""" from langchain_core.messages import AIMessage, HumanMessage - from pyagentspec.agent import Agent - from pyagentspec.managerworkers import ManagerWorkers - - manager_agent = Agent( - name="Coordinator", - description="Coordinates", - system_prompt="You coordinate.", - llm_config=_llm_cfg("manager_llm"), - ) - worker = Agent( - name="Sub Agent", - description="Writes poems", - system_prompt="You write poems.", - llm_config=_llm_cfg("worker_llm"), + mw = ManagerWorkers( + name="Team", + group_manager=_agent("Coordinator", "manager_llm", system_prompt="You coordinate."), + workers=[_agent("Sub Agent", "worker_llm", description="Writes poems")], ) - mw = ManagerWorkers(name="Team", group_manager=manager_agent, workers=[worker]) - # Turn 1: three delegations to the SAME worker in one AIMessage. - # Turn 2: terminate (no tool call). + # Turn 1: three delegations to the same worker in one AIMessage. Turn 2: terminate. manager_responses = [ AIMessage( content="", @@ -414,13 +263,12 @@ def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: ), AIMessage(content="Here are your three poems."), ] - # Each worker invocation pops one reply; provide enough for the fan-out. worker_responses = [AIMessage(content=f"poem #{i}") for i in range(1, 6)] compiled = _load_with_fake_llms( mw, - manager_llm=_fake_manager(*manager_responses), - worker_llm=_fake_manager(*worker_responses), + manager_llm=_fake_llm(*manager_responses), + worker_llm=_fake_llm(*worker_responses), ) result = compiled.invoke( @@ -429,138 +277,68 @@ def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: ) messages = result["messages"] - # Every delegation tool_call_id must be answered by exactly one ToolMessage. - requested = { - tc["id"] - for m in messages - if isinstance(m, AIMessage) - for tc in (m.tool_calls or []) - if tc["name"].startswith("delegate_to_") - } - answered = [m.tool_call_id for m in messages if type(m).__name__ == "ToolMessage"] - assert requested == {"call_1", "call_2", "call_3"} - assert sorted(answered) == [ - "call_1", - "call_2", - "call_3", - ], f"unanswered delegations: {requested - set(answered)}" - # No duplicate replies, and each carries a worker poem. - assert len(answered) == 3 tool_msgs = [m for m in messages if type(m).__name__ == "ToolMessage"] + answered = sorted(m.tool_call_id for m in tool_msgs) + assert answered == ["call_1", "call_2", "call_3"] assert all(m.content.startswith("poem #") for m in tool_msgs) -# ─── Recursive nesting ────────────────────────────────────────────────────── - - def test_nested_manager_workers_compiles_recursively() -> None: - """A worker that is itself a ManagerWorkers compiles through the same dispatch, - becoming a CompiledStateGraph the outer parent graph wires in as a subgraph node.""" + """A worker that is itself a ManagerWorkers compiles through the same dispatch and + is wired in as a subgraph node of the outer parent graph.""" from langchain_core.messages import AIMessage - from pyagentspec.agent import Agent - from pyagentspec.managerworkers import ManagerWorkers - - leaf = Agent( - name="Leaf", - description="Leaf task", - system_prompt="Leaf.", - llm_config=_llm_cfg("leaf_llm"), - ) - inner_manager = Agent( - name="InnerManager", - description="Inner", - system_prompt="Manage leaves.", - llm_config=_llm_cfg("inner_llm"), - ) inner_mw = ManagerWorkers( name="Inner", - group_manager=inner_manager, - workers=[leaf], - ) - outer_manager = Agent( - name="OuterManager", - description="Outer", - system_prompt="Manage subteams.", - llm_config=_llm_cfg("outer_llm"), + group_manager=_agent("InnerManager", "inner_llm", system_prompt="Manage leaves."), + workers=[_agent("Leaf", "leaf_llm", description="Leaf task")], ) outer_mw = ManagerWorkers( name="Outer", - group_manager=outer_manager, + group_manager=_agent("OuterManager", "outer_llm", system_prompt="Manage subteams."), workers=[inner_mw], ) - compiled = _load_with_fake_llms(outer_mw, default=_fake_manager(AIMessage(content="Done."))) + compiled = _load_with_fake_llms(outer_mw, default=_fake_llm(AIMessage(content="Done."))) - # Outer parent graph has a node for the inner ManagerWorkers worker. assert "inner" in compiled.builder.nodes def test_rejects_non_agent_group_manager() -> None: - """group_manager must be an Agent. Pyagentspec accepts any AgenticComponent, but - the adapter needs a chat-LLM emitting tool_calls to decide where to delegate.""" + """A nested ManagerWorkers as group_manager is valid per the pyagentspec + validators, but the adapter needs a chat-LLM emitting tool_calls to route on.""" from langgraph.checkpoint.memory import MemorySaver from pyagentspec.adapters.langgraph import AgentSpecLoader - from pyagentspec.agent import Agent - from pyagentspec.managerworkers import ManagerWorkers - - # A nested ManagerWorkers as group_manager: valid per the pyagentspec - # validators, unsupported here. - leaf = Agent( - name="Leaf", - description="L", - system_prompt="L.", - llm_config=_llm_cfg("l"), - ) - inner_manager = Agent( - name="Inner", - description="I", - system_prompt="I.", - llm_config=_llm_cfg("i"), - ) + inner_mw = ManagerWorkers( name="Inner", - group_manager=inner_manager, - workers=[leaf], + group_manager=_agent("Inner", "i"), + workers=[_agent("Leaf", "l")], ) outer_mw = ManagerWorkers( name="Outer", group_manager=inner_mw, - workers=[ - Agent(name="Other", description="O", system_prompt="O.", llm_config=_llm_cfg("o")), - ], + workers=[_agent("Other", "o")], ) + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) with pytest.raises(NotImplementedError, match="group_manager must be an Agent"): loader.load_component(outer_mw) -# ─── Worker name collision ────────────────────────────────────────────────── - - def test_workers_with_name_slug_collision_are_rejected() -> None: - """Two workers whose names normalize to the same node identifier - would silently overwrite each other in the parent graph; raise at - load time instead.""" + """Two workers whose names normalize to the same node identifier would silently + overwrite each other in the parent graph; raise at load time instead.""" from langgraph.checkpoint.memory import MemorySaver from pyagentspec.adapters.langgraph import AgentSpecLoader - from pyagentspec.agent import Agent - from pyagentspec.managerworkers import ManagerWorkers - a = Agent(name="Helper A", description="x", system_prompt=".", llm_config=_llm_cfg("a")) - b = Agent(name="helper-a", description="x", system_prompt=".", llm_config=_llm_cfg("b")) - # Both normalize to "helper_a". + # Both worker names normalize to "helper_a". mw = ManagerWorkers( name="T", - group_manager=Agent( - name="M", - description="m", - system_prompt=".", - llm_config=_llm_cfg("m"), - ), - workers=[a, b], + group_manager=_agent("M", "m"), + workers=[_agent("Helper A", "a"), _agent("helper-a", "b")], ) loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) @@ -569,24 +347,19 @@ def test_workers_with_name_slug_collision_are_rejected() -> None: def test_worker_events_stream_natively_namespaced_under_worker_node() -> None: - """Regression: a worker's token events must stream under the worker - node's checkpoint namespace so a consumer can attribute them to the - sub-agent. The wrapper must inherit the ambient run config (no fresh - thread_id); a fresh thread_id detaches the worker into a top-level - ``agent:`` run with no worker prefix, which is unattributable.""" + """Regression: a worker's token events must stream under the worker node's + checkpoint namespace so a consumer can attribute them to the sub-agent. The + wrapper must inherit the ambient run config; a fresh thread_id would detach the + worker into an unattributable top-level ``agent:`` run.""" import asyncio - from langchain_core.language_models.fake_chat_models import ( - GenericFakeChatModel, - ) + from langchain_core.language_models.fake_chat_models import GenericFakeChatModel from langchain_core.messages import AIMessage, HumanMessage from langgraph.graph import END, START, MessagesState, StateGraph - from pyagentspec.adapters.langgraph._managerworkers import ( - _wrap_worker_for_subgraph, - ) + from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph - # A minimal worker compiled graph that streams some content. + # A minimal worker graph that streams some content. wmodel = GenericFakeChatModel(messages=iter([AIMessage(content="Saturn has rings")] * 9)) wb = StateGraph(MessagesState) @@ -598,8 +371,8 @@ async def _wagent(state: Any) -> Any: wb.add_edge("agent", END) worker_graph = wb.compile() - # Parent: a plain manager node emits the delegate tool call, then routes - # to the wrapped worker node named "research_helper". + # Parent: a plain manager node emits the delegate tool call, then routes to the + # wrapped worker node. pb = StateGraph(MessagesState) def _manager(state: Any) -> Any: @@ -639,115 +412,9 @@ async def _collect() -> Any: namespaces = asyncio.run(_collect()) assert namespaces, "expected the worker to emit token-stream events" - # Every worker token event is namespaced under the worker node, so a - # consumer can attribute the stream to the sub-agent. assert all(ns.startswith("research_helper:") for ns in namespaces), namespaces -# ─── Shared low-level helper unit tests (no LLM) ───────────────────────────── - - -def test_normalize_identifier_lowercases_collapses_and_strips() -> None: - """The single normalization used for both worker node names and - ``transfer_to_`` tool names.""" - from pyagentspec.adapters.langgraph._managerworkers import _normalize_identifier - - assert _normalize_identifier("Research Helper") == "research_helper" - assert _normalize_identifier("My-Worker!! v2") == "my_worker_v2" - # Punctuation-only / empty slugify to the empty string (callers add a fallback). - assert _normalize_identifier("!!!") == "" - assert _normalize_identifier("") == "" - - -def test_messages_of_reads_dict_and_object_state() -> None: - """The delegation tool receives state as a dict or an attribute-bearing - object depending on the langgraph injection path.""" - from langchain_core.messages import AIMessage - - from pyagentspec.adapters.langgraph._managerworkers import _messages_of - - msg = AIMessage(content="hi") - assert _messages_of({"messages": [msg]}) == [msg] - assert _messages_of({"messages": None}) == [] - assert _messages_of({}) == [] - - class _State: - messages = [msg] - - assert _messages_of(_State()) == [msg] - - class _Empty: - pass - - assert _messages_of(_Empty()) == [] - - -def test_surface_to_parent_command_projects_messages_with_no_goto() -> None: - """The placeholder tool's body: break to the parent graph, project the - subgraph messages, carry no ``goto`` (routing is the parent's job).""" - from langchain_core.messages import AIMessage - from langgraph.types import Command - - from pyagentspec.adapters.langgraph._managerworkers import _surface_to_parent_command - - m1, m2 = AIMessage(content="a"), AIMessage(content="b") - cmd = _surface_to_parent_command({"messages": [m1, m2]}) - - assert isinstance(cmd, Command) - assert cmd.graph == Command.PARENT - assert cmd.goto == () # no goto; the parent graph decides where to go - assert cmd.update == {"messages": [m1, m2]} - - -def test_delegation_tool_exposes_expected_name_and_description() -> None: - """The placeholder tool the manager's LLM addresses by name.""" - from pyagentspec.adapters.langgraph._managerworkers import _make_worker_delegation_tool - - delegate = _make_worker_delegation_tool("research_helper") - assert delegate.name == "delegate_to_research_helper" - assert "research_helper" in delegate.description - - -# ─── _wrap_worker_for_subgraph: pending-delegation extraction (no LLM) ──────── - - -def _echo_worker_graph(reply: str = "WORKER REPLY") -> Any: - """A worker CompiledStateGraph whose only node returns a fixed AIMessage, enough - to exercise the wrapper without an LLM.""" - from langchain_core.messages import AIMessage - from langgraph.graph import END, START, MessagesState, StateGraph - - wb = StateGraph(MessagesState) - wb.add_node("agent", lambda state: {"messages": [AIMessage(content=reply)]}) - wb.add_edge(START, "agent") - wb.add_edge("agent", END) - return wb.compile() - - -def test_wrap_worker_uses_send_payload_task_and_call_id() -> None: - """Fan-out path: the routing edge's ``Send`` payload carries the task and - the originating tool_call_id directly, so the worker reply ToolMessage is - matched to that call.""" - from langchain_core.messages import ToolMessage - - from pyagentspec.adapters.langgraph._managerworkers import ( - _DELEGATE_CALL_ID_KEY, - _DELEGATE_TASK_KEY, - _wrap_worker_for_subgraph, - ) - - node = _wrap_worker_for_subgraph(_echo_worker_graph("DONE"), "research_helper") - out = node.invoke({_DELEGATE_TASK_KEY: "do it", _DELEGATE_CALL_ID_KEY: "call_9"}) - - (reply,) = out["messages"] - assert isinstance(reply, ToolMessage) - assert reply.content == "DONE" - assert reply.tool_call_id == "call_9" - - -# ─── Delegation visibility: the public consumer-side filter ────────────────── - - def test_is_delegation_tool_name_matches_only_the_synthetic_prefix() -> None: from pyagentspec.adapters.langgraph._managerworkers import ( DELEGATE_TOOL_PREFIX, @@ -764,34 +431,16 @@ def test_is_delegation_tool_name_matches_only_the_synthetic_prefix() -> None: def test_manager_workers_leaves_astream_events_unwrapped() -> None: - """The delegation protocol is deliberately visible: nothing wraps - ``astream_events`` to scrub it. Only stream/astream are patched, for the - ManagerWorkersExecutionSpan.""" - - from pyagentspec.agent import Agent - from pyagentspec.managerworkers import ManagerWorkers - + """The delegation protocol is deliberately visible: only stream/astream are + patched (for the ManagerWorkersExecutionSpan); nothing wraps ``astream_events`` + to scrub the delegation tool calls.""" mw = ManagerWorkers( name="Team", - group_manager=Agent( - name="Coordinator", - description="c", - system_prompt=".", - llm_config=_llm_cfg("manager_llm"), - ), - workers=[ - Agent( - name="Research Helper", - description="r", - system_prompt=".", - llm_config=_llm_cfg("worker_llm"), - ), - ], + group_manager=_agent("Coordinator", "manager_llm"), + workers=[_agent("Research Helper", "worker_llm")], ) - compiled = _load_with_fake_llms(mw, default=_fake_manager()) + compiled = _load_with_fake_llms(mw, default=_fake_llm()) - assert getattr(compiled.astream_events, "__name__", "") != "patched_astream_events" - # The execution-span patches are still applied. - assert getattr(compiled.stream, "__name__", "") == "patched_stream" - assert getattr(compiled.astream, "__name__", "") == "patched_astream" + assert "stream" in compiled.__dict__ and "astream" in compiled.__dict__ + assert "astream_events" not in compiled.__dict__ From 4ed7f7cc52810824b63adf5f6dee66d3e2f12371 Mon Sep 17 00:00:00 2001 From: Salah Date: Mon, 17 Aug 2026 21:53:31 +0400 Subject: [PATCH 07/17] refactor(adapters/langgraph): address ManagerWorkers review feedback - Remove the SKIP_LLM_TESTS opt-out fixture from tests/adapters/conftest.py and its usages; the tests now skip like every other LLM-config test. - Rename the delegation tool prefix to __delegate_to__ so it cannot collide with a real tool name, and route a delegation only when its suffix is an actual worker node. - Move flows/test_managerworkers_node.py into the main test_managerworkers.py since ManagerWorkers is not a Node. - Enforce that the I/Os of a ManagerWorkers match the I/Os of its group manager (same name and type), per the language spec decision. - Reject unsupported ManagerWorkers flow-step output shapes when the flow is converted instead of when the step runs. --- .../adapters/langgraph/_langgraphconverter.py | 12 +- .../adapters/langgraph/_managerworkers.py | 66 ++++-- .../adapters/langgraph/_node_execution.py | 21 +- pyagentspec/src/pyagentspec/managerworkers.py | 32 ++- pyagentspec/tests/adapters/conftest.py | 30 --- .../flows/test_managerworkers_node.py | 140 ----------- .../adapters/langgraph/test_managerworkers.py | 223 ++++++++++++++++-- .../test_agentic_patterns_validation.py | 67 ++++++ 8 files changed, 355 insertions(+), 236 deletions(-) delete mode 100644 pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index a98733d8..17536fdb 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -39,9 +39,9 @@ from pyagentspec.adapters.langgraph._managerworkers import ( _MANAGER_NODE_KEY, _append_workers_roster, + _make_manager_router, _make_worker_delegation_tool, _patch_with_manager_workers_execution_span, - _route_manager_to_worker_or_end, _safe_node_name, _wrap_worker_for_subgraph, ) @@ -1086,13 +1086,13 @@ def _manager_workers_convert_to_langgraph( Topology:: - ┌─ delegate_to_w1 ─→ worker_1 ─┐ - START → manager ┤ ├→ manager (loop) - └─ delegate_to_w2 ─→ worker_2 ─┘ + ┌─ __delegate_to__w1 ─→ worker_1 ─┐ + START → manager ┤ ├→ manager (loop) + └─ __delegate_to__w2 ─→ worker_2 ─┘ │ └─ no tool_call ─→ END - The manager is a react-agent holding one synthetic ``delegate_to_`` + The manager is a react-agent holding one synthetic ``__delegate_to__`` tool per worker. A conditional edge routes each delegation to its worker, which runs in an isolated message context and answers with a ``ToolMessage`` matched to the pending delegation id. Workers are converted recursively and @@ -1161,7 +1161,7 @@ def _manager_workers_convert_to_langgraph( builder.add_edge(langgraph_graph.START, _MANAGER_NODE_KEY) builder.add_conditional_edges( _MANAGER_NODE_KEY, - _route_manager_to_worker_or_end, + _make_manager_router(worker_node_names), # The path map covers every worker plus END, so langgraph can validate # the routing statically. {node_name: node_name for node_name in worker_node_names} diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py index c331722e..05077eec 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py @@ -7,13 +7,13 @@ """Helpers for compiling a ``ManagerWorkers`` into LangGraph, orchestrated by ``AgentSpecToLangGraphConverter._manager_workers_convert_to_langgraph``. -The delegation protocol is visible on purpose: ``delegate_to_`` calls stream -like any other tool call. Consumers that would rather not render them can filter on -:func:`is_delegation_tool_name`. +The delegation protocol is visible on purpose: ``__delegate_to__`` calls +stream like any other tool call. Consumers that would rather not render them can +filter on :func:`is_delegation_tool_name`. """ import re -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, Iterable, List, Tuple from pyagentspec.adapters.langgraph._execution_span import patch_with_execution_span from pyagentspec.adapters.langgraph._types import CompiledStateGraph, langgraph_graph @@ -31,9 +31,11 @@ # Cannot collide with a normalized worker node name, which is always [a-z0-9_]. _MANAGER_NODE_KEY = "__manager__" -#: Prefix of the synthetic ``delegate_to_`` tool names the manager's LLM uses -#: to address a worker. Public so consumers can recognize the protocol. -DELEGATE_TOOL_PREFIX = "delegate_to_" +#: Prefix of the synthetic ``__delegate_to__`` tool names the manager's LLM +#: uses to address a worker. The dunder prefix, like the delegation keys below, keeps +#: it from colliding with a real tool named ``delegate_to_``. Public so +#: consumers can recognize the protocol. +DELEGATE_TOOL_PREFIX = "__delegate_to__" # Keys of the per-delegation ``Send`` payload: the task to run, and the tool_call_id # the worker's reply must answer. Routing per delegation (instead of off shared state) @@ -45,7 +47,7 @@ def is_delegation_tool_name(name: Any) -> bool: - """True for the synthetic ``delegate_to_`` tool names a manager emits.""" + """True for the synthetic ``__delegate_to__`` tool names a manager emits.""" return isinstance(name, str) and name.startswith(DELEGATE_TOOL_PREFIX) @@ -57,7 +59,7 @@ def _normalize_identifier(s: str) -> str: def _safe_node_name(name: str, fallback_id: str) -> str: """Normalize a worker name into a LangGraph node identifier. - The LLM has to emit ``delegate_to_`` reliably as a tool name, so node + The LLM has to emit ``__delegate_to__`` reliably as a tool name, so node names stay ASCII identifiers. Falls back to the normalized component id when the name slugifies to nothing. """ @@ -87,12 +89,12 @@ def _append_workers_roster(system_prompt: str, entries: List[Tuple[str, str]]) - def _make_worker_delegation_tool(worker_node_name: str) -> Any: - """Build the ``delegate_to_`` tool the manager's LLM emits to route to a - worker. + """Build the ``__delegate_to__`` tool the manager's LLM emits to route to + a worker. Executing the tool is only how the call escapes the react subgraph: its body surfaces the subgraph messages to the parent with ``Command(graph=PARENT)`` and no - ``goto``. Routing stays in :func:`_route_manager_to_worker_or_end`; a ``goto`` here + ``goto``. Routing stays in the edge built by :func:`_make_manager_router`; a ``goto`` here would collapse several same-turn delegations into one parent Command and leave the other ``tool_call_id``s unanswered. """ @@ -123,33 +125,45 @@ def _delegate( return _delegate -def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: - """Route the parent graph off the manager's last AIMessage: one ``Send`` per - ``delegate_to_`` tool call, or ``END`` when it emitted none. +def _make_manager_router(worker_node_names: Iterable[str]) -> Any: + """Build the conditional edge routing the parent graph off the manager's last + AIMessage: one ``Send`` per ``__delegate_to__`` tool call, or ``END`` + when it emitted none. Every delegation gets its own ``Send``, so each tool_call_id is answered independently; an unanswered one breaks the manager's next-turn tool-call/result - sequence. Plain tool calls already ran inside the react loop. + sequence. Plain tool calls already ran inside the react loop; that includes a + real tool whose name merely starts with the prefix, which is why a suffix that + is not a worker node is not routed. """ - from langgraph.types import Send - - messages = state.get("messages") or [] - last = messages[-1] if messages else None - sends = [] - for tool_call in getattr(last, "tool_calls", None) or []: - name = tool_call.get("name") - if is_delegation_tool_name(name): + known_workers = frozenset(worker_node_names) + + def _route_manager_to_worker_or_end(state: Dict[str, Any]) -> Any: + from langgraph.types import Send + + messages = state.get("messages") or [] + last = messages[-1] if messages else None + sends = [] + for tool_call in getattr(last, "tool_calls", None) or []: + name = tool_call.get("name") + if not is_delegation_tool_name(name): + continue + worker_node_name = name[len(DELEGATE_TOOL_PREFIX) :] + if worker_node_name not in known_workers: + continue args = tool_call.get("args") or {} sends.append( Send( - name[len(DELEGATE_TOOL_PREFIX) :], + worker_node_name, { _DELEGATE_TASK_KEY: args.get("task") or "", _DELEGATE_CALL_ID_KEY: tool_call.get("id") or "", }, ) ) - return sends or langgraph_graph.END + return sends or langgraph_graph.END + + return _route_manager_to_worker_or_end def _worker_input(state: Dict[str, Any]) -> Dict[str, Any]: diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index f3df8873..915abc1c 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -493,6 +493,17 @@ def __init__( super().__init__(node) if not isinstance(self.node, AgentSpecAgentNode): raise TypeError("AgentNodeExecutor can only be initialized with AgentNode") + if isinstance(self.node.agent, AgentSpecManagerWorkers): + # The hierarchical graph runs over MessagesState, which cannot carry a + # structured_response outward: the manager's final message is the only + # result, so anything but a single string output cannot be honored. + # Raising here fails at conversion time rather than mid-run. + outputs = self.node.outputs or [] + if outputs and (len(outputs) != 1 or outputs[0].type != "string"): + raise NotImplementedError( + "A ManagerWorkers flow step supports a single string output; " + f"node `{self.node.name}` declares {[o.title for o in outputs]}." + ) self.tool_registry = tool_registry self.checkpointer = checkpointer self.converted_components = converted_components @@ -607,13 +618,9 @@ def _format_agent_result(self, result: Dict[str, Any]) -> ExecuteOutput: if isinstance(self.node.agent, AgentSpecManagerWorkers): # The hierarchical graph runs over MessagesState, which cannot carry a # structured_response outward: the manager's final message is the result. - outputs = self.node.outputs - if len(outputs) != 1 or outputs[0].type != "string": - raise NotImplementedError( - "A ManagerWorkers flow step supports a single string output; " - f"node `{self.node.name}` declares {[o.title for o in outputs]}." - ) - return {outputs[0].title: result["messages"][-1].content}, NodeExecutionDetails() + # __init__ already rejected any shape but a single string output. + node_outputs = self.node.outputs or [] + return {node_outputs[0].title: result["messages"][-1].content}, NodeExecutionDetails() outputs = extract_outputs_from_invoke_result(result, self.node.outputs or []) return outputs, NodeExecutionDetails() diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index f89853db..f38526a7 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -67,10 +67,11 @@ class ManagerWorkers(AgenticComponent): ) def _get_inferred_inputs(self) -> List[Property]: - # The group manager drives the conversation and is the component whose prompt - # the runtime renders, so the group accepts exactly the inputs the manager - # accepts. The hasattr guard matches Flow._get_inferred_inputs: validators can - # run this against a partially-constructed model with no group_manager yet. + # Per the language spec, the inputs of a ManagerWorkers are the inputs of its + # group manager (same name and type): the manager drives the conversation and + # is the component whose prompt the runtime renders. The hasattr guard matches + # Flow._get_inferred_inputs: validators can run this against a + # partially-constructed model with no group_manager yet. return (self.group_manager.inputs or []) if hasattr(self, "group_manager") else [] def _get_inferred_outputs(self) -> List[Property]: @@ -91,3 +92,26 @@ def _validate_group_manager_is_not_included_as_a_worker(self) -> Self: if any(self.group_manager is agent for agent in self.workers): raise ValueError("Group manager cannot be a worker.") return self + + @model_validator_with_error_accumulation + def _validate_ios_match_group_manager_ios(self) -> Self: + # Per the language spec, the I/Os of a ManagerWorkers must be the I/Os of its + # group manager, same name and type. The base ComponentWithIO validators + # already enforce matching titles; enforce matching types here. + if not hasattr(self, "group_manager"): + return self + for kind, own_properties, manager_properties in ( + ("input", self.inputs or [], self.group_manager.inputs or []), + ("output", self.outputs or [], self.group_manager.outputs or []), + ): + manager_type_by_title = {p.title: p.type for p in manager_properties} + for own_property in own_properties: + manager_type = manager_type_by_title.get(own_property.title) + if manager_type is not None and own_property.type != manager_type: + raise ValueError( + f"The {kind}s of a `ManagerWorkers` must match the {kind}s of its " + f"group manager (same name and type), but {kind} " + f"`{own_property.title}` has type `{own_property.type}` while the " + f"group manager declares `{manager_type}`." + ) + return self diff --git a/pyagentspec/tests/adapters/conftest.py b/pyagentspec/tests/adapters/conftest.py index 8b5714ba..d5732212 100644 --- a/pyagentspec/tests/adapters/conftest.py +++ b/pyagentspec/tests/adapters/conftest.py @@ -6,8 +6,6 @@ import os import ssl -from contextlib import ExitStack -from importlib import import_module from pathlib import Path from typing import Any from unittest.mock import patch @@ -114,34 +112,6 @@ def _skip(*_args, **_kwargs): p.stop() -def _resolve(dotted: str) -> Any: - module_path, _, attr = dotted.rpartition(".") - module_path, _, cls_name = module_path.rpartition(".") - return getattr(getattr(import_module(module_path), cls_name), attr) - - -# Captured at conftest import, before any session fixture starts patching, so these -# are the genuine constructors rather than a skip stub. -_REAL_LLM_INITS = {dotted: _resolve(dotted) for dotted in LLM_MOCKED_METHODS} - - -@pytest.fixture -def allow_llm_config_construction(): - """ - Opt out of the SKIP_LLM_TESTS=1 construction guard for one test, restoring the - real LLM config constructors. Only request this from a test that stubs the model - and never reaches an endpoint; such tests should run offline instead of skipping. - """ - if not should_skip_llm_test(): - # Nothing patched the constructors, so there is nothing to restore. - yield - return - with ExitStack() as stack: - for dotted, real in _REAL_LLM_INITS.items(): - stack.enter_context(patch(dotted, new=real)) - yield - - @pytest.fixture(scope="package") def json_server(json_server_port: int): api_server = Path(__file__).parent / "api_server.py" diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py b/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py deleted file mode 100644 index db551602..00000000 --- a/pyagentspec/tests/adapters/langgraph/flows/test_managerworkers_node.py +++ /dev/null @@ -1,140 +0,0 @@ -# Copyright © 2026 Oracle and/or its affiliates. -# -# This software is under the Apache License 2.0 -# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License -# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. - -from unittest.mock import patch - -import pytest - -from pyagentspec.agent import Agent -from pyagentspec.llms import OpenAiCompatibleConfig -from pyagentspec.managerworkers import ManagerWorkers -from pyagentspec.property import StringProperty - -# These tests only need an LLM config object and stub the chat model, so they run -# offline even under SKIP_LLM_TESTS=1. -pytestmark = pytest.mark.usefixtures("allow_llm_config_construction") - - -def _llm_config() -> OpenAiCompatibleConfig: - return OpenAiCompatibleConfig(name="agent_llm", model_id="fake", url="null") - - -def test_managerworkers_infers_inputs_from_group_manager_prompt() -> None: - """A ManagerWorkers exposes the group manager's prompt placeholders as inputs, so - a flow AgentNode wrapping it declares input ports a DataFlowEdge can resolve.""" - cfg = _llm_config() - manager = Agent( - name="manager", - llm_config=cfg, - system_prompt="Translate the following to Arabic:\n\n{{joke}}\n\nMake {{count}} variants.", - ) - worker = Agent(name="worker", llm_config=cfg, system_prompt="You translate.") - mw = ManagerWorkers(name="mw", group_manager=manager, workers=[worker]) - - assert sorted(p.title for p in (mw.inputs or [])) == ["count", "joke"] - - -def test_managerworkers_infers_outputs_from_group_manager() -> None: - """Symmetric with inputs: a ManagerWorkers exposes the group manager's outputs.""" - cfg = _llm_config() - manager = Agent( - name="manager", - llm_config=cfg, - system_prompt="Answer the question.", - outputs=[StringProperty(title="answer")], - ) - worker = Agent(name="worker", llm_config=cfg, system_prompt="You help.") - mw = ManagerWorkers(name="mw", group_manager=manager, workers=[worker]) - - assert [p.title for p in (mw.outputs or [])] == ["answer"] - - -def test_managerworkers_runs_as_a_flow_step_with_data_edge_inputs() -> None: - """A ManagerWorkers flow step loads with its data edge resolved and executes: - loading proves the node exposes the ``joke`` input the edge targets, running - proves the manager's answer comes back as the node's single string output.""" - from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel - from langchain_core.messages import AIMessage - from langchain_openai import ChatOpenAI - from langgraph.checkpoint.memory import MemorySaver - - from pyagentspec.adapters.langgraph import AgentSpecLoader - from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter - from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge - from pyagentspec.flows.flow import Flow - from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode - - class _FakeModel(FakeMessagesListChatModel, ChatOpenAI): - pass - - # The final message has no tool_calls → the manager routes to END without delegating. - fake_llm = _FakeModel(responses=[AIMessage(content="لماذا...")]) - - cfg = _llm_config() - joke = StringProperty(title="joke") - translated = StringProperty(title="translated") - - manager = Agent( - name="manager", - llm_config=cfg, - system_prompt="Translate the following to Arabic:\n\n{{joke}}", - outputs=[translated], - ) - worker = Agent(name="worker", llm_config=cfg, system_prompt="You translate.") - mw = ManagerWorkers(name="translator", group_manager=manager, workers=[worker]) - assert [p.title for p in (mw.inputs or [])] == ["joke"] - - manager_node = AgentNode(name="manager_node", agent=mw) - start_node = StartNode(name="start", inputs=[joke]) - end_node = EndNode(name="end", outputs=[translated]) - flow = Flow( - name="flow", - start_node=start_node, - nodes=[start_node, manager_node, end_node], - control_flow_connections=[ - ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=manager_node), - ControlFlowEdge(name="node_to_end", from_node=manager_node, to_node=end_node), - ], - data_flow_connections=[ - DataFlowEdge( - name="joke_edge", - source_node=start_node, - source_output=joke.title, - destination_node=manager_node, - destination_input=joke.title, - ), - DataFlowEdge( - name="translated_edge", - source_node=manager_node, - source_output=translated.title, - destination_node=end_node, - destination_input=translated.title, - ), - ], - outputs=[translated], - ) - - loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) - with patch.object( - AgentSpecToLangGraphConverter, - "_llm_convert_to_langgraph", - autospec=True, - side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, - ), patch.object( - FakeMessagesListChatModel, - "bind_tools", - new=lambda self_obj, *a, **k: self_obj, - ): - compiled = loader.load_component(flow) - result = compiled.invoke( - { - "inputs": {"joke": "Why did the car..."}, - "messages": [{"role": "user", "content": ""}], - }, - {"configurable": {"thread_id": "managerworkers-node"}}, - ) - - assert result["outputs"]["translated"] == "لماذا..." diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index da90d28c..96cdc656 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -4,7 +4,7 @@ # (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License # (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. -from typing import Any +from typing import Any, List, Optional from unittest.mock import patch import pytest @@ -12,18 +12,22 @@ from pyagentspec.agent import Agent from pyagentspec.llms import OpenAiCompatibleConfig from pyagentspec.managerworkers import ManagerWorkers +from pyagentspec.property import Property -# Every test stubs the chat model and never reaches an endpoint, so they run offline -# even under SKIP_LLM_TESTS=1. -pytestmark = pytest.mark.usefixtures("allow_llm_config_construction") - -def _agent(name: str, llm_name: str, description: str = "", system_prompt: str = ".") -> Agent: +def _agent( + name: str, + llm_name: str, + description: str = "", + system_prompt: str = ".", + outputs: Optional[List[Property]] = None, +) -> Agent: return Agent( name=name, description=description, system_prompt=system_prompt, llm_config=OpenAiCompatibleConfig(name=llm_name, model_id="fake", url="null"), + outputs=outputs, ) @@ -102,11 +106,12 @@ def test_route_manager_to_worker_or_end_returns_end_when_no_delegation() -> None from langchain_core.messages import AIMessage from langgraph.graph import END - from pyagentspec.adapters.langgraph._managerworkers import _route_manager_to_worker_or_end + from pyagentspec.adapters.langgraph._managerworkers import _make_manager_router + route = _make_manager_router(["drafter"]) not_delegating = AIMessage(content="Done.", tool_calls=[]) - assert _route_manager_to_worker_or_end({"messages": [not_delegating]}) == END - assert _route_manager_to_worker_or_end({"messages": []}) == END + assert route({"messages": [not_delegating]}) == END + assert route({"messages": []}) == END def test_route_manager_to_worker_or_end_fans_out_one_send_per_delegation() -> None: @@ -116,18 +121,19 @@ def test_route_manager_to_worker_or_end_fans_out_one_send_per_delegation() -> No from pyagentspec.adapters.langgraph._managerworkers import ( _DELEGATE_CALL_ID_KEY, _DELEGATE_TASK_KEY, - _route_manager_to_worker_or_end, + _make_manager_router, ) + route = _make_manager_router(["drafter", "research_helper"]) msg = AIMessage( content="", tool_calls=[ {"name": "some_other_tool", "args": {}, "id": "c0"}, - {"name": "delegate_to_drafter", "args": {"task": "x"}, "id": "c1"}, - {"name": "delegate_to_research_helper", "args": {"task": "y"}, "id": "c2"}, + {"name": "__delegate_to__drafter", "args": {"task": "x"}, "id": "c1"}, + {"name": "__delegate_to__research_helper", "args": {"task": "y"}, "id": "c2"}, ], ) - sends = _route_manager_to_worker_or_end({"messages": [msg]}) + sends = route({"messages": [msg]}) # Every delegation gets its own Send carrying the task and the tool_call_id its # reply must answer. The non-delegation tool call already ran inside the manager's # react loop and is ignored by routing. @@ -137,6 +143,23 @@ def test_route_manager_to_worker_or_end_fans_out_one_send_per_delegation() -> No assert [s.arg[_DELEGATE_CALL_ID_KEY] for s in sends] == ["c1", "c2"] +def test_route_manager_ignores_prefixed_tool_whose_suffix_is_not_a_worker() -> None: + """A tool call that merely looks like a delegation must not be routed: its suffix + is not a worker node, so a Send would target a non-existing node. It already ran + as a plain tool inside the react loop.""" + from langchain_core.messages import AIMessage + from langgraph.graph import END + + from pyagentspec.adapters.langgraph._managerworkers import _make_manager_router + + route = _make_manager_router(["drafter"]) + msg = AIMessage( + content="", + tool_calls=[{"name": "__delegate_to__nobody", "args": {"task": "x"}, "id": "c1"}], + ) + assert route({"messages": [msg]}) == END + + def test_manager_workers_compiles_to_hierarchical_graph_topology() -> None: from langchain_core.messages import AIMessage from langgraph.graph import START @@ -186,7 +209,7 @@ def test_manager_workers_registers_a_delegation_tool_per_worker() -> None: # react-agent's tools node, so the LLM has the matching contract. manager_subgraph = compiled.builder.nodes[_MANAGER_NODE_KEY].runnable tools_node = manager_subgraph.builder.nodes["tools"].runnable - assert "delegate_to_research_helper" in tools_node.tools_by_name + assert "__delegate_to__research_helper" in tools_node.tools_by_name def test_manager_workers_delegates_and_routes_back_with_tool_message() -> None: @@ -207,7 +230,7 @@ def test_manager_workers_delegates_and_routes_back_with_tool_message() -> None: content="", tool_calls=[ { - "name": "delegate_to_research_helper", + "name": "__delegate_to__research_helper", "args": {"task": "Look up Saturn"}, "id": "call_1", } @@ -256,9 +279,21 @@ def test_manager_workers_answers_every_delegation_in_a_single_turn() -> None: AIMessage( content="", tool_calls=[ - {"name": "delegate_to_sub_agent", "args": {"task": "Spanish poem"}, "id": "call_1"}, - {"name": "delegate_to_sub_agent", "args": {"task": "French poem"}, "id": "call_2"}, - {"name": "delegate_to_sub_agent", "args": {"task": "German poem"}, "id": "call_3"}, + { + "name": "__delegate_to__sub_agent", + "args": {"task": "Spanish poem"}, + "id": "call_1", + }, + { + "name": "__delegate_to__sub_agent", + "args": {"task": "French poem"}, + "id": "call_2", + }, + { + "name": "__delegate_to__sub_agent", + "args": {"task": "German poem"}, + "id": "call_3", + }, ], ), AIMessage(content="Here are your three poems."), @@ -382,7 +417,7 @@ def _manager(state: Any) -> Any: content="", tool_calls=[ { - "name": "delegate_to_research_helper", + "name": "__delegate_to__research_helper", "args": {"task": "Saturn"}, "id": "c1", } @@ -421,11 +456,13 @@ def test_is_delegation_tool_name_matches_only_the_synthetic_prefix() -> None: is_delegation_tool_name, ) - assert DELEGATE_TOOL_PREFIX == "delegate_to_" - assert is_delegation_tool_name("delegate_to_research_helper") + assert DELEGATE_TOOL_PREFIX == "__delegate_to__" + assert is_delegation_tool_name("__delegate_to__research_helper") assert not is_delegation_tool_name("get_weather") - # A real tool merely *containing* the prefix mid-name is not a delegation. - assert not is_delegation_tool_name("please_delegate_to_someone") + # A real tool plausibly named delegate_to_ is not a delegation. + assert not is_delegation_tool_name("delegate_to_someone") + # Nor is one merely *containing* the prefix mid-name. + assert not is_delegation_tool_name("please__delegate_to__someone") assert not is_delegation_tool_name(None) assert not is_delegation_tool_name(123) @@ -444,3 +481,143 @@ def test_manager_workers_leaves_astream_events_unwrapped() -> None: assert "stream" in compiled.__dict__ and "astream" in compiled.__dict__ assert "astream_events" not in compiled.__dict__ + + +def test_managerworkers_infers_inputs_from_group_manager_prompt() -> None: + """A ManagerWorkers exposes the group manager's prompt placeholders as inputs, so + a flow AgentNode wrapping it declares input ports a DataFlowEdge can resolve.""" + manager = _agent( + "manager", + "manager_llm", + system_prompt="Translate the following to Arabic:\n\n{{joke}}\n\nMake {{count}} variants.", + ) + worker = _agent("worker", "worker_llm", system_prompt="You translate.") + mw = ManagerWorkers(name="mw", group_manager=manager, workers=[worker]) + + assert sorted(p.title for p in (mw.inputs or [])) == ["count", "joke"] + + +def test_managerworkers_infers_outputs_from_group_manager() -> None: + """Symmetric with inputs: a ManagerWorkers exposes the group manager's outputs.""" + from pyagentspec.property import StringProperty + + manager = _agent( + "manager", + "manager_llm", + system_prompt="Answer the question.", + outputs=[StringProperty(title="answer")], + ) + worker = _agent("worker", "worker_llm", system_prompt="You help.") + mw = ManagerWorkers(name="mw", group_manager=manager, workers=[worker]) + + assert [p.title for p in (mw.outputs or [])] == ["answer"] + + +def _flow_with_manager_workers_step(outputs: List[Property]) -> Any: + """A start → AgentNode(ManagerWorkers) → end flow whose end node exposes + ``outputs``, with the data edges resolving the manager's ``joke`` input and + every output.""" + from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge + from pyagentspec.flows.flow import Flow + from pyagentspec.flows.nodes import AgentNode, EndNode, StartNode + from pyagentspec.property import StringProperty + + joke = StringProperty(title="joke") + manager = _agent( + "manager", + "manager_llm", + system_prompt="Translate the following to Arabic:\n\n{{joke}}", + outputs=outputs, + ) + worker = _agent("worker", "worker_llm", system_prompt="You translate.") + mw = ManagerWorkers(name="translator", group_manager=manager, workers=[worker]) + assert [p.title for p in (mw.inputs or [])] == ["joke"] + + manager_node = AgentNode(name="manager_node", agent=mw) + start_node = StartNode(name="start", inputs=[joke]) + end_node = EndNode(name="end", outputs=outputs) + return Flow( + name="flow", + start_node=start_node, + nodes=[start_node, manager_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_node", from_node=start_node, to_node=manager_node), + ControlFlowEdge(name="node_to_end", from_node=manager_node, to_node=end_node), + ], + data_flow_connections=[ + DataFlowEdge( + name="joke_edge", + source_node=start_node, + source_output=joke.title, + destination_node=manager_node, + destination_input=joke.title, + ), + ] + + [ + DataFlowEdge( + name=f"{output.title}_edge", + source_node=manager_node, + source_output=output.title, + destination_node=end_node, + destination_input=output.title, + ) + for output in outputs + ], + outputs=outputs, + ) + + +def test_managerworkers_runs_as_a_flow_step_with_data_edge_inputs() -> None: + """A ManagerWorkers flow step loads with its data edge resolved and executes: + loading proves the node exposes the ``joke`` input the edge targets, running + proves the manager's answer comes back as the node's single string output.""" + from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel + from langchain_core.messages import AIMessage + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter + from pyagentspec.property import StringProperty + + # The final message has no tool_calls → the manager routes to END without delegating. + fake_llm = _fake_llm(AIMessage(content="لماذا...")) + flow = _flow_with_manager_workers_step(outputs=[StringProperty(title="translated")]) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with patch.object( + AgentSpecToLangGraphConverter, + "_llm_convert_to_langgraph", + autospec=True, + side_effect=lambda self_obj, llm_config, *a, **k: fake_llm, + ), patch.object( + FakeMessagesListChatModel, + "bind_tools", + new=lambda self_obj, *a, **k: self_obj, + ): + compiled = loader.load_component(flow) + result = compiled.invoke( + { + "inputs": {"joke": "Why did the car..."}, + "messages": [{"role": "user", "content": ""}], + }, + {"configurable": {"thread_id": "managerworkers-node"}}, + ) + + assert result["outputs"]["translated"] == "لماذا..." + + +def test_managerworkers_flow_step_with_unsupported_outputs_fails_at_conversion() -> None: + """A ManagerWorkers flow step supports a single string output only; any other + shape must be rejected when the flow is converted, not once the step runs.""" + from langgraph.checkpoint.memory import MemorySaver + + from pyagentspec.adapters.langgraph import AgentSpecLoader + from pyagentspec.property import StringProperty + + flow = _flow_with_manager_workers_step( + outputs=[StringProperty(title="translated"), StringProperty(title="notes")] + ) + + loader = AgentSpecLoader(tool_registry={}, checkpointer=MemorySaver()) + with pytest.raises(NotImplementedError, match="single string output"): + loader.load_component(flow) diff --git a/pyagentspec/tests/validation/test_agentic_patterns_validation.py b/pyagentspec/tests/validation/test_agentic_patterns_validation.py index 81a13af8..f951ce3a 100644 --- a/pyagentspec/tests/validation/test_agentic_patterns_validation.py +++ b/pyagentspec/tests/validation/test_agentic_patterns_validation.py @@ -14,6 +14,7 @@ from pyagentspec.flows.nodes.startnode import StartNode from pyagentspec.llms import OpenAiConfig from pyagentspec.managerworkers import ManagerWorkers +from pyagentspec.property import FloatProperty, StringProperty from pyagentspec.swarm import Swarm @@ -79,6 +80,72 @@ def test_managerworkers_with_different_agentic_components_can_be_validated() -> ) +def test_managerworkers_with_ios_matching_the_group_manager_can_be_validated() -> None: + manager_agent = Agent( + name="manager_agent", + system_prompt="Answer about {{topic}}.", + llm_config=OpenAiConfig(name="default", model_id="test_model"), + outputs=[StringProperty(title="answer")], + ) + worker_agent = Agent( + name="worker_agent", + system_prompt="You help.", + llm_config=OpenAiConfig(name="default", model_id="test_model"), + ) + + # The I/Os of a ManagerWorkers must be the I/Os of its group manager (same name + # and type); redeclaring them explicitly is valid. + _ = ManagerWorkers( + name="managerworkers", + group_manager=manager_agent, + workers=[worker_agent], + inputs=[StringProperty(title="topic")], + outputs=[StringProperty(title="answer")], + ) + + +def test_managerworkers_with_ios_not_matching_the_group_manager_raises_errors() -> None: + manager_agent = Agent( + name="manager_agent", + system_prompt="Answer about {{topic}}.", + llm_config=OpenAiConfig(name="default", model_id="test_model"), + outputs=[StringProperty(title="answer")], + ) + worker_agent = Agent( + name="worker_agent", + system_prompt="You help.", + llm_config=OpenAiConfig(name="default", model_id="test_model"), + ) + + # Same title as a group manager input, but a different type. + with pytest.raises(ValueError, match="must match the inputs of its group manager"): + ManagerWorkers( + name="managerworkers", + group_manager=manager_agent, + workers=[worker_agent], + inputs=[FloatProperty(title="topic")], + ) + + # Same title as a group manager output, but a different type. + with pytest.raises(ValueError, match="must match the outputs of its group manager"): + ManagerWorkers( + name="managerworkers", + group_manager=manager_agent, + workers=[worker_agent], + outputs=[FloatProperty(title="answer")], + ) + + # A title the group manager does not declare is rejected by the base + # ComponentWithIO validation. + with pytest.raises(ValueError, match="expected only properties with the titles"): + ManagerWorkers( + name="managerworkers", + group_manager=manager_agent, + workers=[worker_agent], + outputs=[StringProperty(title="answer"), StringProperty(title="extra")], + ) + + def test_swarm_with_empty_relationships_raises_errors() -> None: first_agent = Agent( name="first_agent", From e05d4a7c7e3ff459710bfc5e4af2917288a20b8a Mon Sep 17 00:00:00 2001 From: Salah Date: Tue, 18 Aug 2026 23:01:43 +0400 Subject: [PATCH 08/17] refactor(adapters/langgraph): extract ManagerWorkersNodeExecutor - Move the ManagerWorkers flow-step behavior out of AgentNodeExecutor into a dedicated ManagerWorkersNodeExecutor, selected once at conversion time in _agent_node_convert_to_langgraph. This removes the scattered isinstance branches and brings _node_execution.py back under 1000 lines. - Document why the executor calls the private converter entry point: the public convert() caches by component id, which would collapse differently-rendered prompt copies into one graph. - Re-export DELEGATE_TOOL_PREFIX and is_delegation_tool_name from pyagentspec.adapters.langgraph so the delegation protocol is actually public API. - Tidy: hoist the stdlib Annotated import, annotate _async_or_sync -> None, correct the _MANAGER_NODE_KEY collision-safety comment. --- .../adapters/langgraph/__init__.py | 3 + .../adapters/langgraph/_execution_span.py | 4 +- .../adapters/langgraph/_langgraphconverter.py | 8 +- .../adapters/langgraph/_managerworkers.py | 11 +- .../langgraph/_managerworkers_node.py | 119 ++++++++++++++++++ .../adapters/langgraph/_node_execution.py | 76 ++--------- 6 files changed, 145 insertions(+), 76 deletions(-) create mode 100644 pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/__init__.py b/pyagentspec/src/pyagentspec/adapters/langgraph/__init__.py index baeb855b..fb75ddeb 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/__init__.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/__init__.py @@ -6,10 +6,13 @@ """Agent Spec adapter for the LangGraph agentic framework.""" +from ._managerworkers import DELEGATE_TOOL_PREFIX, is_delegation_tool_name from .agentspecexporter import AgentSpecExporter from .agentspecloader import AgentSpecLoader __all__ = [ "AgentSpecLoader", "AgentSpecExporter", + "DELEGATE_TOOL_PREFIX", + "is_delegation_tool_name", ] diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py index cb22b199..51e39e7f 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py @@ -34,7 +34,9 @@ def _final_state(chunk: Any, so_far: Any) -> Any: return chunk[1] if isinstance(chunk, tuple) else so_far -async def _async_or_sync(async_call: Callable[..., Any], sync_call: Callable[..., Any], *args: Any): +async def _async_or_sync( + async_call: Callable[..., Any], sync_call: Callable[..., Any], *args: Any +) -> None: """Await ``async_call``, falling back to ``sync_call`` for spans that don't implement the async half of the tracing protocol.""" try: diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 17536fdb..8929b1be 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -712,9 +712,15 @@ def _agent_node_convert_to_langgraph( config: RunnableConfig, middleware: List[Any], ) -> "NodeExecutor": + from pyagentspec.adapters.langgraph._managerworkers_node import ManagerWorkersNodeExecutor from pyagentspec.adapters.langgraph._node_execution import AgentNodeExecutor - return AgentNodeExecutor( + executor_class = ( + ManagerWorkersNodeExecutor + if isinstance(agent_node.agent, AgentSpecManagerWorkers) + else AgentNodeExecutor + ) + return executor_class( agent_node, tool_registry=tool_registry, converted_components=converted_components, diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py index 05077eec..ea931b2a 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py @@ -13,7 +13,7 @@ """ import re -from typing import Any, Dict, Iterable, List, Tuple +from typing import Annotated, Any, Dict, Iterable, List, Tuple from pyagentspec.adapters.langgraph._execution_span import patch_with_execution_span from pyagentspec.adapters.langgraph._types import CompiledStateGraph, langgraph_graph @@ -28,13 +28,14 @@ ManagerWorkersExecutionSpan as AgentSpecManagerWorkersExecutionSpan, ) -# Cannot collide with a normalized worker node name, which is always [a-z0-9_]. +# Cannot collide with a worker node name: _normalize_identifier strips leading and +# trailing underscores, so no normalized name ever starts with one. _MANAGER_NODE_KEY = "__manager__" #: Prefix of the synthetic ``__delegate_to__`` tool names the manager's LLM #: uses to address a worker. The dunder prefix, like the delegation keys below, keeps -#: it from colliding with a real tool named ``delegate_to_``. Public so -#: consumers can recognize the protocol. +#: it from colliding with a real tool named ``delegate_to_``. Re-exported +#: from ``pyagentspec.adapters.langgraph`` so consumers can recognize the protocol. DELEGATE_TOOL_PREFIX = "__delegate_to__" # Keys of the per-delegation ``Send`` payload: the task to run, and the tool_call_id @@ -98,8 +99,6 @@ def _make_worker_delegation_tool(worker_node_name: str) -> Any: would collapse several same-turn delegations into one parent Command and leave the other ``tool_call_id``s unanswered. """ - from typing import Annotated - from langchain_core.tools import InjectedToolCallId, tool from langgraph.prebuilt import InjectedState from langgraph.types import Command diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py new file mode 100644 index 00000000..f04c7815 --- /dev/null +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py @@ -0,0 +1,119 @@ +# Copyright © 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (UPL) 1.0 (LICENSE-UPL or https://oss.oracle.com/licenses/upl), at your option. + +"""Runs a ``ManagerWorkers`` as a flow step. + +``AgentSpecToLangGraphConverter._agent_node_convert_to_langgraph`` selects +:class:`ManagerWorkersNodeExecutor` when the node's agent is a ``ManagerWorkers``, +so :class:`~pyagentspec.adapters.langgraph._node_execution.AgentNodeExecutor` keeps +the plain-Agent behavior only. +""" + +from typing import Any, Dict, List, Optional, Tuple + +from pyagentspec.adapters._utils import render_template +from pyagentspec.adapters.langgraph._node_execution import AgentNodeExecutor +from pyagentspec.adapters.langgraph._types import ( + Checkpointer, + CompiledStateGraph, + ExecuteOutput, + LangGraphTool, + Messages, + NodeExecutionDetails, + RunnableConfig, +) +from pyagentspec.agent import Agent as AgentSpecAgent +from pyagentspec.flows.nodes import AgentNode as AgentSpecAgentNode +from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers + + +class ManagerWorkersNodeExecutor(AgentNodeExecutor): + """Executes an ``AgentNode`` whose agent is a ``ManagerWorkers``. + + The hierarchical graph runs over ``MessagesState``, which can carry neither + structured inputs inward nor a ``structured_response`` outward. Inputs are + therefore rendered into the group-manager's system prompt before compiling, and + the manager's final message is the node's single string output. + """ + + def __init__( + self, + node: AgentSpecAgentNode, + tool_registry: Dict[str, "LangGraphTool"], + converted_components: Dict[str, Any], + checkpointer: Optional[Checkpointer], + config: RunnableConfig, + middleware: Optional[List[Any]] = None, + ) -> None: + super().__init__( + node, tool_registry, converted_components, checkpointer, config, middleware + ) + if not isinstance(node.agent, AgentSpecManagerWorkers): + raise TypeError( + "ManagerWorkersNodeExecutor requires an AgentNode holding a ManagerWorkers" + ) + self._manager_workers: AgentSpecManagerWorkers = node.agent + # Anything but a single string output cannot be honored (see class docstring); + # raising here fails at conversion time rather than mid-run. + outputs = node.outputs or [] + if outputs and (len(outputs) != 1 or outputs[0].type != "string"): + raise NotImplementedError( + "A ManagerWorkers flow step supports a single string output; " + f"node `{node.name}` declares {[o.title for o in outputs]}." + ) + + def _create_manager_workers_with_given_input_values( + self, inputs: Dict[str, Any] + ) -> CompiledStateGraph[Any, Any]: + """Compile the ``ManagerWorkers`` with the node inputs rendered into the + group-manager's ``system_prompt`` and the satisfied ports dropped. + + Cached by rendered prompt, the same key + :meth:`AgentNodeExecutor._create_react_agent_with_given_input_values` uses. + Calling the private converter entry point is deliberate, mirroring the react + path: the public ``convert`` caches by component id, which would collapse the + differently-rendered copies (all sharing the original's id) into one graph. + """ + from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter + + converter = AgentSpecToLangGraphConverter() + component = self._manager_workers + entry_agent = component.group_manager + if not isinstance(entry_agent, AgentSpecAgent): + # Nothing to render or cache; the converter owns the error for this case. + return converter._manager_workers_convert_to_langgraph( + component, **self._conversion_kwargs() + ) + + system_prompt = render_template(entry_agent.system_prompt, inputs) + if system_prompt not in self._agents_cache: + rendered = component.model_copy( + update={ + "group_manager": entry_agent.model_copy( + update={"system_prompt": system_prompt, "inputs": []} + ), + "inputs": [], + } + ) + self._agents_cache[system_prompt] = converter._manager_workers_convert_to_langgraph( + rendered, **self._conversion_kwargs() + ) + return self._agents_cache[system_prompt] + + def _prepare_agent_and_inputs( + self, inputs: Dict[str, Any], messages: Messages + ) -> Tuple[CompiledStateGraph[Any, Any], Dict[str, Any]]: + # Inputs were baked into the group-manager's prompt, so this graph runs on + # messages alone rather than the react-agent's remaining_steps state. + graph = self._create_manager_workers_with_given_input_values(inputs) + return graph, {"messages": self._with_driving_message(messages)} + + def _format_agent_result(self, result: Dict[str, Any]) -> ExecuteOutput: + node_outputs = self.node.outputs + if not node_outputs: + return super()._format_agent_result(result) + # __init__ already rejected any shape but a single string output. + return {node_outputs[0].title: result["messages"][-1].content}, NodeExecutionDetails() diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py index 915abc1c..a1398df9 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_node_execution.py @@ -49,7 +49,6 @@ from pyagentspec.flows.nodes import OutputMessageNode as AgentSpecOutputMessageNode from pyagentspec.flows.nodes import StartNode as AgentSpecStartNode from pyagentspec.flows.nodes import ToolNode as AgentSpecToolNode -from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers from pyagentspec.property import Property as AgentSpecProperty from pyagentspec.property import _empty_default as pyagentspec_empty_default from pyagentspec.tracing.events import NodeExecutionEnd as AgentSpecNodeExecutionEnd @@ -493,17 +492,6 @@ def __init__( super().__init__(node) if not isinstance(self.node, AgentSpecAgentNode): raise TypeError("AgentNodeExecutor can only be initialized with AgentNode") - if isinstance(self.node.agent, AgentSpecManagerWorkers): - # The hierarchical graph runs over MessagesState, which cannot carry a - # structured_response outward: the manager's final message is the only - # result, so anything but a single string output cannot be honored. - # Raising here fails at conversion time rather than mid-run. - outputs = self.node.outputs or [] - if outputs and (len(outputs) != 1 or outputs[0].type != "string"): - raise NotImplementedError( - "A ManagerWorkers flow step supports a single string output; " - f"node `{self.node.name}` declares {[o.title for o in outputs]}." - ) self.tool_registry = tool_registry self.checkpointer = checkpointer self.converted_components = converted_components @@ -547,62 +535,21 @@ def _create_react_agent_with_given_input_values( ) return self._agents_cache[system_prompt] - def _create_manager_workers_with_given_input_values( - self, component: AgentSpecManagerWorkers, inputs: Dict[str, Any] - ) -> CompiledStateGraph[Any, Any]: - """Compile a ``ManagerWorkers`` that this node runs as a flow step. - - The graph runs over ``MessagesState``, which can't carry structured inputs - inward to the group manager, so the node inputs are rendered into its - ``system_prompt`` and the satisfied ports dropped. Cached by rendered prompt, - the same key :meth:`_create_react_agent_with_given_input_values` uses. - """ - from pyagentspec.adapters.langgraph._langgraphconverter import AgentSpecToLangGraphConverter - - converter = AgentSpecToLangGraphConverter() - entry_agent = component.group_manager - if not isinstance(entry_agent, AgentSpecAgent): - # Nothing to render or cache; the converter owns the error for this case. - return converter._manager_workers_convert_to_langgraph( - component, **self._conversion_kwargs() - ) - - system_prompt = render_template(entry_agent.system_prompt, inputs) - if system_prompt not in self._agents_cache: - rendered = component.model_copy( - update={ - "group_manager": entry_agent.model_copy( - update={"system_prompt": system_prompt, "inputs": []} - ), - "inputs": [], - } - ) - self._agents_cache[system_prompt] = converter._manager_workers_convert_to_langgraph( - rendered, **self._conversion_kwargs() - ) - return self._agents_cache[system_prompt] + @staticmethod + def _with_driving_message(messages: Messages) -> Messages: + # LangGraph's agent expects at least one user message to drive execution. + # When an AgentNode is used with a templated system prompt and no messages are + # provided by the flow, the agent can crash. To avoid this, we artificially + # insert an empty user message when the message list is empty. + return messages if messages else cast(Messages, [{"role": "user", "content": ""}]) def _prepare_agent_and_inputs( self, inputs: Dict[str, Any], messages: Messages ) -> Tuple[CompiledStateGraph[Any, Any], Dict[str, Any]]: - # LangGraph's agent expects at least one user message to drive execution. - # When an AgentNode is used with a templated system prompt and no messages are provided - # by the flow, the agent can crash. To avoid this, we artificially insert an empty - # user message when the message list is empty. - if not messages: - messages = cast(Messages, [{"role": "user", "content": ""}]) - agentspec_component = self.node.agent - if isinstance(agentspec_component, AgentSpecManagerWorkers): - # Inputs were baked into the group-manager's prompt, so this graph runs on - # messages alone rather than the agent's remaining_steps state. - graph = self._create_manager_workers_with_given_input_values( - agentspec_component, inputs - ) - return graph, {"messages": messages} agent = self._create_react_agent_with_given_input_values(inputs) inputs |= { "remaining_steps": 20, # Get the right number of steps left - "messages": messages, + "messages": self._with_driving_message(messages), "structured_response": {}, } return agent, inputs @@ -615,13 +562,6 @@ def _format_agent_result(self, result: Dict[str, Any]) -> ExecuteOutput: ] return {}, NodeExecutionDetails(generated_messages=generated_messages) - if isinstance(self.node.agent, AgentSpecManagerWorkers): - # The hierarchical graph runs over MessagesState, which cannot carry a - # structured_response outward: the manager's final message is the result. - # __init__ already rejected any shape but a single string output. - node_outputs = self.node.outputs or [] - return {node_outputs[0].title: result["messages"][-1].content}, NodeExecutionDetails() - outputs = extract_outputs_from_invoke_result(result, self.node.outputs or []) return outputs, NodeExecutionDetails() From 700081af5c86cb30090f712a10742e922ec93d2a Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Mon, 24 Aug 2026 12:04:51 +0200 Subject: [PATCH 09/17] Validation updates. Language updates. TS adapter support. --- .../agentspec/language_spec_nightly.rst | 3 ++ docs/pyagentspec/source/changelog.rst | 19 +++++++ .../adapters/langgraph/_execution_span.py | 1 - pyagentspec/src/pyagentspec/managerworkers.py | 42 +++++++++------- .../serialization/test_managerworkers.py | 24 +++++++++ .../test_agentic_patterns_validation.py | 15 +++++- tsagentspec/src/agents/manager-workers.ts | 49 ++++++++++++++++++- .../tests/agents/manager-workers.test.ts | 40 +++++++++++++++ 8 files changed, 172 insertions(+), 21 deletions(-) diff --git a/docs/pyagentspec/source/agentspec/language_spec_nightly.rst b/docs/pyagentspec/source/agentspec/language_spec_nightly.rst index 99de2c3a..fc3d9dad 100644 --- a/docs/pyagentspec/source/agentspec/language_spec_nightly.rst +++ b/docs/pyagentspec/source/agentspec/language_spec_nightly.rst @@ -2270,6 +2270,9 @@ The ManagerWorkers has two main parameters: - Workers cannot interact with the end user directly. - When invoked, each worker can leverage its equipped tools to complete the assigned task and report the result back to the group manager. +The ``ManagerWorkers`` input and output schemas must match those of its ``group_manager``. +In particular, the two components must declare the same input property names and the same output property names, +and each corresponding property must have the same type. Datastores ~~~~~~~~~~ diff --git a/docs/pyagentspec/source/changelog.rst b/docs/pyagentspec/source/changelog.rst index 60795a13..c9ceb2b5 100644 --- a/docs/pyagentspec/source/changelog.rst +++ b/docs/pyagentspec/source/changelog.rst @@ -71,6 +71,25 @@ New features Added ``DbmsVectorChainLlmConfig`` for configuring LLM requests executed through Oracle Database ``DBMS_VECTOR_CHAIN``. +* **ManagerWorkers I/O update** + + The ManagerWorkers language specification now requires its input and output + schemas to use the same property names and types as its group manager. + This allows exposing inputs required by the manager agent (e.g., the placeholders + in its system prompt). + + We thank @spichen for the contribution! + +* **ManagerWorkers support in the LangGraph adapter** + + The LangGraph adapter now converts ``ManagerWorkers`` into hierarchical graphs: + the group manager delegates tasks to workers and receives their results before + producing a final response. Nested ``ManagerWorkers`` can be used as workers. + The adapter also supports ``ManagerWorkers`` in Flow ``AgentNode`` steps with + one string output only. + + We thank @spichen for the contribution! + * **MCP tool retry policies** Added ``retry_policy`` support to ``MCPTool`` and ``MCPToolBox`` so runtimes can diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py index 51e39e7f..033484c8 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py @@ -47,7 +47,6 @@ async def _async_or_sync( def patch_with_execution_span( compiled_graph: CompiledStateGraph[Any, Any, Any], - *, make_span: Callable[[], Any], make_start_event: Callable[[Dict[str, Any]], Any], make_end_event: Callable[[Dict[str, Any]], Any], diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index f38526a7..497f8b5c 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -13,7 +13,7 @@ from typing_extensions import Self from pyagentspec.agenticcomponent import AgenticComponent -from pyagentspec.property import Property +from pyagentspec.property import Property, properties_have_same_type from pyagentspec.validation_helpers import model_validator_with_error_accumulation from pyagentspec.versioning import AgentSpecVersionEnum @@ -68,15 +68,19 @@ class ManagerWorkers(AgenticComponent): def _get_inferred_inputs(self) -> List[Property]: # Per the language spec, the inputs of a ManagerWorkers are the inputs of its - # group manager (same name and type): the manager drives the conversation and - # is the component whose prompt the runtime renders. The hasattr guard matches - # Flow._get_inferred_inputs: validators can run this against a - # partially-constructed model with no group_manager yet. - return (self.group_manager.inputs or []) if hasattr(self, "group_manager") else [] + # group manager (same name and type): the manager drives the conversation. + return self.group_manager.inputs or [] def _get_inferred_outputs(self) -> List[Property]: # Symmetric with the inferred inputs: the group manager's outputs. - return (self.group_manager.outputs or []) if hasattr(self, "group_manager") else [] + return self.group_manager.outputs or [] + + def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: + min_version = super()._infer_min_agentspec_version_from_configuration() + # Inheritance of manager's inputs and outputs was introduced in 26.2.0 + if self.group_manager.inputs or self.group_manager.outputs: + min_version = max(min_version, AgentSpecVersionEnum.v26_2_0) + return min_version @model_validator_with_error_accumulation def _validate_one_or_more_workers(self) -> Self: @@ -97,21 +101,23 @@ def _validate_group_manager_is_not_included_as_a_worker(self) -> Self: def _validate_ios_match_group_manager_ios(self) -> Self: # Per the language spec, the I/Os of a ManagerWorkers must be the I/Os of its # group manager, same name and type. The base ComponentWithIO validators - # already enforce matching titles; enforce matching types here. - if not hasattr(self, "group_manager"): - return self - for kind, own_properties, manager_properties in ( - ("input", self.inputs or [], self.group_manager.inputs or []), - ("output", self.outputs or [], self.group_manager.outputs or []), + # already enforce matching titles; use the shared property helper here so + # nested JSON Schema types are compared correctly as well. + for kind, own_properties, manager_properties, explicitly_provided in ( + ("input", self.inputs or [], self.group_manager.inputs or [], "inputs"), + ("output", self.outputs or [], self.group_manager.outputs or [], "outputs"), ): - manager_type_by_title = {p.title: p.type for p in manager_properties} + if explicitly_provided not in self.model_fields_set: + continue + manager_property_by_title = {p.title: p for p in manager_properties} for own_property in own_properties: - manager_type = manager_type_by_title.get(own_property.title) - if manager_type is not None and own_property.type != manager_type: + manager_property = manager_property_by_title.get(own_property.title) + if manager_property is not None and not properties_have_same_type( + own_property, manager_property + ): raise ValueError( f"The {kind}s of a `ManagerWorkers` must match the {kind}s of its " f"group manager (same name and type), but {kind} " - f"`{own_property.title}` has type `{own_property.type}` while the " - f"group manager declares `{manager_type}`." + f"`{own_property.title}` has a different type from the group manager." ) return self diff --git a/pyagentspec/tests/serialization/test_managerworkers.py b/pyagentspec/tests/serialization/test_managerworkers.py index fefb4271..693b3915 100644 --- a/pyagentspec/tests/serialization/test_managerworkers.py +++ b/pyagentspec/tests/serialization/test_managerworkers.py @@ -9,6 +9,7 @@ from pyagentspec.agent import Agent from pyagentspec.llms import VllmConfig from pyagentspec.managerworkers import ManagerWorkers +from pyagentspec.property import StringProperty from pyagentspec.serialization import AgentSpecDeserializer, AgentSpecSerializer from pyagentspec.versioning import AgentSpecVersionEnum @@ -109,3 +110,26 @@ def test_deserializing_managerworkers_with_unsupported_version_raises_error( with pytest.raises(ValueError, match="Invalid agentspec_version"): AgentSpecDeserializer().from_yaml(serialized_managerworkers) + + +def test_managerworkers_infers_manager_ios_in_current_version() -> None: + llm_config = VllmConfig(name="model", model_id="model_id", url="https://example.com") + manager = Agent( + name="manager", + llm_config=llm_config, + system_prompt="Manage the team.", + outputs=[StringProperty(title="answer")], + ) + worker = Agent(name="worker", llm_config=llm_config, system_prompt="Help the manager.") + manager_workers = ManagerWorkers( + name="team", + group_manager=manager, + workers=[worker], + ) + + assert manager_workers.outputs == manager.outputs + assert manager_workers.min_agentspec_version == AgentSpecVersionEnum.current_version + with pytest.raises(ValueError, match="Invalid agentspec_version"): + AgentSpecSerializer().to_dict( + manager_workers, agentspec_version=AgentSpecVersionEnum.v25_4_2 + ) diff --git a/pyagentspec/tests/validation/test_agentic_patterns_validation.py b/pyagentspec/tests/validation/test_agentic_patterns_validation.py index f951ce3a..2eeab694 100644 --- a/pyagentspec/tests/validation/test_agentic_patterns_validation.py +++ b/pyagentspec/tests/validation/test_agentic_patterns_validation.py @@ -14,7 +14,7 @@ from pyagentspec.flows.nodes.startnode import StartNode from pyagentspec.llms import OpenAiConfig from pyagentspec.managerworkers import ManagerWorkers -from pyagentspec.property import FloatProperty, StringProperty +from pyagentspec.property import FloatProperty, ListProperty, StringProperty from pyagentspec.swarm import Swarm @@ -135,6 +135,19 @@ def test_managerworkers_with_ios_not_matching_the_group_manager_raises_errors() outputs=[FloatProperty(title="answer")], ) + # The comparison must also inspect nested schema types, rather than only the + # top-level ``array`` type. + list_output_manager = manager_agent.model_copy( + update={"outputs": [ListProperty(title="answer", item_type=StringProperty())]} + ) + with pytest.raises(ValueError, match="must match the outputs of its group manager"): + ManagerWorkers( + name="managerworkers", + group_manager=list_output_manager, + workers=[worker_agent], + outputs=[ListProperty(title="answer", item_type=FloatProperty())], + ) + # A title the group manager does not declare is rejected by the base # ComponentWithIO validation. with pytest.raises(ValueError, match="expected only properties with the titles"): diff --git a/tsagentspec/src/agents/manager-workers.ts b/tsagentspec/src/agents/manager-workers.ts index 5e32e4e1..03aaea42 100644 --- a/tsagentspec/src/agents/manager-workers.ts +++ b/tsagentspec/src/agents/manager-workers.ts @@ -3,7 +3,7 @@ */ import { z } from "zod"; import { ComponentWithIOSchema } from "../component.js"; -import type { Property } from "../property.js"; +import { propertiesHaveSameType, type Property } from "../property.js"; // z.record(z.unknown()) is used instead of AgenticComponentUnion to break a circular // dependency (ManagerWorkers -> AgenticComponentUnion -> ManagerWorkers). Validation of @@ -18,6 +18,43 @@ export const ManagerWorkersSchema = ComponentWithIOSchema.extend({ export type ManagerWorkers = z.infer; +function getComponentProperties( + component: Record, + field: "inputs" | "outputs", +): Property[] { + const properties = component[field]; + return Array.isArray(properties) ? (properties as Property[]) : []; +} + +function validatePropertiesMatchManager( + properties: Property[], + managerProperties: Property[], + kind: "inputs" | "outputs", +): void { + const managerPropertiesByTitle = new Map( + managerProperties.map((property) => [property.title, property]), + ); + const propertiesByTitle = new Map( + properties.map((property) => [property.title, property]), + ); + if ( + propertiesByTitle.size !== properties.length || + propertiesByTitle.size !== managerPropertiesByTitle.size + ) { + throw new Error( + `The ${kind} of a ManagerWorkers must match the ${kind} of its group manager.`, + ); + } + for (const property of properties) { + const managerProperty = managerPropertiesByTitle.get(property.title); + if (!managerProperty || !propertiesHaveSameType(property, managerProperty)) { + throw new Error( + `The ${kind} of a ManagerWorkers must match the ${kind} of its group manager.`, + ); + } + } +} + export function createManagerWorkers(opts: { name: string; groupManager: Record; @@ -31,9 +68,19 @@ export function createManagerWorkers(opts: { if (opts.workers.some(w => w === opts.groupManager)) { throw new Error("Group manager cannot be a worker."); } + const managerInputs = getComponentProperties(opts.groupManager, "inputs"); + const managerOutputs = getComponentProperties(opts.groupManager, "outputs"); + if (opts.inputs !== undefined) { + validatePropertiesMatchManager(opts.inputs, managerInputs, "inputs"); + } + if (opts.outputs !== undefined) { + validatePropertiesMatchManager(opts.outputs, managerOutputs, "outputs"); + } return Object.freeze( ManagerWorkersSchema.parse({ ...opts, + inputs: opts.inputs ?? managerInputs, + outputs: opts.outputs ?? managerOutputs, componentType: "ManagerWorkers" as const, }), ); diff --git a/tsagentspec/tests/agents/manager-workers.test.ts b/tsagentspec/tests/agents/manager-workers.test.ts index 3f58b895..7daac563 100644 --- a/tsagentspec/tests/agents/manager-workers.test.ts +++ b/tsagentspec/tests/agents/manager-workers.test.ts @@ -3,6 +3,8 @@ import { createManagerWorkers, createAgent, createOpenAiCompatibleConfig, + numberProperty, + stringProperty, } from "../../src/index.js"; function makeLlmConfig() { @@ -118,4 +120,42 @@ describe("ManagerWorkers", () => { }), ).toThrow("Group manager cannot be a worker."); }); + + it("should infer the group manager's inputs and outputs", () => { + const topic = stringProperty({ title: "topic" }); + const answer = stringProperty({ title: "answer" }); + const manager = createAgent({ + name: "manager", + llmConfig: makeLlmConfig(), + systemPrompt: "Manage the team.", + inputs: [topic], + outputs: [answer], + }); + const mw = createManagerWorkers({ + name: "test-mw", + groupManager: manager, + workers: [makeAgent("worker")], + }); + + expect(mw.inputs).toEqual([topic]); + expect(mw.outputs).toEqual([answer]); + }); + + it("should reject explicit I/O that differs from the group manager", () => { + const manager = createAgent({ + name: "manager", + llmConfig: makeLlmConfig(), + systemPrompt: "Manage the team.", + outputs: [stringProperty({ title: "answer" })], + }); + + expect(() => + createManagerWorkers({ + name: "test-mw", + groupManager: manager, + workers: [makeAgent("worker")], + outputs: [numberProperty({ title: "answer" })], + }), + ).toThrow("outputs of a ManagerWorkers must match"); + }); }); From 3160835129ce983f578c18350d92d05833b8ff25 Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Mon, 24 Aug 2026 15:19:57 +0200 Subject: [PATCH 10/17] Fix validation --- .../howto_managerworkers.json | 13 +++++-- .../howto_managerworkers.yaml | 8 +++-- pyagentspec/src/pyagentspec/managerworkers.py | 23 ++++++++++--- .../serialization/test_managerworkers.py | 34 +++++++++++++++++++ 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json index 1159e7f9..492f715d 100644 --- a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json +++ b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json @@ -4,7 +4,16 @@ "name": "managerworkers", "description": null, "metadata": {}, - "inputs": [], + "inputs": [ + { + "title": "customer_id", + "type": "string" + }, + { + "title": "company_policy_info", + "type": "string" + } + ], "outputs": [], "group_manager": { "component_type": "Agent", @@ -208,5 +217,5 @@ "model_id": "llama-4-maverick" } }, - "agentspec_version": "26.1.0" + "agentspec_version": "26.2.0" } diff --git a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml index fb74f087..b3d31b4f 100644 --- a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml +++ b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml @@ -9,7 +9,11 @@ id: 248045cb-ca6f-4d6f-9e22-d28b452a25da name: managerworkers description: null metadata: {} -inputs: [] +inputs: +- title: company_policy_info + type: string +- title: customer_id + type: string outputs: [] group_manager: component_type: Agent @@ -225,4 +229,4 @@ $referenced_components: default_generation_parameters: null url: http://url.to.my.vllm.server/llama4mav model_id: llama-4-maverick -agentspec_version: 26.1.0 +agentspec_version: 26.2.0 diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index 497f8b5c..50f69b70 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -69,19 +69,34 @@ class ManagerWorkers(AgenticComponent): def _get_inferred_inputs(self) -> List[Property]: # Per the language spec, the inputs of a ManagerWorkers are the inputs of its # group manager (same name and type): the manager drives the conversation. - return self.group_manager.inputs or [] + return ( + self.group_manager.inputs or [] + if self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 + else [] + ) def _get_inferred_outputs(self) -> List[Property]: # Symmetric with the inferred inputs: the group manager's outputs. - return self.group_manager.outputs or [] + return ( + self.group_manager.outputs or [] + if self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 + else [] + ) def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: min_version = super()._infer_min_agentspec_version_from_configuration() - # Inheritance of manager's inputs and outputs was introduced in 26.2.0 - if self.group_manager.inputs or self.group_manager.outputs: + # ManagerWorkers I/O matching was introduced in 26.2.0. + if self.inputs or self.outputs: min_version = max(min_version, AgentSpecVersionEnum.v26_2_0) return min_version + def _infer_max_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: + max_version = super()._infer_max_agentspec_version_from_configuration() + # Before 26.2.0 a ManagerWorkers did not inherit its manager's I/O. + if (self.group_manager.inputs or self.group_manager.outputs) and not (self.inputs or self.outputs): + max_version = min(max_version, AgentSpecVersionEnum.v26_1_2) + return max_version + @model_validator_with_error_accumulation def _validate_one_or_more_workers(self) -> Self: if len(self.workers) == 0: diff --git a/pyagentspec/tests/serialization/test_managerworkers.py b/pyagentspec/tests/serialization/test_managerworkers.py index 693b3915..757d4f73 100644 --- a/pyagentspec/tests/serialization/test_managerworkers.py +++ b/pyagentspec/tests/serialization/test_managerworkers.py @@ -125,6 +125,7 @@ def test_managerworkers_infers_manager_ios_in_current_version() -> None: name="team", group_manager=manager, workers=[worker], + outputs=[StringProperty(title="answer")], ) assert manager_workers.outputs == manager.outputs @@ -133,3 +134,36 @@ def test_managerworkers_infers_manager_ios_in_current_version() -> None: AgentSpecSerializer().to_dict( manager_workers, agentspec_version=AgentSpecVersionEnum.v25_4_2 ) + + +def test_managerworkers_without_ios_with_manager_ios_is_legacy_compatible() -> None: + llm_config = VllmConfig(name="model", model_id="model_id", url="https://example.com") + manager = Agent( + name="manager", + llm_config=llm_config, + system_prompt="Manage {{question}}.", + inputs=[StringProperty(title="question")], + outputs=[StringProperty(title="answer")], + ) + worker = Agent(name="worker", llm_config=llm_config, system_prompt="Help the manager.") + manager_workers = ManagerWorkers( + name="team", + group_manager=manager, + workers=[worker], + inputs=[], + outputs=[], + ) + + assert manager_workers.inputs == [] + assert manager_workers.outputs == [] + assert manager_workers.min_agentspec_version == AgentSpecVersionEnum.v25_4_2 + assert manager_workers.max_agentspec_version == AgentSpecVersionEnum.v26_1_2 + + serialized = AgentSpecSerializer().to_dict( + manager_workers, agentspec_version=AgentSpecVersionEnum.v26_1_2 + ) + deserialized = AgentSpecDeserializer().from_dict(serialized) + + assert isinstance(deserialized, ManagerWorkers) + assert deserialized.inputs == [] + assert deserialized.outputs == [] From fee4bf83a2a91b242e99668e6ea838fd5965b3e7 Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Mon, 24 Aug 2026 15:35:18 +0200 Subject: [PATCH 11/17] Fix format --- pyagentspec/src/pyagentspec/managerworkers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index 50f69b70..8a6b0610 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -93,7 +93,9 @@ def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnu def _infer_max_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: max_version = super()._infer_max_agentspec_version_from_configuration() # Before 26.2.0 a ManagerWorkers did not inherit its manager's I/O. - if (self.group_manager.inputs or self.group_manager.outputs) and not (self.inputs or self.outputs): + if (self.group_manager.inputs or self.group_manager.outputs) and not ( + self.inputs or self.outputs + ): max_version = min(max_version, AgentSpecVersionEnum.v26_1_2) return max_version From f81b5ea79286924fbcdb57b1da080412eb17d20e Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Mon, 24 Aug 2026 16:02:02 +0200 Subject: [PATCH 12/17] Fix format --- pyagentspec/src/pyagentspec/managerworkers.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index 8a6b0610..d5db68a8 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -71,7 +71,8 @@ def _get_inferred_inputs(self) -> List[Property]: # group manager (same name and type): the manager drives the conversation. return ( self.group_manager.inputs or [] - if self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 + if getattr(self, "group_manager", None) + and self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 else [] ) @@ -79,22 +80,25 @@ def _get_inferred_outputs(self) -> List[Property]: # Symmetric with the inferred inputs: the group manager's outputs. return ( self.group_manager.outputs or [] - if self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 + if getattr(self, "group_manager", None) + and self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 else [] ) def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: min_version = super()._infer_min_agentspec_version_from_configuration() # ManagerWorkers I/O matching was introduced in 26.2.0. - if self.inputs or self.outputs: + if getattr(self, "inputs", []) or getattr(self, "outputs", []): min_version = max(min_version, AgentSpecVersionEnum.v26_2_0) return min_version def _infer_max_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: max_version = super()._infer_max_agentspec_version_from_configuration() # Before 26.2.0 a ManagerWorkers did not inherit its manager's I/O. - if (self.group_manager.inputs or self.group_manager.outputs) and not ( - self.inputs or self.outputs + if ( + getattr(self, "group_manager", None) + and (self.group_manager.inputs or self.group_manager.outputs) + and not (self.inputs or self.outputs) ): max_version = min(max_version, AgentSpecVersionEnum.v26_1_2) return max_version From bf30c66fcbc2c456b651361200fdf8ca2bbfcf7b Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Mon, 24 Aug 2026 17:01:22 +0200 Subject: [PATCH 13/17] Fix mypy issues --- .../src/pyagentspec/adapters/langgraph/_execution_span.py | 4 ++-- .../pyagentspec/adapters/langgraph/_langgraphconverter.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py index 033484c8..1fe7e0bb 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_execution_span.py @@ -83,5 +83,5 @@ async def patched_astream(*args: Any, **kwargs: Any) -> AsyncGenerator[Any, Any] finally: await _async_or_sync(span.end_async, span.end) - compiled_graph.stream = patched_stream # type: ignore[assignment] - compiled_graph.astream = patched_astream # type: ignore[assignment] + compiled_graph.stream = patched_stream # type: ignore[method-assign] + compiled_graph.astream = patched_astream # type: ignore[method-assign] diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py index 8929b1be..0ca55cb0 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_langgraphconverter.py @@ -15,6 +15,7 @@ Awaitable, Callable, Dict, + Hashable, List, Optional, Tuple, @@ -1165,13 +1166,16 @@ def _manager_workers_convert_to_langgraph( builder.add_edge(node_name, _MANAGER_NODE_KEY) builder.add_edge(langgraph_graph.START, _MANAGER_NODE_KEY) + path_map: Dict[Hashable, str] = {} + for node_name in worker_node_names: + path_map[node_name] = node_name + path_map[langgraph_graph.END] = langgraph_graph.END builder.add_conditional_edges( _MANAGER_NODE_KEY, _make_manager_router(worker_node_names), # The path map covers every worker plus END, so langgraph can validate # the routing statically. - {node_name: node_name for node_name in worker_node_names} - | {langgraph_graph.END: langgraph_graph.END}, + path_map, ) compiled_graph = builder.compile(checkpointer=checkpointer, name=mw.name) From 4e77dcf0dcb4af8f224b6db0c50efd34e9db641f Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Tue, 25 Aug 2026 15:54:34 +0200 Subject: [PATCH 14/17] Post-release updates --- .../howto_managerworkers.json | 2 +- .../howto_managerworkers.yaml | 2 +- pyagentspec/src/pyagentspec/managerworkers.py | 12 ++++++------ .../tests/serialization/test_managerworkers.py | 6 +++--- tsagentspec/src/versioning.ts | 5 ++++- tsagentspec/tests/versioning.test.ts | 7 +++++-- 6 files changed, 20 insertions(+), 14 deletions(-) diff --git a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json index 492f715d..221088a6 100644 --- a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json +++ b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.json @@ -217,5 +217,5 @@ "model_id": "llama-4-maverick" } }, - "agentspec_version": "26.2.0" + "agentspec_version": "26.4.0" } diff --git a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml index b3d31b4f..01bb633f 100644 --- a/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml +++ b/docs/pyagentspec/source/agentspec_config_examples/howto_managerworkers.yaml @@ -229,4 +229,4 @@ $referenced_components: default_generation_parameters: null url: http://url.to.my.vllm.server/llama4mav model_id: llama-4-maverick -agentspec_version: 26.2.0 +agentspec_version: 26.4.0 diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index d5db68a8..c2e25090 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -72,7 +72,7 @@ def _get_inferred_inputs(self) -> List[Property]: return ( self.group_manager.inputs or [] if getattr(self, "group_manager", None) - and self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 + and self.min_agentspec_version >= AgentSpecVersionEnum.v26_4_0 else [] ) @@ -81,26 +81,26 @@ def _get_inferred_outputs(self) -> List[Property]: return ( self.group_manager.outputs or [] if getattr(self, "group_manager", None) - and self.min_agentspec_version >= AgentSpecVersionEnum.v26_2_0 + and self.min_agentspec_version >= AgentSpecVersionEnum.v26_4_0 else [] ) def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: min_version = super()._infer_min_agentspec_version_from_configuration() - # ManagerWorkers I/O matching was introduced in 26.2.0. + # ManagerWorkers I/O matching was introduced in 26.4.0. if getattr(self, "inputs", []) or getattr(self, "outputs", []): - min_version = max(min_version, AgentSpecVersionEnum.v26_2_0) + min_version = max(min_version, AgentSpecVersionEnum.v26_4_0) return min_version def _infer_max_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: max_version = super()._infer_max_agentspec_version_from_configuration() - # Before 26.2.0 a ManagerWorkers did not inherit its manager's I/O. + # Before 26.4.0 a ManagerWorkers did not inherit its manager's I/O. if ( getattr(self, "group_manager", None) and (self.group_manager.inputs or self.group_manager.outputs) and not (self.inputs or self.outputs) ): - max_version = min(max_version, AgentSpecVersionEnum.v26_1_2) + max_version = min(max_version, AgentSpecVersionEnum.v26_3_0) return max_version @model_validator_with_error_accumulation diff --git a/pyagentspec/tests/serialization/test_managerworkers.py b/pyagentspec/tests/serialization/test_managerworkers.py index 757d4f73..dba17837 100644 --- a/pyagentspec/tests/serialization/test_managerworkers.py +++ b/pyagentspec/tests/serialization/test_managerworkers.py @@ -132,7 +132,7 @@ def test_managerworkers_infers_manager_ios_in_current_version() -> None: assert manager_workers.min_agentspec_version == AgentSpecVersionEnum.current_version with pytest.raises(ValueError, match="Invalid agentspec_version"): AgentSpecSerializer().to_dict( - manager_workers, agentspec_version=AgentSpecVersionEnum.v25_4_2 + manager_workers, agentspec_version=AgentSpecVersionEnum.v26_3_0 ) @@ -157,10 +157,10 @@ def test_managerworkers_without_ios_with_manager_ios_is_legacy_compatible() -> N assert manager_workers.inputs == [] assert manager_workers.outputs == [] assert manager_workers.min_agentspec_version == AgentSpecVersionEnum.v25_4_2 - assert manager_workers.max_agentspec_version == AgentSpecVersionEnum.v26_1_2 + assert manager_workers.max_agentspec_version == AgentSpecVersionEnum.v26_3_0 serialized = AgentSpecSerializer().to_dict( - manager_workers, agentspec_version=AgentSpecVersionEnum.v26_1_2 + manager_workers, agentspec_version=AgentSpecVersionEnum.v26_3_0 ) deserialized = AgentSpecDeserializer().from_dict(serialized) diff --git a/tsagentspec/src/versioning.ts b/tsagentspec/src/versioning.ts index 94df5c04..fa566920 100644 --- a/tsagentspec/src/versioning.ts +++ b/tsagentspec/src/versioning.ts @@ -10,14 +10,17 @@ export const AgentSpecVersion = { V25_4_1: "25.4.1", V25_4_2: "25.4.2", V26_1_0: "26.1.0", + V26_1_2: "26.1.2", V26_2_0: "26.2.0", + V26_3_0: "26.3.0", + V26_4_0: "26.4.0", } as const; export type AgentSpecVersion = (typeof AgentSpecVersion)[keyof typeof AgentSpecVersion]; /** The current (latest) agent spec version */ -export const CURRENT_VERSION: AgentSpecVersion = AgentSpecVersion.V26_2_0; +export const CURRENT_VERSION: AgentSpecVersion = AgentSpecVersion.V26_4_0; /** Field name for the agentspec version in serialized JSON/YAML */ export const AGENTSPEC_VERSION_FIELD_NAME = "agentspec_version"; diff --git a/tsagentspec/tests/versioning.test.ts b/tsagentspec/tests/versioning.test.ts index d8b63f9c..06d13be4 100644 --- a/tsagentspec/tests/versioning.test.ts +++ b/tsagentspec/tests/versioning.test.ts @@ -17,12 +17,15 @@ describe("AgentSpecVersion", () => { expect(AgentSpecVersion.V25_4_1).toBe("25.4.1"); expect(AgentSpecVersion.V25_4_2).toBe("25.4.2"); expect(AgentSpecVersion.V26_1_0).toBe("26.1.0"); + expect(AgentSpecVersion.V26_1_2).toBe("26.1.2"); expect(AgentSpecVersion.V26_2_0).toBe("26.2.0"); + expect(AgentSpecVersion.V26_3_0).toBe("26.3.0"); + expect(AgentSpecVersion.V26_4_0).toBe("26.4.0"); }); it("should set CURRENT_VERSION to the latest version", () => { - expect(CURRENT_VERSION).toBe("26.2.0"); - expect(CURRENT_VERSION).toBe(AgentSpecVersion.V26_2_0); + expect(CURRENT_VERSION).toBe("26.4.0"); + expect(CURRENT_VERSION).toBe(AgentSpecVersion.V26_4_0); }); it("should define the version field name", () => { From 6c3b58352000e70c607cad7a6a6bf90150d0bcea Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Fri, 28 Aug 2026 19:20:25 +0200 Subject: [PATCH 15/17] Fix min/max version inference --- .../langgraph/_managerworkers_node.py | 5 ++-- pyagentspec/src/pyagentspec/managerworkers.py | 27 ++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py index f04c7815..74a0e700 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers_node.py @@ -7,9 +7,8 @@ """Runs a ``ManagerWorkers`` as a flow step. ``AgentSpecToLangGraphConverter._agent_node_convert_to_langgraph`` selects -:class:`ManagerWorkersNodeExecutor` when the node's agent is a ``ManagerWorkers``, -so :class:`~pyagentspec.adapters.langgraph._node_execution.AgentNodeExecutor` keeps -the plain-Agent behavior only. +`ManagerWorkersNodeExecutor` when the node's agent is a ``ManagerWorkers``, +so `AgentNodeExecutor` keeps the plain-Agent behavior only. """ from typing import Any, Dict, List, Optional, Tuple diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index c2e25090..8d9c7319 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -87,18 +87,33 @@ def _get_inferred_outputs(self) -> List[Property]: def _infer_min_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: min_version = super()._infer_min_agentspec_version_from_configuration() - # ManagerWorkers I/O matching was introduced in 26.4.0. - if getattr(self, "inputs", []) or getattr(self, "outputs", []): + # ManagerWorkers I/O matching was introduced in 26.4.0. Omitted I/O + # inherits from the group manager; explicitly empty I/O is legacy-only. + manager = getattr(self, "group_manager", None) + inherits_manager_io = bool( + manager + and ( + ("inputs" not in self.model_fields_set and manager.inputs) + or ("outputs" not in self.model_fields_set and manager.outputs) + ) + ) + if inherits_manager_io or getattr(self, "inputs", []) or getattr(self, "outputs", []): min_version = max(min_version, AgentSpecVersionEnum.v26_4_0) return min_version def _infer_max_agentspec_version_from_configuration(self) -> AgentSpecVersionEnum: max_version = super()._infer_max_agentspec_version_from_configuration() # Before 26.4.0 a ManagerWorkers did not inherit its manager's I/O. - if ( - getattr(self, "group_manager", None) - and (self.group_manager.inputs or self.group_manager.outputs) - and not (self.inputs or self.outputs) + manager = getattr(self, "group_manager", None) + inherits_manager_io = bool( + manager + and ( + ("inputs" not in self.model_fields_set and manager.inputs) + or ("outputs" not in self.model_fields_set and manager.outputs) + ) + ) + if manager and (manager.inputs or manager.outputs) and not inherits_manager_io and not ( + self.inputs or self.outputs ): max_version = min(max_version, AgentSpecVersionEnum.v26_3_0) return max_version From 3db690b52e7ee6badbfec4843fed2535fcfcff9b Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Mon, 31 Aug 2026 10:08:48 +0200 Subject: [PATCH 16/17] Format --- pyagentspec/src/pyagentspec/managerworkers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pyagentspec/src/pyagentspec/managerworkers.py b/pyagentspec/src/pyagentspec/managerworkers.py index 8d9c7319..11c9088c 100644 --- a/pyagentspec/src/pyagentspec/managerworkers.py +++ b/pyagentspec/src/pyagentspec/managerworkers.py @@ -112,8 +112,11 @@ def _infer_max_agentspec_version_from_configuration(self) -> AgentSpecVersionEnu or ("outputs" not in self.model_fields_set and manager.outputs) ) ) - if manager and (manager.inputs or manager.outputs) and not inherits_manager_io and not ( - self.inputs or self.outputs + if ( + manager + and (manager.inputs or manager.outputs) + and not inherits_manager_io + and not (self.inputs or self.outputs) ): max_version = min(max_version, AgentSpecVersionEnum.v26_3_0) return max_version From 264d328f9170df32d1f1e5c20ac292d2996860e8 Mon Sep 17 00:00:00 2001 From: Cesare Bernardis Date: Mon, 31 Aug 2026 14:50:19 +0200 Subject: [PATCH 17/17] Fixes --- .../adapters/langgraph/_managerworkers.py | 19 ++++++++++++------- .../adapters/langgraph/test_managerworkers.py | 5 +++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py index ea931b2a..9d2298fb 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/_managerworkers.py @@ -16,7 +16,11 @@ from typing import Annotated, Any, Dict, Iterable, List, Tuple from pyagentspec.adapters.langgraph._execution_span import patch_with_execution_span -from pyagentspec.adapters.langgraph._types import CompiledStateGraph, langgraph_graph +from pyagentspec.adapters.langgraph._types import ( + CompiledStateGraph, + RunnableConfig, + langgraph_graph, +) from pyagentspec.managerworkers import ManagerWorkers as AgentSpecManagerWorkers from pyagentspec.tracing.events import ( ManagerWorkersExecutionEnd as AgentSpecManagerWorkersExecutionEnd, @@ -194,16 +198,17 @@ def _wrap_worker_for_subgraph( Hierarchical rather than shared-state like a Swarm: each run is handed only the manager's chosen task, and the worker's answer comes back as a ToolMessage so the manager's react loop sees a well-formed tool response on its next turn. The worker - is invoked with no explicit config and inherits this node's ambient run config, - which streams its token events under the worker node's checkpoint namespace. + receives this node's ambient run config explicitly, which streams its token events + under the worker node's checkpoint namespace. Explicit propagation is necessary on + Python 3.10, where LangChain cannot preserve the callback context across tasks. """ from pyagentspec.adapters.langgraph._types import RunnableLambda - def run(state: Dict[str, Any]) -> Dict[str, Any]: - return _worker_reply(state, worker_graph.invoke(_worker_input(state))) + def run(state: Dict[str, Any], config: RunnableConfig) -> Dict[str, Any]: + return _worker_reply(state, worker_graph.invoke(_worker_input(state), config=config)) - async def arun(state: Dict[str, Any]) -> Dict[str, Any]: - return _worker_reply(state, await worker_graph.ainvoke(_worker_input(state))) + async def arun(state: Dict[str, Any], config: RunnableConfig) -> Dict[str, Any]: + return _worker_reply(state, await worker_graph.ainvoke(_worker_input(state), config=config)) return RunnableLambda(func=run, afunc=arun, name=f"worker:{worker_node_name}") diff --git a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py index 96cdc656..01a14389 100644 --- a/pyagentspec/tests/adapters/langgraph/test_managerworkers.py +++ b/pyagentspec/tests/adapters/langgraph/test_managerworkers.py @@ -390,6 +390,7 @@ def test_worker_events_stream_natively_namespaced_under_worker_node() -> None: from langchain_core.language_models.fake_chat_models import GenericFakeChatModel from langchain_core.messages import AIMessage, HumanMessage + from langchain_core.runnables import RunnableConfig from langgraph.graph import END, START, MessagesState, StateGraph from pyagentspec.adapters.langgraph._managerworkers import _wrap_worker_for_subgraph @@ -398,8 +399,8 @@ def test_worker_events_stream_natively_namespaced_under_worker_node() -> None: wmodel = GenericFakeChatModel(messages=iter([AIMessage(content="Saturn has rings")] * 9)) wb = StateGraph(MessagesState) - async def _wagent(state: Any) -> Any: - return {"messages": [await wmodel.ainvoke(state["messages"])]} + async def _wagent(state: Any, config: RunnableConfig) -> Any: + return {"messages": [await wmodel.ainvoke(state["messages"], config=config)]} wb.add_node("agent", _wagent) wb.add_edge(START, "agent")