diff --git a/docs/pyagentspec/source/api/adapters.rst b/docs/pyagentspec/source/api/adapters.rst index a7d414ae6..e95da929d 100644 --- a/docs/pyagentspec/source/api/adapters.rst +++ b/docs/pyagentspec/source/api/adapters.rst @@ -7,6 +7,12 @@ into the equivalent solution, as per each framework's definition, and return an This page presents all APIs and classes related to Agent Spec Adapters. +Shared Loader +------------- + +.. _adapters_shared_loader: +.. autoclass:: pyagentspec.adapters._agentspecloader.AdapterAgnosticAgentSpecLoader + LangGraph --------- diff --git a/docs/pyagentspec/source/changelog.rst b/docs/pyagentspec/source/changelog.rst index d9961f62f..16aaeaa70 100644 --- a/docs/pyagentspec/source/changelog.rst +++ b/docs/pyagentspec/source/changelog.rst @@ -71,6 +71,12 @@ Improvements directly in the source template instead of recursively splitting the template text. This keeps unresolved placeholders unchanged and makes the rendering logic easier to follow. +* **Component loading defaults** + + Agent Spec loaders now expose ``allowed_components`` and ``blocked_components`` options, + allowing users to control which Agent Spec component types can be loaded from configurations. + Component type names that resolve to known Component classes use hierarchy matching, + like class entries; unresolved type names match only the exact serialized component type. New features ^^^^^^^^^^^^ @@ -202,6 +208,14 @@ Property titles in Agent Spec must not be empty. This is now enforced by validat Migration: If your YAML/JSON configurations have properties without titles, you’ll need to set a non-empty, descriptive title for those properties to pass validation. If you generate Agent Spec configurations via the SDK, your code may still work, but we recommend explicitly setting property titles to ensure forward compatibility. +* **MCP stdio transport is blocked by default in Agent Spec loaders** + + ``StdioTransport`` and its subclasses will no longer load by default through + Agent Spec loaders. If a trusted configuration + intentionally uses stdio transports, pass ``blocked_components=[]`` to the + loader, or provide a custom ``blocked_components`` value that does not include + the stdio transport component class. + Agent Spec 26.1.0 ----------------- diff --git a/docs/pyagentspec/source/security.rst b/docs/pyagentspec/source/security.rst index 7f80a451c..5d778c7ea 100644 --- a/docs/pyagentspec/source/security.rst +++ b/docs/pyagentspec/source/security.rst @@ -353,6 +353,27 @@ utilities to avoid unsafe behavior. When deploying serialized representation to an execution engine, use the engine's secure loading mechanism for the import. +Considerations regarding runtime-sensitive components +----------------------------------------------------- + +Some Agent Spec components can influence actions performed by adapters or runtimes +when a configuration is loaded or run. For example, MCP stdio transports can start +a local MCP server subprocess on the machine that loads and runs the resulting +agent. + +Only enable runtime-sensitive components from trusted configurations. PyAgentSpec +loaders block ``StdioTransport`` and its subclasses by default. If a trusted +configuration intentionally uses stdio transports, pass +``blocked_components=[]`` to the loader, or provide a custom +``blocked_components`` value that does not include the stdio transport +component class. For stricter environments, use ``allowed_components`` to +explicitly list the component types that may be loaded. + +Component policy entries can be provided as Component classes or component type +names. Type names that resolve to known Component classes use the same hierarchy +matching as class entries; unresolved type names match only the exact serialized +component type. + .. _securitycatchexceptionnode: Considerations regarding exception handling in Flows diff --git a/pyagentspec/constraints/constraints.txt b/pyagentspec/constraints/constraints.txt index ac3e65f2a..e8d292682 100644 --- a/pyagentspec/constraints/constraints.txt +++ b/pyagentspec/constraints/constraints.txt @@ -17,6 +17,7 @@ crewai==1.6.1 langgraph==1.0.5 langchain==1.2.0 langchain-openai==1.1.7 +openai==2.35.1 langchain-ollama==1.0.1 langchain-oci==0.2.3 langchain-mcp-adapters==0.2.1 diff --git a/pyagentspec/src/pyagentspec/adapters/_agentspecloader.py b/pyagentspec/src/pyagentspec/adapters/_agentspecloader.py index 4b93f58e2..3d4c9dfe9 100644 --- a/pyagentspec/src/pyagentspec/adapters/_agentspecloader.py +++ b/pyagentspec/src/pyagentspec/adapters/_agentspecloader.py @@ -8,13 +8,27 @@ import logging from abc import ABC, abstractmethod -from typing import Any, Callable, Dict, List, Optional, Protocol, TypeAlias, Union, cast, overload +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Protocol, + TypeAlias, + Union, + cast, + overload, +) from pyagentspec.component import Component as AgentSpecComponent +from pyagentspec.mcp import StdioTransport from pyagentspec.serialization import AgentSpecDeserializer, ComponentDeserializationPlugin +from pyagentspec.serialization.componentpolicy import ComponentLoadPolicy, ComponentPolicyInput _RuntimeComponentT: TypeAlias = Any _RuntimeRegistryT: TypeAlias = Dict[str, Any] +_DEFAULT_BLOCKED_COMPONENTS: tuple[type[AgentSpecComponent], ...] = (StdioTransport,) logger = logging.getLogger(__name__) @@ -66,15 +80,37 @@ class AdapterAgnosticAgentSpecLoader(ABC): Optional callable to convert a runtime components registry into an Agent Spec registry (mapping ids to Agent Spec components/values) so that references can be resolved during deserialization. + allowed_components: + Optional iterable of Agent Spec component type names or Component classes allowed + to be loaded. If omitted, all component types are allowed unless blocked. + blocked_components: + Optional iterable of Agent Spec component type names or Component classes blocked + from loading. If omitted, ``StdioTransport`` and its subclasses are blocked by default. + Resolvable type names and Component classes also match subclasses; unresolved + type names match only the exact serialized component type. When allow and + block entries both match, the closest match in the component class hierarchy + wins; block entries win same-distance ties. """ def __init__( self, tool_registry: Optional[Dict[str, Any]] = None, plugins: Optional[List[ComponentDeserializationPlugin]] = None, + *, + allowed_components: Optional[ComponentPolicyInput] = None, + blocked_components: Optional[ComponentPolicyInput] = None, ) -> None: self.plugins = plugins self.tool_registry = tool_registry or {} + blocked_components = ( + _DEFAULT_BLOCKED_COMPONENTS if blocked_components is None else blocked_components + ) + self.component_load_policy = ComponentLoadPolicy( + allowed_components=allowed_components, + blocked_components=blocked_components, + ) + self.allowed_components = self.component_load_policy.allowed_components + self.blocked_components = self.component_load_policy.blocked_components @property @abstractmethod @@ -296,6 +332,7 @@ def load_component(self, agentspec_component: AgentSpecComponent) -> _RuntimeCom Subclasses may override this method to pass adapter-specific parameters into their converter (e.g., tool registries). """ + self.component_load_policy.validate_component_tree(agentspec_component) return self.agentspec_to_runtime_converter.convert( agentspec_component, tool_registry=self.tool_registry ) @@ -326,7 +363,11 @@ def _load( import_only_referenced_components: bool, ) -> Union[_RuntimeComponentT, Dict[str, _RuntimeComponentT]]: - deserializer = AgentSpecDeserializer(plugins=self.plugins) + deserializer = AgentSpecDeserializer( + plugins=self.plugins, + allowed_components=self.allowed_components, + blocked_components=self.blocked_components, + ) deserializer_func: Callable[..., Any] if loader == "yaml": deserializer_func = deserializer.from_yaml diff --git a/pyagentspec/src/pyagentspec/adapters/crewai/agentspecloader.py b/pyagentspec/src/pyagentspec/adapters/crewai/agentspecloader.py index 3d1c54bbe..19836298d 100644 --- a/pyagentspec/src/pyagentspec/adapters/crewai/agentspecloader.py +++ b/pyagentspec/src/pyagentspec/adapters/crewai/agentspecloader.py @@ -15,19 +15,36 @@ ) from pyagentspec.adapters.crewai._agentspecconverter import CrewAIToAgentSpecConverter from pyagentspec.adapters.crewai._crewaiconverter import AgentSpecToCrewAIConverter +from pyagentspec.serialization.componentpolicy import ComponentPolicyInput class AgentSpecLoader(AdapterAgnosticAgentSpecLoader): - """Helper class to convert Agent Spec configurations to CrewAI objects.""" + """Helper class to convert Agent Spec configurations to CrewAI objects. + + ``allowed_components`` and ``blocked_components`` can be used to constrain + which Agent Spec component types load. Resolvable type names and Component + classes match subclasses; unresolved type names match only the exact serialized + component type. When allow and block entries both match, the closest match + in the component class hierarchy wins; block entries win same-distance ties. + If ``blocked_components`` is omitted, ``StdioTransport`` and its subclasses + are blocked by default. + """ def __init__( self, tool_registry: Optional[Dict[str, Any]] = None, plugins: Optional[List[Any]] = None, *, + allowed_components: Optional[ComponentPolicyInput] = None, + blocked_components: Optional[ComponentPolicyInput] = None, enable_agentspec_tracing: bool = True, ) -> None: - super().__init__(tool_registry=tool_registry, plugins=plugins) + super().__init__( + tool_registry=tool_registry, + plugins=plugins, + allowed_components=allowed_components, + blocked_components=blocked_components, + ) self._enable_agentspec_tracing = enable_agentspec_tracing @property diff --git a/pyagentspec/src/pyagentspec/adapters/langgraph/agentspecloader.py b/pyagentspec/src/pyagentspec/adapters/langgraph/agentspecloader.py index 4b72cb5f4..b0d768b3d 100644 --- a/pyagentspec/src/pyagentspec/adapters/langgraph/agentspecloader.py +++ b/pyagentspec/src/pyagentspec/adapters/langgraph/agentspecloader.py @@ -18,6 +18,7 @@ ) from pyagentspec.component import Component as AgentSpecComponent from pyagentspec.serialization import ComponentDeserializationPlugin +from pyagentspec.serialization.componentpolicy import ComponentPolicyInput class AgentSpecLoader(AdapterAgnosticAgentSpecLoader): @@ -38,6 +39,16 @@ class AgentSpecLoader(AdapterAgnosticAgentSpecLoader): enables features that require a checkpointer (e.g., client tools). config: Optional ``RunnableConfig`` to pass to created runnables/graphs. + allowed_components: + Optional iterable of Agent Spec component type names or Component classes allowed + to be loaded. If omitted, all component types are allowed unless blocked. + blocked_components: + Optional iterable of Agent Spec component type names or Component classes blocked + from loading. If omitted, stdio MCP transports are blocked by default. + Resolvable type names and Component classes also match subclasses; unresolved + type names match only the exact serialized component type. When allow and + block entries both match, the closest match in the component class hierarchy + wins; block entries win same-distance ties. """ def __init__( @@ -46,8 +57,16 @@ def __init__( plugins: Optional[List[ComponentDeserializationPlugin]] = None, checkpointer: Optional[Checkpointer] = None, config: Optional[RunnableConfig] = None, + *, + allowed_components: Optional[ComponentPolicyInput] = None, + blocked_components: Optional[ComponentPolicyInput] = None, ) -> None: - super().__init__(plugins=plugins, tool_registry=tool_registry) + super().__init__( + plugins=plugins, + tool_registry=tool_registry, + allowed_components=allowed_components, + blocked_components=blocked_components, + ) self.checkpointer = checkpointer self.config = config @@ -259,7 +278,8 @@ def load_dict( ) def load_component(self, agentspec_component: AgentSpecComponent) -> LangGraphRuntimeComponent: - # Need to override to make it use config and checkpointer + # Need to override to make it use config and checkpointer, while preserving base policy validation + self.component_load_policy.validate_component_tree(agentspec_component) return cast( LangGraphRuntimeComponent, self.agentspec_to_runtime_converter.convert( diff --git a/pyagentspec/src/pyagentspec/adapters/openaiagents/agentspecloader.py b/pyagentspec/src/pyagentspec/adapters/openaiagents/agentspecloader.py index 7ab3eb290..ba9e3f042 100644 --- a/pyagentspec/src/pyagentspec/adapters/openaiagents/agentspecloader.py +++ b/pyagentspec/src/pyagentspec/adapters/openaiagents/agentspecloader.py @@ -17,6 +17,7 @@ from pyagentspec.component import Component as AgentSpecComponent from pyagentspec.flows.flow import Flow as AgentSpecFlow from pyagentspec.serialization import ComponentDeserializationPlugin +from pyagentspec.serialization.componentpolicy import ComponentPolicyInput _OAComponent = Union[OAAgent, str] @@ -48,6 +49,9 @@ def _load_component( """ is_flow = isinstance(agentspec_component, cast(type, AgentSpecFlow)) if is_flow: + # Flow codegen bypasses the base loader's conversion path, so enforce the + # component policy here before generating executable Python source. + agent_spec_loader.component_load_policy.validate_component_tree(agentspec_component) pack = resolve_rulepack(rulepack_version) ir = pack.agentspec_to_ir(agentspec_component, strict=True) mod = pack.codegen(ir, module_name=module_name) @@ -61,12 +65,22 @@ def _load_component( class AgentSpecLoader(AdapterAgnosticAgentSpecLoader): - """Helper class to convert Agent Spec configurations to OpenAI Agents SDK objects.""" + """Helper class to convert Agent Spec configurations to OpenAI Agents SDK objects. + + ``allowed_components`` and ``blocked_components`` can be used to constrain + which Agent Spec component types load. Resolvable type names and Component + classes match subclasses; unresolved type names match only the exact serialized + component type. When allow and block entries both match, the closest match + in the component class hierarchy wins; block entries win same-distance ties. + """ def __init__( self, tool_registry: Optional[Dict[str, _TargetTool]] = None, plugins: Optional[List[ComponentDeserializationPlugin]] = None, + *, + allowed_components: Optional[ComponentPolicyInput] = None, + blocked_components: Optional[ComponentPolicyInput] = None, ): """ Parameters @@ -77,8 +91,23 @@ def __init__( the values are the tool objects (prebuilt OpenAI FunctionTool or callable). plugins: Optional list of deserialization plugins for PyAgentSpec. + allowed_components: + Optional iterable of Agent Spec component type names or Component classes allowed + to be loaded. If omitted, all component types are allowed unless blocked. + blocked_components: + Optional iterable of Agent Spec component type names or Component classes blocked + from loading. If omitted, stdio MCP transports are blocked by default. + Resolvable type names and Component classes also match subclasses; unresolved + type names match only the exact serialized component type. When allow and + block entries both match, the closest match in the component class hierarchy + wins; block entries win same-distance ties. """ - super().__init__(plugins=plugins, tool_registry=tool_registry) + super().__init__( + plugins=plugins, + tool_registry=tool_registry, + allowed_components=allowed_components, + blocked_components=blocked_components, + ) @property def agentspec_to_runtime_converter(self) -> AgentSpecToOpenAIConverter: diff --git a/pyagentspec/src/pyagentspec/component.py b/pyagentspec/src/pyagentspec/component.py index 08ebceb22..5a30e57cd 100644 --- a/pyagentspec/src/pyagentspec/component.py +++ b/pyagentspec/src/pyagentspec/component.py @@ -61,7 +61,7 @@ ) if TYPE_CHECKING: - from pyagentspec.serialization import ComponentDeserializationPlugin + from pyagentspec.serialization import ComponentDeserializationPlugin, ComponentPolicyInput from pyagentspec.serialization.serializationplugin import ComponentSerializationPlugin from pyagentspec.serialization.types import ( ComponentAsDictT, @@ -1060,6 +1060,8 @@ def from_yaml( yaml_content: str, *, plugins: Optional[List["ComponentDeserializationPlugin"]] = None, + allowed_components: Optional["ComponentPolicyInput"] = None, + blocked_components: Optional["ComponentPolicyInput"] = None, ) -> ComponentT: ... @overload @@ -1070,6 +1072,8 @@ def from_yaml( components_registry: Optional["ComponentsRegistryT"], *, plugins: Optional[List["ComponentDeserializationPlugin"]] = None, + allowed_components: Optional["ComponentPolicyInput"] = None, + blocked_components: Optional["ComponentPolicyInput"] = None, ) -> ComponentT: ... @classmethod @@ -1079,6 +1083,8 @@ def from_yaml( components_registry: Optional["ComponentsRegistryT"] = None, *, plugins: Optional[List["ComponentDeserializationPlugin"]] = None, + allowed_components: Optional["ComponentPolicyInput"] = None, + blocked_components: Optional["ComponentPolicyInput"] = None, ) -> ComponentT: """ Load a component and its sub-components from YAML. @@ -1092,6 +1098,18 @@ def from_yaml( main component. plugins: List of plugins to deserialize additional components. + allowed_components: + Optional iterable of component type names or Component classes allowed to be loaded. + If omitted, all component types are allowed unless blocked. + blocked_components: + Optional iterable of component type names or Component classes blocked from loading. + If omitted, this convenience deserialization API does not block any component + types by default. Adapter loaders block ``StdioTransport`` and its subclasses + by default. + Resolvable type names and Component classes also match subclasses; unresolved + type names match only the exact serialized component type. When allow and + block entries both match, the closest match in the component class hierarchy + wins; block entries win same-distance ties. Returns ------- @@ -1105,7 +1123,11 @@ def from_yaml( """ from pyagentspec.serialization.deserializer import AgentSpecDeserializer - deserialized = AgentSpecDeserializer(plugins=plugins).from_yaml( + deserialized = AgentSpecDeserializer( + plugins=plugins, + allowed_components=allowed_components, + blocked_components=blocked_components, + ).from_yaml( yaml_content, components_registry=components_registry, import_only_referenced_components=False, @@ -1119,6 +1141,8 @@ def from_json( json_content: str, *, plugins: Optional[List["ComponentDeserializationPlugin"]] = None, + allowed_components: Optional["ComponentPolicyInput"] = None, + blocked_components: Optional["ComponentPolicyInput"] = None, ) -> ComponentT: ... @overload @@ -1129,6 +1153,8 @@ def from_json( components_registry: Optional["ComponentsRegistryT"], *, plugins: Optional[List["ComponentDeserializationPlugin"]] = None, + allowed_components: Optional["ComponentPolicyInput"] = None, + blocked_components: Optional["ComponentPolicyInput"] = None, ) -> ComponentT: ... @classmethod @@ -1138,6 +1164,8 @@ def from_json( components_registry: Optional["ComponentsRegistryT"] = None, *, plugins: Optional[List["ComponentDeserializationPlugin"]] = None, + allowed_components: Optional["ComponentPolicyInput"] = None, + blocked_components: Optional["ComponentPolicyInput"] = None, ) -> ComponentT: """ Load a component and its sub-components from JSON. @@ -1151,6 +1179,18 @@ def from_json( main component. plugins: List of plugins to deserialize additional components. + allowed_components: + Optional iterable of component type names or Component classes allowed to be loaded. + If omitted, all component types are allowed unless blocked. + blocked_components: + Optional iterable of component type names or Component classes blocked from loading. + If omitted, this convenience deserialization API does not block any component + types by default. Adapter loaders block ``StdioTransport`` and its subclasses + by default. + Resolvable type names and Component classes also match subclasses; unresolved + type names match only the exact serialized component type. When allow and + block entries both match, the closest match in the component class hierarchy + wins; block entries win same-distance ties. Returns ------- @@ -1164,7 +1204,11 @@ def from_json( """ from pyagentspec.serialization.deserializer import AgentSpecDeserializer - deserialized = AgentSpecDeserializer(plugins=plugins).from_json( + deserialized = AgentSpecDeserializer( + plugins=plugins, + allowed_components=allowed_components, + blocked_components=blocked_components, + ).from_json( json_content, components_registry=components_registry, import_only_referenced_components=False, @@ -1178,6 +1222,8 @@ def from_dict( dict_content: "ComponentAsDictT", *, plugins: Optional[List["ComponentDeserializationPlugin"]] = None, + allowed_components: Optional["ComponentPolicyInput"] = None, + blocked_components: Optional["ComponentPolicyInput"] = None, ) -> ComponentT: ... @overload @@ -1188,6 +1234,8 @@ def from_dict( components_registry: Optional["ComponentsRegistryT"], *, plugins: Optional[List["ComponentDeserializationPlugin"]] = None, + allowed_components: Optional["ComponentPolicyInput"] = None, + blocked_components: Optional["ComponentPolicyInput"] = None, ) -> ComponentT: ... @classmethod @@ -1197,6 +1245,8 @@ def from_dict( components_registry: Optional["ComponentsRegistryT"] = None, *, plugins: Optional[List["ComponentDeserializationPlugin"]] = None, + allowed_components: Optional["ComponentPolicyInput"] = None, + blocked_components: Optional["ComponentPolicyInput"] = None, ) -> ComponentT: """ Load a component and its sub-components from dictionary. @@ -1210,6 +1260,18 @@ def from_dict( main component. plugins: List of plugins to deserialize additional components. + allowed_components: + Optional iterable of component type names or Component classes allowed to be loaded. + If omitted, all component types are allowed unless blocked. + blocked_components: + Optional iterable of component type names or Component classes blocked from loading. + If omitted, this convenience deserialization API does not block any component + types by default. Adapter loaders block ``StdioTransport`` and its subclasses + by default. + Resolvable type names and Component classes also match subclasses; unresolved + type names match only the exact serialized component type. When allow and + block entries both match, the closest match in the component class hierarchy + wins; block entries win same-distance ties. Returns ------- @@ -1258,7 +1320,11 @@ def from_dict( """ from pyagentspec.serialization.deserializer import AgentSpecDeserializer - deserialized = AgentSpecDeserializer(plugins=plugins).from_dict( + deserialized = AgentSpecDeserializer( + plugins=plugins, + allowed_components=allowed_components, + blocked_components=blocked_components, + ).from_dict( dict_content, components_registry=components_registry, import_only_referenced_components=False, diff --git a/pyagentspec/src/pyagentspec/serialization/__init__.py b/pyagentspec/src/pyagentspec/serialization/__init__.py index 85ced33fe..6d9ba1276 100644 --- a/pyagentspec/src/pyagentspec/serialization/__init__.py +++ b/pyagentspec/src/pyagentspec/serialization/__init__.py @@ -6,6 +6,7 @@ """This module and its submodules define the utilities helping with serialization/deserialization of Agent Spec configurations.""" # noqa: E501 +from .componentpolicy import ComponentLoadPolicy, ComponentPolicyInput from .deserializationcontext import DeserializationContext from .deserializationplugin import ComponentDeserializationPlugin from .deserializer import AgentSpecDeserializer @@ -17,6 +18,8 @@ "AgentSpecDeserializer", "AgentSpecSerializer", "DeserializationContext", + "ComponentLoadPolicy", + "ComponentPolicyInput", "ComponentSerializationPlugin", "SerializationContext", "ComponentDeserializationPlugin", diff --git a/pyagentspec/src/pyagentspec/serialization/componentpolicy.py b/pyagentspec/src/pyagentspec/serialization/componentpolicy.py new file mode 100644 index 000000000..2231f74ec --- /dev/null +++ b/pyagentspec/src/pyagentspec/serialization/componentpolicy.py @@ -0,0 +1,211 @@ +# 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. + +"""Component allow/block policy helpers used while loading Agent Spec configurations.""" + +from typing import Iterable, Optional, Set, Tuple, Type, Union, cast + +from pyagentspec.component import Component + +ComponentPolicyEntry = Union[str, Type[Component]] +ComponentPolicyInput = Union[ComponentPolicyEntry, Iterable[ComponentPolicyEntry]] +_NormalizedComponentPolicyInput = Tuple[ComponentPolicyEntry, ...] + + +def _normalize_component_types( + component_types: Optional[ComponentPolicyInput], +) -> Optional[_NormalizedComponentPolicyInput]: + """Normalizes component policy input to a reusable tuple. + + Accepts a single component type name, a Component class, or an iterable of either + form. Returns None when no policy was provided. + """ + if component_types is None: + return None + if isinstance(component_types, str): + component_types = [component_types] + elif isinstance(component_types, type) and issubclass(component_types, Component): + component_types = [component_types] + else: + try: + iter(component_types) + except TypeError: + raise TypeError( + "`allowed_components` and `blocked_components` entries must be component " + f"type names or Component classes, got {component_types!r}." + ) from None + + normalized_component_types: list[ComponentPolicyEntry] = [] + for component_type in component_types: + if isinstance(component_type, str): + normalized_component_types.append(component_type) + elif isinstance(component_type, type) and issubclass(component_type, Component): + normalized_component_types.append(component_type) + else: + raise TypeError( + "`allowed_components` and `blocked_components` entries must be component " + f"type names or Component classes, got {component_type!r}." + ) + return tuple(normalized_component_types) + + +def _split_component_types( + component_types: _NormalizedComponentPolicyInput, +) -> tuple[Set[str], Tuple[Type[Component], ...]]: + """Splits normalized entries into exact names and class hierarchy entries.""" + component_type_names: Set[str] = set() + component_classes: list[Type[Component]] = [] + for component_type in component_types: + if isinstance(component_type, str): + component_class = Component.get_class_from_name(component_type) + if component_class is None: + component_type_names.add(component_type) + else: + component_classes.append(component_class) + else: + component_classes.append(component_type) + return component_type_names, tuple(component_classes) + + +def _get_children_direct_from_field_value(field_value: object) -> list[Component]: + """Returns Component instances contained directly or through collection values.""" + if isinstance(field_value, Component): + return [field_value] + if isinstance(field_value, dict): + return [ + child + for inner_field_value in field_value.values() + for child in _get_children_direct_from_field_value(inner_field_value) + ] + if isinstance(field_value, (list, set, tuple)): + return [ + child + for inner_field_value in field_value + for child in _get_children_direct_from_field_value(inner_field_value) + ] + return [] + + +class ComponentLoadPolicy: + """Allow/block policy for component types loaded from Agent Spec configurations. + + Policy entries can be serialized component type names or ``Component`` classes. + Component type names that resolve to known ``Component`` classes match that class + and its subclasses, like class entries. Component type names that do not resolve + to known classes match only that exact serialized component type. When both allow + and block entries match a component, the closest match in the component class + hierarchy wins. Exact component type name matches and exact class matches have + distance 0; block entries win ties at the same hierarchy distance. + """ + + def __init__( + self, + allowed_components: Optional[ComponentPolicyInput] = None, + blocked_components: Optional[ComponentPolicyInput] = None, + ) -> None: + """ + Instantiate a component load policy. + + Parameters + ---------- + allowed_components: + Optional component type names or Component classes allowed to load. If omitted, + all component types are allowed unless a matching block entry applies. + Resolvable type names and Component classes also match subclasses; unresolved + type names match only the exact serialized component type. + blocked_components: + Optional component type names or Component classes blocked from loading. + Resolvable type names and Component classes also match subclasses; unresolved + type names match only the exact serialized component type. + """ + self.allowed_components = _normalize_component_types(allowed_components) + self.blocked_components = _normalize_component_types(blocked_components) or () + ( + self._allowed_component_type_names, + self._allowed_component_classes, + ) = _split_component_types(self.allowed_components or ()) + ( + self._blocked_component_type_names, + self._blocked_component_classes, + ) = _split_component_types(self.blocked_components) + + def validate_component_type(self, component_type: str) -> None: + """Raise if the component type is disallowed by the policy.""" + component_class = Component.get_class_from_name(component_type) + self._validate_component(component_type, component_class) + + def validate_component(self, component: Component) -> None: + """Raise if the component is disallowed by the policy.""" + self._validate_component( + cast(str, component.component_type), + component.__class__, + ) + + def _validate_component( + self, + component_type: str, + component_class: Optional[Type[Component]], + ) -> None: + blocked_match_distance = self._get_best_policy_match_distance( + component_type, + component_class, + self._blocked_component_type_names, + self._blocked_component_classes, + ) + allowed_match_distance = self._get_best_policy_match_distance( + component_type, + component_class, + self._allowed_component_type_names, + self._allowed_component_classes, + ) + + if blocked_match_distance is not None and ( + allowed_match_distance is None or blocked_match_distance <= allowed_match_distance + ): + raise ValueError( + f"Loading Agent Spec component type `{component_type}` is in the block list." + ) + if self.allowed_components is not None and allowed_match_distance is None: + raise ValueError( + f"Loading Agent Spec component type `{component_type}` is not in the allow list." + ) + + @staticmethod + def _get_best_policy_match_distance( + component_type: str, + component_class: Optional[Type[Component]], + component_type_names: Set[str], + component_classes: Tuple[Type[Component], ...], + ) -> Optional[int]: + """Return the distance of the most specific matching policy entry.""" + match_distances: list[int] = [] + if component_type in component_type_names: + match_distances.append(0) + if component_class is not None: + # mro() is Python's built-in class method for method resolution order. + # component_mro is the ordered list of the component class and its base + # classes, so its index gives the inheritance distance used to choose + # the most specific policy. + component_mro = component_class.mro() + for policy_class in component_classes: + if issubclass(component_class, policy_class): + match_distances.append(component_mro.index(policy_class)) + return min(match_distances) if match_distances else None + + def validate_component_tree(self, component: Component) -> None: + """Validate a constructed component and all nested child components.""" + components_to_check = [component] + visited_component_ids: set[int] = set() + while components_to_check: + current_component = components_to_check.pop() + if id(current_component) in visited_component_ids: + continue + visited_component_ids.add(id(current_component)) + + self.validate_component(current_component) + for field_name in current_component.__class__.model_fields: + field_value = getattr(current_component, field_name, None) + components_to_check.extend(_get_children_direct_from_field_value(field_value)) diff --git a/pyagentspec/src/pyagentspec/serialization/deserializationcontext.py b/pyagentspec/src/pyagentspec/serialization/deserializationcontext.py index 108e29c56..8ca5dc2a0 100644 --- a/pyagentspec/src/pyagentspec/serialization/deserializationcontext.py +++ b/pyagentspec/src/pyagentspec/serialization/deserializationcontext.py @@ -31,6 +31,7 @@ from pyagentspec.component import Component from pyagentspec.property import Property +from pyagentspec.serialization.componentpolicy import ComponentLoadPolicy, ComponentPolicyInput from pyagentspec.serialization.types import ( BaseModelAsDictT, ComponentAsDictT, @@ -90,9 +91,15 @@ def __init__( self, plugins: Optional[List["ComponentDeserializationPlugin"]] = None, partial_model_build: bool = False, + allowed_components: Optional[ComponentPolicyInput] = None, + blocked_components: Optional[ComponentPolicyInput] = None, ) -> None: self.plugins = list(plugins) if plugins is not None else [] + self.component_load_policy = ComponentLoadPolicy( + allowed_components=allowed_components, + blocked_components=blocked_components, + ) # Add the deserialization plugin that loads all builtin Agent Spec components # All other components must be loaded by custom components @@ -239,6 +246,8 @@ def _load_reference( f"'{annotation.__name__}', got '{loaded_reference.__class__.__name__}'. " "If using a component registry, make sure that the components are correct." ) + if isinstance(loaded_reference, Component): + self.component_load_policy.validate_component(loaded_reference) return loaded_reference, validation_errors def load_field( @@ -444,7 +453,10 @@ def _load_pydantic_model_from_dict( PyAgentSpecErrorDetails(**error_details) # type: ignore for error_details in e.errors() ] - return model_class.model_construct(**resolved_content), all_validation_errors + return ( + model_class.model_construct(**resolved_content), + all_validation_errors, + ) # If we are not ok with partial build, we forward the exception raise e @@ -487,6 +499,7 @@ def _load_component_with_plugin( ) component.min_agentspec_version = agentspec_version + self.component_load_policy.validate_component(component) return component, validation_errors def _load_component_from_dict( @@ -511,6 +524,7 @@ def _load_component_from_dict( return self._load_reference(content["$component_ref"], annotation) component_type = self.get_component_type(content) + self.component_load_policy.validate_component_type(component_type) # get the plugin to use for loading if there is one plugin = self.component_types_to_plugins.get(component_type, None) @@ -549,7 +563,8 @@ def load_config_dict( else: self._agentspec_version = AgentSpecVersionEnum( value=content.get( - AGENTSPEC_VERSION_FIELD_NAME, content.get(_LEGACY_VERSION_FIELD_NAME) + AGENTSPEC_VERSION_FIELD_NAME, + content.get(_LEGACY_VERSION_FIELD_NAME), ) ) diff --git a/pyagentspec/src/pyagentspec/serialization/deserializer.py b/pyagentspec/src/pyagentspec/serialization/deserializer.py index 29bbe5ae5..ac61e2732 100644 --- a/pyagentspec/src/pyagentspec/serialization/deserializer.py +++ b/pyagentspec/src/pyagentspec/serialization/deserializer.py @@ -17,6 +17,7 @@ import yaml from pyagentspec.component import Component +from pyagentspec.serialization.componentpolicy import ComponentLoadPolicy, ComponentPolicyInput from pyagentspec.serialization.deserializationcontext import _DeserializationContextImpl from pyagentspec.serialization.deserializationplugin import ComponentDeserializationPlugin from pyagentspec.serialization.types import ComponentAsDictT, ComponentsRegistryT @@ -24,18 +25,62 @@ class AgentSpecDeserializer: - """Provides methods to deserialize Agent Spec Components.""" + """Provides methods to deserialize Agent Spec Components. - def __init__(self, plugins: Optional[List[ComponentDeserializationPlugin]] = None) -> None: + ``allowed_components`` and ``blocked_components`` can be used to constrain + which Agent Spec component types load. Resolvable type names and Component + classes match subclasses; unresolved type names match only the exact serialized + component type. When allow and block entries both match, the closest match + in the component class hierarchy wins; block entries win same-distance ties. + + This low-level deserializer does not block any component types by default. + Adapter loaders block ``StdioTransport`` and its subclasses by default. + """ + + def __init__( + self, + plugins: Optional[List[ComponentDeserializationPlugin]] = None, + allowed_components: Optional[ComponentPolicyInput] = None, + blocked_components: Optional[ComponentPolicyInput] = None, + ) -> None: """ Instantiate an Agent Spec Deserializer. plugins: List of plugins to serialize additional components. + allowed_components: + Optional iterable of component type names or Component classes allowed to be loaded. + If omitted, all component types are allowed unless blocked. + blocked_components: + Optional iterable of component type names or Component classes blocked from loading. + If omitted, this deserializer does not block any component types by default. + Adapter loaders block ``StdioTransport`` and its subclasses by default. + Resolvable type names and Component classes also match subclasses; unresolved + type names match only the exact serialized component type. When allow and + block entries both match, the closest match in the component class hierarchy + wins; block entries win same-distance ties. """ - # for early failure when using incorrect plugins - _DeserializationContextImpl(plugins=plugins) + component_load_policy = ComponentLoadPolicy( + allowed_components=allowed_components, + blocked_components=blocked_components, + ) self.plugins = plugins + self.allowed_components = component_load_policy.allowed_components + self.blocked_components = component_load_policy.blocked_components + + # for early failure when using incorrect plugins + self._get_new_deserialization_context(partial_model_build=False) + + def _get_new_deserialization_context( + self, + partial_model_build: bool, + ) -> _DeserializationContextImpl: + return _DeserializationContextImpl( + plugins=self.plugins, + partial_model_build=partial_model_build, + allowed_components=self.allowed_components, + blocked_components=self.blocked_components, + ) @overload def from_yaml(self, yaml_content: str) -> Component: @@ -389,8 +434,8 @@ def from_dict( "valid Agent Spec Component. To load a disaggregated configuration, " "make sure that `import_only_referenced_components` is `True`" ) - main_deserialization_context = _DeserializationContextImpl( - plugins=self.plugins, partial_model_build=False + main_deserialization_context = self._get_new_deserialization_context( + partial_model_build=False ) return main_deserialization_context.load_config_dict( dict_content, components_registry=components_registry @@ -412,8 +457,8 @@ def from_dict( ) referenced_components: Dict[str, Component] = {} for component_id, component_as_dict in dict_content["$referenced_components"].items(): - disag_deserialization_context = _DeserializationContextImpl( - plugins=self.plugins, partial_model_build=False + disag_deserialization_context = self._get_new_deserialization_context( + partial_model_build=False ) referenced_components[component_id] = disag_deserialization_context.load_config_dict( component_as_dict, components_registry=components_registry @@ -497,8 +542,8 @@ def from_partial_dict( "valid Agent Spec Component. To load a disaggregated configuration, " "make sure that `import_only_referenced_components` is `True`" ) - main_deserialization_context = _DeserializationContextImpl( - plugins=self.plugins, partial_model_build=True + main_deserialization_context = self._get_new_deserialization_context( + partial_model_build=True ) return main_deserialization_context.load_config_dict( dict_content, components_registry=components_registry @@ -521,8 +566,8 @@ def from_partial_dict( referenced_components: Dict[str, Component] = {} all_validation_errors: List[PyAgentSpecErrorDetails] = [] for component_id, component_as_dict in dict_content["$referenced_components"].items(): - disag_deserialization_context = _DeserializationContextImpl( - plugins=self.plugins, partial_model_build=True + disag_deserialization_context = self._get_new_deserialization_context( + partial_model_build=True ) referenced_components[component_id], validation_errors = ( disag_deserialization_context.load_config_dict( @@ -535,7 +580,8 @@ def from_partial_dict( @staticmethod def _check_missing_component_references( - dict_content: ComponentAsDictT, components_registry: Optional[ComponentsRegistryT] = None + dict_content: ComponentAsDictT, + components_registry: Optional[ComponentsRegistryT] = None, ) -> None: """ Check that all references that are part of the dict_content are either defined in @@ -557,7 +603,9 @@ def _check_missing_component_references( ) @staticmethod - def _recursively_get_all_references(value: Dict[str, Any]) -> Tuple[Set[str], Set[str]]: + def _recursively_get_all_references( + value: Dict[str, Any], + ) -> Tuple[Set[str], Set[str]]: """ This method recursively traverses the content of `value` and collects all the references used that appear as `{"$component_ref": "some_component_id"}` and all the references that diff --git a/pyagentspec/tests/adapters/conftest.py b/pyagentspec/tests/adapters/conftest.py index 71d282b69..8f795d8d2 100644 --- a/pyagentspec/tests/adapters/conftest.py +++ b/pyagentspec/tests/adapters/conftest.py @@ -1,4 +1,4 @@ -# Copyright © 2025 Oracle and/or its affiliates. +# 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 @@ -27,6 +27,11 @@ ) SKIP_LLM_TESTS_ENV_VAR = "SKIP_LLM_TESTS" +DUMMY_LLM_URL_BY_ENV = { + "LLAMA_API_URL": "http://dummy-llm.local", + "LLAMA70BV33_API_URL": "http://dummy-llm70.local", + "OSS_API_URL": "http://dummy-llm-oss.local", +} @pytest.fixture(autouse=True) @@ -51,15 +56,32 @@ def should_skip_llm_test() -> bool: return os.environ.get(SKIP_LLM_TESTS_ENV_VAR) == "1" +def _seed_dummy_llm_env_for_skip() -> None: + if not should_skip_llm_test(): + return + for env_name, dummy_url in DUMMY_LLM_URL_BY_ENV.items(): + os.environ.setdefault(env_name, dummy_url) + + +def _get_required_llm_url(env_name: str) -> str: + value = os.environ.get(env_name) + if value: + return value + if should_skip_llm_test(): + return DUMMY_LLM_URL_BY_ENV[env_name] + raise Exception(f"{env_name} is not set in the environment") + + +_seed_dummy_llm_env_for_skip() + + @pytest.fixture(scope="session", autouse=True) def _seed_llm_env_for_skip(): """ When SKIP_LLM_TESTS=1, seed harmless dummy endpoints so imports/deserialization never crash on missing env vars. """ - if should_skip_llm_test(): - os.environ.setdefault("LLAMA_API_URL", "http://dummy-llm.local") - os.environ.setdefault("LLAMA70BV33_API_URL", "http://dummy-llm70.local") + _seed_dummy_llm_env_for_skip() yield @@ -105,28 +127,9 @@ def json_server(json_server_port: int): terminate_process_tree(process, timeout=5.0) -llama_api_url = os.environ.get("LLAMA_API_URL") -if not llama_api_url: - if should_skip_llm_test(): - llama_api_url = "http://dummy-llm.local" - else: - raise Exception("LLAMA_API_URL is not set in the environment") - - -llama70bv33_api_url = os.environ.get("LLAMA70BV33_API_URL") -if not llama70bv33_api_url: - if should_skip_llm_test(): - llama70bv33_api_url = "http://dummy-llm70.local" - else: - raise Exception("LLAMA70BV33_API_URL is not set in the environment") - - -oss_api_url = os.environ.get("OSS_API_URL") -if not oss_api_url: - if should_skip_llm_test(): - oss_api_url = "http://dummy-llm-oss.local" - else: - raise Exception("OSS_API_URL is not set in the environment") +llama_api_url = _get_required_llm_url("LLAMA_API_URL") +llama70bv33_api_url = _get_required_llm_url("LLAMA70BV33_API_URL") +oss_api_url = _get_required_llm_url("OSS_API_URL") def _replace_config_placeholders(yaml_config: str, json_server_url: str) -> str: @@ -180,9 +183,9 @@ def quickstart_agent_json() -> str: ) agentspec_llm_config = OpenAiCompatibleConfig( - name="llama-3.3-70b-instruct", - model_id="/storage/models/Llama-3.3-70B-Instruct", - url=os.environ["LLAMA70BV33_API_URL"], + name="gpt-oss-120b", + model_id="openai/gpt-oss-120b", + url=_get_required_llm_url("OSS_API_URL"), ) agent = Agent( diff --git a/pyagentspec/tests/adapters/langgraph/configs/ancestry_agent_with_client_tool.yaml b/pyagentspec/tests/adapters/langgraph/configs/ancestry_agent_with_client_tool.yaml index be9def57d..fa351c330 100644 --- a/pyagentspec/tests/adapters/langgraph/configs/ancestry_agent_with_client_tool.yaml +++ b/pyagentspec/tests/adapters/langgraph/configs/ancestry_agent_with_client_tool.yaml @@ -16,9 +16,9 @@ llm_config: id: d60ddfad-e086-498b-97da-ad35296a6ec4 metadata: __metadata_info__: {} - model_id: /storage/models/Llama-3.3-70B-Instruct - name: Llama-3.3-70B-Instruct - url: [[LLAMA70BV33_API_URL]] + model_id: openai/gpt-oss-120b + name: gpt-oss-120b + url: [[OSS_API_URL]] metadata: __metadata_info__: {} name: agent_7ced9454 diff --git a/pyagentspec/tests/adapters/langgraph/configs/haiku_without_a_flow.json b/pyagentspec/tests/adapters/langgraph/configs/haiku_without_a_flow.json index 3bf8f8672..1526b6019 100644 --- a/pyagentspec/tests/adapters/langgraph/configs/haiku_without_a_flow.json +++ b/pyagentspec/tests/adapters/langgraph/configs/haiku_without_a_flow.json @@ -105,12 +105,12 @@ "llm_config": { "component_type": "OpenAiCompatibleConfig", "id": "81c0e83e-3585-44b5-8d47-41dd6a0783ee", - "name": "Llama-3.3-70B-Instruct", + "name": "gpt-oss-120b", "description": null, "metadata": {}, "default_generation_parameters": null, - "url": "[[LLAMA70BV33_API_URL]]", - "model_id": "/storage/models/Llama-3.3-70B-Instruct" + "url": "[[OSS_API_URL]]", + "model_id": "openai/gpt-oss-120b" }, "prompt_template": "Write a haiku about Oracle." }, diff --git a/pyagentspec/tests/adapters/langgraph/configs/swarm_calculator.yaml b/pyagentspec/tests/adapters/langgraph/configs/swarm_calculator.yaml index 8d43771c2..14c133d1d 100644 --- a/pyagentspec/tests/adapters/langgraph/configs/swarm_calculator.yaml +++ b/pyagentspec/tests/adapters/langgraph/configs/swarm_calculator.yaml @@ -24,7 +24,11 @@ $referenced_components: outputs: [] llm_config: $component_ref: 8055058e-1c45-4d9d-9378-7a45e2ba10ee - system_prompt: You are able to do sums. In case of multiplications, ask the `multiply_agent`. + system_prompt: >- + You handle addition requests. For every addition calculation, you must call the + `add` tool and must not calculate the result yourself. After the `add` tool + returns a result, answer with that result and do not call `add` again. In case + of multiplications, ask the `multiply_agent`. tools: - component_type: ServerTool id: d089e4c2-21d2-41b8-ba59-95f415c66771 @@ -45,12 +49,12 @@ $referenced_components: 8055058e-1c45-4d9d-9378-7a45e2ba10ee: component_type: OpenAiCompatibleConfig id: 8055058e-1c45-4d9d-9378-7a45e2ba10ee - name: Llama-3.3-70B-Instruct + name: gpt-oss-120b description: null metadata: {} default_generation_parameters: null - url: '[[LLAMA70BV33_API_URL]]' - model_id: /storage/models/Llama-3.3-70B-Instruct + url: '[[OSS_API_URL]]' + model_id: openai/gpt-oss-120b api_type: chat_completions api_key: null de8f0d0e-57ca-4998-9520-766cdba6ab85: @@ -63,7 +67,11 @@ $referenced_components: outputs: [] llm_config: $component_ref: 8055058e-1c45-4d9d-9378-7a45e2ba10ee - system_prompt: You are able to do multiplications. In case of sums, ask the `sum_agent`. + system_prompt: >- + You handle multiplication requests. For every multiplication calculation, you + must call the `multiply` tool and must not calculate the result yourself. + After the `multiply` tool returns a result, answer with that result and do not + call `multiply` again. In case of sums, ask the `sum_agent`. tools: - component_type: ServerTool id: 0ebddedd-c4ac-4b1b-b8e5-f977b9499106 diff --git a/pyagentspec/tests/adapters/langgraph/configs/weather_agent_client_tool.yaml b/pyagentspec/tests/adapters/langgraph/configs/weather_agent_client_tool.yaml index 8091b092a..9594b90b8 100644 --- a/pyagentspec/tests/adapters/langgraph/configs/weather_agent_client_tool.yaml +++ b/pyagentspec/tests/adapters/langgraph/configs/weather_agent_client_tool.yaml @@ -15,9 +15,9 @@ llm_config: id: 01932e0d-5115-4848-a529-f1f1f7a5e866 metadata: __metadata_info__: {} - model_id: /storage/models/Llama-3.3-70B-Instruct - name: Llama-3.3-70B-Instruct - url: [[LLAMA70BV33_API_URL]] + model_id: openai/gpt-oss-120b + name: gpt-oss-120b + url: [[OSS_API_URL]] metadata: __metadata_info__: {} name: agent_75dd14fc diff --git a/pyagentspec/tests/adapters/langgraph/configs/weather_agent_remote_tool.yaml b/pyagentspec/tests/adapters/langgraph/configs/weather_agent_remote_tool.yaml index 8a9d83a41..c87e43661 100644 --- a/pyagentspec/tests/adapters/langgraph/configs/weather_agent_remote_tool.yaml +++ b/pyagentspec/tests/adapters/langgraph/configs/weather_agent_remote_tool.yaml @@ -15,9 +15,9 @@ llm_config: id: fde915e6-4f21-4e5a-a0a5-d6121c2655f6 metadata: __metadata_info__: {} - model_id: /storage/models/Llama-3.3-70B-Instruct - name: Llama-3.3-70B-Instruct - url: [[LLAMA70BV33_API_URL]] + model_id: openai/gpt-oss-120b + name: gpt-oss-120b + url: [[OSS_API_URL]] metadata: __metadata_info__: {} name: agent_87040c07 diff --git a/pyagentspec/tests/adapters/langgraph/configs/weather_agent_server_tool.yaml b/pyagentspec/tests/adapters/langgraph/configs/weather_agent_server_tool.yaml index 00a2e1bcb..846a44c07 100644 --- a/pyagentspec/tests/adapters/langgraph/configs/weather_agent_server_tool.yaml +++ b/pyagentspec/tests/adapters/langgraph/configs/weather_agent_server_tool.yaml @@ -15,9 +15,9 @@ llm_config: id: fde915e6-4f21-4e5a-a0a5-d6121c2655f6 metadata: __metadata_info__: {} - model_id: /storage/models/Llama-3.3-70B-Instruct - name: Llama-3.3-70B-Instruct - url: [[LLAMA70BV33_API_URL]] + model_id: openai/gpt-oss-120b + name: gpt-oss-120b + url: [[OSS_API_URL]] metadata: __metadata_info__: {} name: agent_87040c07 diff --git a/pyagentspec/tests/adapters/langgraph/configs/weather_agent_with_outputs.yaml b/pyagentspec/tests/adapters/langgraph/configs/weather_agent_with_outputs.yaml index 63b7a1594..1b79a12ea 100644 --- a/pyagentspec/tests/adapters/langgraph/configs/weather_agent_with_outputs.yaml +++ b/pyagentspec/tests/adapters/langgraph/configs/weather_agent_with_outputs.yaml @@ -15,9 +15,9 @@ llm_config: id: 1db0adce-3418-4c8e-b081-aa299e8df3d9 metadata: __metadata_info__: {} - model_id: /storage/models/Llama-3.3-70B-Instruct - name: Llama-3.3-70B-Instruct - url: [[LLAMA70BV33_API_URL]] + model_id: openai/gpt-oss-120b + name: gpt-oss-120b + url: [[OSS_API_URL]] metadata: __metadata_info__: {} name: agent_2f8b3363 diff --git a/pyagentspec/tests/adapters/langgraph/conftest.py b/pyagentspec/tests/adapters/langgraph/conftest.py index c537b3f41..cc697e0b4 100644 --- a/pyagentspec/tests/adapters/langgraph/conftest.py +++ b/pyagentspec/tests/adapters/langgraph/conftest.py @@ -34,6 +34,20 @@ def get_weather(city: str) -> str: return f"The weather in {city} is sunny." +@pytest.fixture() +def disable_parallel_tool_calls(monkeypatch: pytest.MonkeyPatch) -> None: + from langchain_openai.chat_models import ChatOpenAI + + original_init = ChatOpenAI.__init__ + + def init_without_parallel_tool_calls(self: ChatOpenAI, *args: Any, **kwargs: Any) -> None: + model_kwargs = kwargs.setdefault("model_kwargs", {}) + model_kwargs.setdefault("parallel_tool_calls", False) + original_init(self, *args, **kwargs) + + monkeypatch.setattr(ChatOpenAI, "__init__", init_without_parallel_tool_calls) + + CONFIGS = Path(__file__).parent / "configs" diff --git a/pyagentspec/tests/adapters/langgraph/flows/test_llmnode.py b/pyagentspec/tests/adapters/langgraph/flows/test_llmnode.py index 0733eb633..0a63ccd01 100644 --- a/pyagentspec/tests/adapters/langgraph/flows/test_llmnode.py +++ b/pyagentspec/tests/adapters/langgraph/flows/test_llmnode.py @@ -11,7 +11,7 @@ from pyagentspec.flows.edges import ControlFlowEdge, DataFlowEdge from pyagentspec.flows.flow import Flow from pyagentspec.flows.nodes import EndNode, LlmNode, StartNode -from pyagentspec.llms import VllmConfig +from pyagentspec.llms import LlmGenerationConfig, VllmConfig from pyagentspec.property import StringProperty @@ -23,13 +23,14 @@ def llm_flow() -> Flow: car_property = StringProperty(title="car") llm_config = VllmConfig( name="llm_config", - model_id="/storage/models/Llama-3.3-70B-Instruct", - url=os.environ.get("LLAMA70BV33_API_URL"), + model_id="openai/gpt-oss-120b", + url=os.environ.get("OSS_API_URL"), + default_generation_parameters=LlmGenerationConfig(temperature=0, max_tokens=512), ) llm_node = LlmNode( name="llm_node", llm_config=llm_config, - prompt_template="What is the fastest {{nationality}} car?", + prompt_template="Answer in one short sentence. What is the fastest {{nationality}} car?", inputs=[nationality_property], outputs=[car_property], ) diff --git a/pyagentspec/tests/adapters/langgraph/llms/test_llm_conversion.py b/pyagentspec/tests/adapters/langgraph/llms/test_llm_conversion.py index 686d4cadf..c932e3269 100644 --- a/pyagentspec/tests/adapters/langgraph/llms/test_llm_conversion.py +++ b/pyagentspec/tests/adapters/langgraph/llms/test_llm_conversion.py @@ -100,9 +100,9 @@ def test_ollama_conversion_maps_generation_config_names(default_generation_param def test_invoke_vllm_model(default_generation_parameters, monkeypatch): agentspec_llm = OpenAiCompatibleConfig( - name="llama33", - model_id="/storage/models/Llama-3.3-70B-Instruct", - url=os.getenv("LLAMA70BV33_API_URL"), + name="gpt-oss-120b", + model_id="openai/gpt-oss-120b", + url=os.getenv("OSS_API_URL"), default_generation_parameters=default_generation_parameters, ) monkeypatch.setenv("OPENAI_API_KEY", os.getenv("OPENAI_API_KEY", "not needed")) diff --git a/pyagentspec/tests/adapters/langgraph/mcp/conftest.py b/pyagentspec/tests/adapters/langgraph/mcp/conftest.py index 5e152bf46..1fd0f1f6a 100644 --- a/pyagentspec/tests/adapters/langgraph/mcp/conftest.py +++ b/pyagentspec/tests/adapters/langgraph/mcp/conftest.py @@ -15,6 +15,6 @@ def big_llama(): return VllmConfig( name="TEST MODEL", - model_id="/storage/models/Llama-3.3-70B-Instruct", - url=os.environ.get("LLAMA70BV33_API_URL"), + model_id="openai/gpt-oss-120b", + url=os.environ.get("OSS_API_URL"), ) diff --git a/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py b/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py index 1eabe18fa..cba2e2588 100644 --- a/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py +++ b/pyagentspec/tests/adapters/langgraph/mcp/test_mcp.py @@ -135,7 +135,10 @@ def test_mcp_toolbox_exposes_proper_tools(loaded_langgraph_agent): @pytest.mark.anyio -async def test_can_run_imported_agent_with_mcp_tools(agentspec_agent_with_mcp_toolbox): +async def test_can_run_imported_agent_with_mcp_tools( + agentspec_agent_with_mcp_toolbox, + disable_parallel_tool_calls, +): langgraph_agent = AgentSpecLoader().load_component(agentspec_agent_with_mcp_toolbox) response = await langgraph_agent.ainvoke( @@ -151,7 +154,10 @@ async def test_can_run_imported_agent_with_mcp_tools(agentspec_agent_with_mcp_to ) output = "" for m in response["messages"][1:]: - output += m.content + if isinstance(m.content, str): + output += m.content + elif isinstance(m.content, list): + output += "".join(str(item) for item in m.content) assert output # should not be empty diff --git a/pyagentspec/tests/adapters/langgraph/test_agentspec_to_langraph.py b/pyagentspec/tests/adapters/langgraph/test_agentspec_to_langraph.py index 595a5a4a6..878e183e7 100644 --- a/pyagentspec/tests/adapters/langgraph/test_agentspec_to_langraph.py +++ b/pyagentspec/tests/adapters/langgraph/test_agentspec_to_langraph.py @@ -87,7 +87,10 @@ def test_client_tool_with_agent(weather_agent_client_tool_yaml: str) -> None: assert all(x in last_message.content.lower() for x in ("agadir", "sunny")) -def test_client_tool_with_two_inputs(ancestry_agent_with_client_tool_yaml: str) -> None: +def test_client_tool_with_two_inputs( + ancestry_agent_with_client_tool_yaml: str, + disable_parallel_tool_calls: None, +) -> None: from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.memory import MemorySaver from langgraph.types import Command @@ -208,7 +211,10 @@ def test_execute_weather_agent_with_server_tool_with_openaicompatible_llm( assert isinstance(tool_call_message, ToolMessage) -def test_execute_swarm(swarm_calculator_yaml: str) -> None: +def test_execute_swarm( + swarm_calculator_yaml: str, + disable_parallel_tool_calls: None, +) -> None: from langchain_core.runnables import RunnableConfig from pyagentspec.adapters.langgraph import AgentSpecLoader diff --git a/pyagentspec/tests/test_agentspecloader_stdio.py b/pyagentspec/tests/test_agentspecloader_stdio.py new file mode 100644 index 000000000..763b52ed2 --- /dev/null +++ b/pyagentspec/tests/test_agentspecloader_stdio.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, Dict, Optional + +import pytest + +from pyagentspec.adapters._agentspecloader import AdapterAgnosticAgentSpecLoader +from pyagentspec.component import Component +from pyagentspec.flows.edges import ControlFlowEdge +from pyagentspec.flows.flow import Flow +from pyagentspec.flows.nodes import EndNode, StartNode +from pyagentspec.mcp import MCPTool, StdioTransport +from pyagentspec.serialization import AgentSpecSerializer + + +class _IdentityConverter: + def convert( + self, + agentspec_component: Component, + tool_registry: Dict[str, Any], + referenced_objects: Optional[Dict[str, Component]] = None, + **kwargs: Any, + ) -> Component: + return agentspec_component + + +class _IdentityLoader(AdapterAgnosticAgentSpecLoader): + @property + def agentspec_to_runtime_converter(self) -> _IdentityConverter: + return _IdentityConverter() + + @property + def runtime_to_agentspec_converter(self) -> _IdentityConverter: + return _IdentityConverter() + + +class CustomStdioTransport(StdioTransport): + pass + + +def _make_stdio_mcp_tool() -> MCPTool: + return MCPTool( + name="stdio_tool", + client_transport=StdioTransport( + name="stdio_transport", + command="python3", + args=["server.py"], + env={"EXAMPLE": "1"}, + cwd=".", + ), + ) + + +def test_adapter_agnostic_loader_blocks_stdio_transport_from_yaml_by_default() -> None: + with pytest.raises(ValueError, match="StdioTransport.*in the block list"): + _IdentityLoader().load_yaml(AgentSpecSerializer().to_yaml(_make_stdio_mcp_tool())) + + +def test_adapter_agnostic_loader_allows_stdio_transport_from_yaml_when_unblocked() -> None: + loaded_tool = _IdentityLoader(blocked_components=[]).load_yaml( + AgentSpecSerializer().to_yaml(_make_stdio_mcp_tool()) + ) + assert isinstance(loaded_tool, MCPTool) + assert isinstance(loaded_tool.client_transport, StdioTransport) + assert loaded_tool.client_transport.command == "python3" + + +def test_adapter_agnostic_loader_blocks_stdio_transport_from_component_by_default() -> None: + with pytest.raises(ValueError, match="StdioTransport.*in the block list"): + _IdentityLoader().load_component(_make_stdio_mcp_tool()) + + +def test_adapter_agnostic_loader_blocks_stdio_transport_subclasses_by_default() -> None: + tool = MCPTool( + name="custom_stdio_tool", + client_transport=CustomStdioTransport( + name="custom_stdio_transport", + command="python3", + ), + ) + + with pytest.raises(ValueError, match="CustomStdioTransport.*in the block list"): + _IdentityLoader().load_component(tool) + + +def test_langgraph_loader_blocks_stdio_transport_from_component_by_default() -> None: + from pyagentspec.adapters.langgraph import AgentSpecLoader + + with pytest.raises(ValueError, match="StdioTransport.*in the block list"): + AgentSpecLoader().load_component(_make_stdio_mcp_tool()) + + +def test_component_convenience_loaders_respect_blocked_components() -> None: + tool = _make_stdio_mcp_tool() + serializer = AgentSpecSerializer() + + with pytest.raises(ValueError, match="StdioTransport.*in the block list"): + Component.from_yaml(serializer.to_yaml(tool), blocked_components=[StdioTransport]) + with pytest.raises(ValueError, match="StdioTransport.*in the block list"): + Component.from_json(serializer.to_json(tool), blocked_components=[StdioTransport]) + with pytest.raises(ValueError, match="StdioTransport.*in the block list"): + Component.from_dict(serializer.to_dict(tool), blocked_components=[StdioTransport]) + + +def test_adapter_agnostic_loader_respects_allowed_components() -> None: + with pytest.raises(ValueError, match="MCPTool.*not in the allow list"): + loader = _IdentityLoader( + allowed_components=["StdioTransport"], + blocked_components=[], + ) + loader.load_component(_make_stdio_mcp_tool()) + + +def test_openai_flow_codegen_load_component_validates_component_policy() -> None: + from pyagentspec.adapters.openaiagents import AgentSpecLoader + + start_node = StartNode(name="start") + end_node = EndNode(name="end") + flow = Flow( + name="blocked_flow", + start_node=start_node, + nodes=[start_node, end_node], + control_flow_connections=[ + ControlFlowEdge(name="start_to_end", from_node=start_node, to_node=end_node) + ], + ) + + with pytest.raises(ValueError, match="Flow.*in the block list"): + AgentSpecLoader(blocked_components=["Flow"]).load_component(flow) diff --git a/pyagentspec/tests/test_componentpolicy.py b/pyagentspec/tests/test_componentpolicy.py new file mode 100644 index 000000000..d8e0a8126 --- /dev/null +++ b/pyagentspec/tests/test_componentpolicy.py @@ -0,0 +1,239 @@ +# 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, Optional + +import pytest + +from pyagentspec.component import Component +from pyagentspec.llms import LlmConfig, OpenAiConfig +from pyagentspec.mcp import MCPTool, StdioTransport +from pyagentspec.mcp.clienttransport import ClientTransport +from pyagentspec.serialization import ( + AgentSpecDeserializer, + AgentSpecSerializer, + ComponentDeserializationPlugin, + DeserializationContext, +) +from pyagentspec.serialization.componentpolicy import ( + ComponentLoadPolicy, + ComponentPolicyInput, +) +from pyagentspec.serialization.pydanticdeserializationplugin import ( + PydanticComponentDeserializationPlugin, +) +from pyagentspec.serialization.pydanticserializationplugin import ( + PydanticComponentSerializationPlugin, +) + + +class PolicyExtensionComponent(Component): + value: str = "" + + +class PolicyExtensionChildComponent(PolicyExtensionComponent): + child_value: str = "" + + +class PolicyPluginContainer(Component): + child: Component + + +class PolicyBypassDeserializationPlugin(ComponentDeserializationPlugin): + @property + def plugin_name(self) -> str: + return "PolicyBypassDeserializationPlugin" + + @property + def plugin_version(self) -> str: + return "0.0.0" + + def supported_component_types(self) -> list[str]: + return [PolicyPluginContainer.__name__] + + def deserialize( + self, + serialized_component: dict[str, Any], + deserialization_context: DeserializationContext, + ) -> Component: + return PolicyPluginContainer( + name=serialized_component["name"], + child=StdioTransport(name="plugin_stdio_transport", command="python3"), + ) + + +def _make_stdio_mcp_tool() -> MCPTool: + return MCPTool( + name="stdio_tool", + client_transport=StdioTransport( + name="stdio_transport", + command="python3", + args=["server.py"], + env={"EXAMPLE": "1"}, + cwd=".", + ), + ) + + +@pytest.mark.parametrize( + ("allowed_components", "blocked_components", "error_match"), + [ + ([MCPTool, "StdioTransport"], [], None), + ( + [MCPTool, "StdioTransport"], + [StdioTransport], + "StdioTransport.*in the block list", + ), + ([MCPTool], [], "StdioTransport.*not in the allow list"), + (["StdioTransport"], [], "MCPTool.*not in the allow list"), + ], +) +def test_component_load_policy_handles_allow_block_combinations_with_mixed_entry_types( + allowed_components: ComponentPolicyInput, + blocked_components: ComponentPolicyInput, + error_match: Optional[str], +) -> None: + policy = ComponentLoadPolicy( + allowed_components=allowed_components, + blocked_components=blocked_components, + ) + tool = _make_stdio_mcp_tool() + + if error_match is None: + policy.validate_component_tree(tool) + else: + with pytest.raises(ValueError, match=error_match): + policy.validate_component_tree(tool) + + +def test_component_load_policy_blocks_child_classes_of_blocked_component_classes() -> None: + policy = ComponentLoadPolicy(blocked_components=[ClientTransport]) + + with pytest.raises(ValueError, match="StdioTransport.*in the block list"): + policy.validate_component_tree(_make_stdio_mcp_tool()) + + +def test_component_load_policy_allows_child_classes_of_allowed_component_classes() -> None: + policy = ComponentLoadPolicy( + allowed_components=[MCPTool, ClientTransport], + blocked_components=[], + ) + + policy.validate_component_tree(_make_stdio_mcp_tool()) + + +def test_component_load_policy_blocks_child_classes_of_resolved_component_type_names() -> None: + policy = ComponentLoadPolicy(blocked_components=["ClientTransport"]) + + with pytest.raises(ValueError, match="StdioTransport.*in the block list"): + policy.validate_component_tree(_make_stdio_mcp_tool()) + + +def test_component_load_policy_allows_child_classes_of_resolved_component_type_names() -> None: + policy = ComponentLoadPolicy( + allowed_components=["MCPTool", "ClientTransport"], + blocked_components=[], + ) + + policy.validate_component_tree(_make_stdio_mcp_tool()) + + +def test_component_load_policy_uses_more_specific_block_rule() -> None: + policy = ComponentLoadPolicy( + allowed_components=[LlmConfig], + blocked_components=[OpenAiConfig], + ) + + with pytest.raises(ValueError, match="OpenAiConfig.*in the block list"): + policy.validate_component_tree(OpenAiConfig(name="openai", model_id="gpt-4o")) + + +def test_component_load_policy_uses_more_specific_allow_rule() -> None: + policy = ComponentLoadPolicy( + allowed_components=[OpenAiConfig], + blocked_components=[LlmConfig], + ) + + policy.validate_component_tree(OpenAiConfig(name="openai", model_id="gpt-4o")) + + +def test_component_load_policy_exact_type_name_outranks_parent_class_rule() -> None: + policy = ComponentLoadPolicy( + allowed_components=["OpenAiConfig"], + blocked_components=[LlmConfig], + ) + + policy.validate_component_tree(OpenAiConfig(name="openai", model_id="gpt-4o")) + + +def test_component_load_policy_uses_more_specific_resolved_type_name_rule() -> None: + policy = ComponentLoadPolicy( + allowed_components=["OpenAiConfig"], + blocked_components=["LlmConfig"], + ) + + policy.validate_component_tree(OpenAiConfig(name="openai", model_id="gpt-4o")) + + +def test_component_load_policy_unresolved_type_names_match_exactly() -> None: + policy = ComponentLoadPolicy(blocked_components=["UnregisteredComponent"]) + + policy.validate_component_tree(OpenAiConfig(name="openai", model_id="gpt-4o")) + with pytest.raises(ValueError, match="UnregisteredComponent.*in the block list"): + policy.validate_component_type("UnregisteredComponent") + + +def test_deserializer_applies_hierarchical_policy_to_loaded_components() -> None: + serialized_tool = AgentSpecSerializer().to_dict(_make_stdio_mcp_tool()) + + with pytest.raises(ValueError, match="StdioTransport.*in the block list"): + AgentSpecDeserializer(blocked_components=[ClientTransport]).from_dict(serialized_tool) + + +def test_component_load_policy_class_entries_work_for_extension_components() -> None: + component_types_and_models = { + PolicyExtensionComponent.__name__: PolicyExtensionComponent, + PolicyExtensionChildComponent.__name__: PolicyExtensionChildComponent, + } + serialization_plugin = PydanticComponentSerializationPlugin( + component_types_and_models=component_types_and_models + ) + deserialization_plugin = PydanticComponentDeserializationPlugin( + component_types_and_models=component_types_and_models + ) + component = PolicyExtensionChildComponent( + name="extension_child", + value="parent-value", + child_value="child-value", + ) + serialized_component = AgentSpecSerializer(plugins=[serialization_plugin]).to_dict(component) + + with pytest.raises(ValueError, match="PolicyExtensionChildComponent.*in the block list"): + AgentSpecDeserializer( + plugins=[deserialization_plugin], + blocked_components=[PolicyExtensionComponent], + ).from_dict(serialized_component) + + loaded_component = AgentSpecDeserializer( + plugins=[deserialization_plugin], + allowed_components=[PolicyExtensionComponent], + ).from_dict(serialized_component) + + assert loaded_component == component + + +def test_deserializer_validates_component_returned_by_plugin() -> None: + with pytest.raises(ValueError, match="PolicyPluginContainer.*in the block list"): + AgentSpecDeserializer( + plugins=[PolicyBypassDeserializationPlugin()], + blocked_components=[PolicyPluginContainer], + ).from_dict( + { + "component_type": PolicyPluginContainer.__name__, + "agentspec_version": "25.4.1", + "name": "plugin_container", + } + )