diff --git a/VERSION b/VERSION index ffd7385f..6271efa3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -26.2.0.dev7 +26.2.0.dev8 diff --git a/docs/pyagentspec/source/_components/all_components.json b/docs/pyagentspec/source/_components/all_components.json index a0ce07bd..24dfbbb9 100644 --- a/docs/pyagentspec/source/_components/all_components.json +++ b/docs/pyagentspec/source/_components/all_components.json @@ -207,6 +207,16 @@ } ] }, + { + "name": "Code Executors", + "path": "codeexecutors", + "classes": [ + {"path": "pyagentspec.tools.codeexecutors.CodeExecutor"}, + {"path": "pyagentspec.tools.codeexecutors.SubProcessCodeExecutor"}, + {"path": "pyagentspec.tools.codeexecutors.LocalContainerCodeExecutor"}, + {"path": "pyagentspec.tools.codeexecutors.EndpointCodeExecutor"} + ] + }, { "name": "IO Properties", "path": "ioproperties", 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..353174ca 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 @@ -164,6 +164,16 @@ } ] }, + "CodeExecutor": { + "anyOf": [ + { + "$ref": "#/$defs/BaseCodeExecutor" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, "ComponentWithIO": { "anyOf": [ { @@ -224,6 +234,16 @@ } ] }, + "EndpointCodeExecutor": { + "anyOf": [ + { + "$ref": "#/$defs/BaseEndpointCodeExecutor" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, "Flow": { "anyOf": [ { @@ -378,6 +398,16 @@ } ] }, + "LocalContainerCodeExecutor": { + "anyOf": [ + { + "$ref": "#/$defs/BaseLocalContainerCodeExecutor" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, "MCPTool": { "anyOf": [ { @@ -1000,6 +1030,16 @@ } ] }, + "SubProcessCodeExecutor": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSubProcessCodeExecutor" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ] + }, "Swarm": { "anyOf": [ { @@ -2241,6 +2281,20 @@ ], "x-abstract-component": true }, + "BaseCodeExecutor": { + "anyOf": [ + { + "$ref": "#/$defs/EndpointCodeExecutor" + }, + { + "$ref": "#/$defs/LocalContainerCodeExecutor" + }, + { + "$ref": "#/$defs/SubProcessCodeExecutor" + } + ], + "x-abstract-component": true + }, "BaseComponentWithIO": { "anyOf": [ { @@ -2730,6 +2784,114 @@ "type": "object", "x-abstract-component": false }, + "BaseEndpointCodeExecutor": { + "additionalProperties": false, + "description": "Code executor that sends execution requests to an endpoint.", + "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_seconds": { + "default": 30.0, + "exclusiveMinimum": 0, + "title": "Timeout Seconds", + "type": "number" + }, + "max_code_chars": { + "default": 50000, + "exclusiveMinimum": 0, + "title": "Max Code Chars", + "type": "integer" + }, + "url": { + "title": "Url", + "type": "string" + }, + "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": "EndpointCodeExecutor" + } + }, + "required": [ + "name", + "url" + ], + "title": "EndpointCodeExecutor", + "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... )", @@ -3639,6 +3801,73 @@ "type": "object", "x-abstract-component": false }, + "BaseLocalContainerCodeExecutor": { + "additionalProperties": false, + "description": "Code executor that declares local container execution.", + "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_seconds": { + "default": 30.0, + "exclusiveMinimum": 0, + "title": "Timeout Seconds", + "type": "number" + }, + "max_code_chars": { + "default": 50000, + "exclusiveMinimum": 0, + "title": "Max Code Chars", + "type": "integer" + }, + "image": { + "title": "Image", + "type": "string" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "LocalContainerCodeExecutor" + } + }, + "required": [ + "image", + "name" + ], + "title": "LocalContainerCodeExecutor", + "type": "object", + "x-abstract-component": false + }, "BaseMCPTool": { "additionalProperties": false, "description": "Class for tools exposed by MCP servers", @@ -7070,6 +7299,68 @@ "type": "object", "x-abstract-component": false }, + "BaseSubProcessCodeExecutor": { + "additionalProperties": false, + "description": "Code executor that declares local process execution.\n\nNote: The subprocess executor is intended for prototyping only and must\nnot be used in production deployments.", + "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_seconds": { + "default": 30.0, + "exclusiveMinimum": 0, + "title": "Timeout Seconds", + "type": "number" + }, + "max_code_chars": { + "default": 50000, + "exclusiveMinimum": 0, + "title": "Max Code Chars", + "type": "integer" + }, + "$referenced_components": { + "$ref": "#/$defs/ReferencedComponents" + }, + "component_type": { + "const": "SubProcessCodeExecutor" + } + }, + "required": [ + "name" + ], + "title": "SubProcessCodeExecutor", + "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... )", @@ -7717,6 +8008,9 @@ { "$ref": "#/$defs/BaseClientTransport" }, + { + "$ref": "#/$defs/BaseCodeExecutor" + }, { "$ref": "#/$defs/BaseComponentWithIO" }, @@ -7735,6 +8029,9 @@ { "$ref": "#/$defs/BaseEndNode" }, + { + "$ref": "#/$defs/BaseEndpointCodeExecutor" + }, { "$ref": "#/$defs/BaseFlow" }, @@ -7765,6 +8062,9 @@ { "$ref": "#/$defs/BaseLlmNode" }, + { + "$ref": "#/$defs/BaseLocalContainerCodeExecutor" + }, { "$ref": "#/$defs/BaseMCPTool" }, @@ -7882,6 +8182,9 @@ { "$ref": "#/$defs/BaseStreamableHTTPmTLSTransport" }, + { + "$ref": "#/$defs/BaseSubProcessCodeExecutor" + }, { "$ref": "#/$defs/BaseSwarm" }, @@ -8117,6 +8420,21 @@ } } }, + "VersionedCodeExecutor": { + "anyOf": [ + { + "$ref": "#/$defs/BaseCodeExecutor" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, "VersionedComponentWithIO": { "anyOf": [ { @@ -8207,6 +8525,21 @@ } } }, + "VersionedEndpointCodeExecutor": { + "anyOf": [ + { + "$ref": "#/$defs/BaseEndpointCodeExecutor" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, "VersionedFlow": { "anyOf": [ { @@ -8357,6 +8690,21 @@ } } }, + "VersionedLocalContainerCodeExecutor": { + "anyOf": [ + { + "$ref": "#/$defs/BaseLocalContainerCodeExecutor" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, "VersionedMCPTool": { "anyOf": [ { @@ -8897,6 +9245,21 @@ } } }, + "VersionedSubProcessCodeExecutor": { + "anyOf": [ + { + "$ref": "#/$defs/BaseSubProcessCodeExecutor" + }, + { + "$ref": "#/$defs/ComponentReference" + } + ], + "properties": { + "agentspec_version": { + "$ref": "#/$defs/AgentSpecVersionEnum" + } + } + }, "VersionedSwarm": { "anyOf": [ { @@ -9059,6 +9422,9 @@ { "$ref": "#/$defs/VersionedClientTransport" }, + { + "$ref": "#/$defs/VersionedCodeExecutor" + }, { "$ref": "#/$defs/VersionedComponentReferenceWithNestedReferences" }, @@ -9080,6 +9446,9 @@ { "$ref": "#/$defs/VersionedEndNode" }, + { + "$ref": "#/$defs/VersionedEndpointCodeExecutor" + }, { "$ref": "#/$defs/VersionedFlow" }, @@ -9110,6 +9479,9 @@ { "$ref": "#/$defs/VersionedLlmNode" }, + { + "$ref": "#/$defs/VersionedLocalContainerCodeExecutor" + }, { "$ref": "#/$defs/VersionedMCPTool" }, @@ -9218,6 +9590,9 @@ { "$ref": "#/$defs/VersionedStreamableHTTPmTLSTransport" }, + { + "$ref": "#/$defs/VersionedSubProcessCodeExecutor" + }, { "$ref": "#/$defs/VersionedSwarm" }, diff --git a/docs/pyagentspec/source/agentspec/language_spec_nightly.rst b/docs/pyagentspec/source/agentspec/language_spec_nightly.rst index 80aeb86d..37d33ee7 100644 --- a/docs/pyagentspec/source/agentspec/language_spec_nightly.rst +++ b/docs/pyagentspec/source/agentspec/language_spec_nightly.rst @@ -366,6 +366,9 @@ following goals : - 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 + * - Code Executor + - A component that configures a backend for executing code. + - A local container executor using a configured image, or an endpoint executor connected to a hosted sandbox service. * - 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 @@ -1294,6 +1297,75 @@ and raise an error if the values are not expected. **Tool Version:** Our vector_retrieval_tool is not versioned. +Code Executors +^^^^^^^^^^^^^^ + +Agent Spec runtimes can provide backends for executing code. A CodeExecutor +component describes one such backend in a serialized Agent Spec configuration. + +Code executor components do not execute code by themselves and do not contain +the code to execute. They configure runtime-provided execution backends that are +used by other components or runtime features. + +The base CodeExecutor is abstract. Code executor components define +the executor configuration. + +.. code-block:: python + + class CodeExecutor(Component): + timeout_seconds: float + max_code_chars: int + + class SubProcessCodeExecutor(CodeExecutor): + pass + + class LocalContainerCodeExecutor(CodeExecutor): + image: str + + class EndpointCodeExecutor(CodeExecutor): + url: str + headers: Optional[Dictstr, str] + sensitive_headers: SensitiveField[Optional[Dictstr, str]] + retry_policy: OptionalRetryPolicy + +- ``timeout_seconds``: The maximum wall-clock time allowed for one execution. + The default value is 30. +- ``max_code_chars``: The maximum accepted source length in characters. + The default value is 50000. +- ``SubProcessCodeExecutor``: Declares that code execution happens in a local + process managed by the runtime. It does not expose process launch details. +- ``LocalContainerCodeExecutor.image``: Identifies the local container image + used by the runtime for code execution. +- ``EndpointCodeExecutor.url``: Identifies the code execution endpoint. +- ``EndpointCodeExecutor.headers``: Contains non-sensitive headers sent to the + endpoint. +- ``EndpointCodeExecutor.sensitive_headers``: Contains sensitive headers sent + to the endpoint. +- ``EndpointCodeExecutor.retry_policy``: Configures retries for requests sent + to the endpoint. + +Runtime responsibilities +'''''''''''''''''''''''' + +Agent Spec configurations store the executor configuration and shared limits. Runtime +implementations may determine the supported languages, execution modes, dependency +policy, isolation strength, result shape, and any additional execution policy, +or they may defer this responsibility to the code execution server entirely. + +Security considerations +''''''''''''''''''''''' + +Code executor components configure execution backends, and executing code is +security-sensitive. Runtimes document the security properties of each +supported executor component, including isolation boundary, network access, +filesystem access, resource limits, dependency policy, and whether the backend +is appropriate for untrusted code. + +Local process execution is a local execution mechanism, not a strong sandbox. +Hardened deployments use execution backends with stronger isolation, such as +containers, virtual machines, or hosted sandbox services, together with +defense-in-depth controls. + Execution flows ~~~~~~~~~~~~~~~ diff --git a/docs/pyagentspec/source/api/codeexecutors.rst b/docs/pyagentspec/source/api/codeexecutors.rst new file mode 100644 index 00000000..463d0296 --- /dev/null +++ b/docs/pyagentspec/source/api/codeexecutors.rst @@ -0,0 +1,20 @@ +Code Executors +============== + +This page presents all APIs and classes related to code execution backends. + +.. _codeexecutor: +.. autoclass:: pyagentspec.tools.codeexecutors.CodeExecutor + :exclude-members: model_post_init, model_config + +.. _subprocesscodeexecutor: +.. autoclass:: pyagentspec.tools.codeexecutors.SubProcessCodeExecutor + :exclude-members: model_post_init, model_config + +.. _localcontainercodeexecutor: +.. autoclass:: pyagentspec.tools.codeexecutors.LocalContainerCodeExecutor + :exclude-members: model_post_init, model_config + +.. _endpointcodeexecutor: +.. autoclass:: pyagentspec.tools.codeexecutors.EndpointCodeExecutor + :exclude-members: model_post_init, model_config diff --git a/docs/pyagentspec/source/changelog.rst b/docs/pyagentspec/source/changelog.rst index db693b2a..84139a61 100644 --- a/docs/pyagentspec/source/changelog.rst +++ b/docs/pyagentspec/source/changelog.rst @@ -4,6 +4,17 @@ Changelog Agent Spec |release| -------------------- +New features +^^^^^^^^^^^^ + +* **Code executor components** + + Added ``CodeExecutor``, ``SubProcessCodeExecutor``, ``LocalContainerCodeExecutor``, + and ``EndpointCodeExecutor`` components for configuring runtime-provided code execution + backends with shared timeout and source-length limits. + + + Improvements ^^^^^^^^^^^^ diff --git a/docs/pyagentspec/source/conf.py b/docs/pyagentspec/source/conf.py index 69c33aae..f7082cc5 100644 --- a/docs/pyagentspec/source/conf.py +++ b/docs/pyagentspec/source/conf.py @@ -84,6 +84,11 @@ "sphinx_design", ] +# The API index is generated with a hidden toctree during the build. Sphinx's +# consistency check reports the generated API pages as not included even +# though they are intentionally reachable through that index. +suppress_warnings = ["toc.not_included"] + if docs_version == "dev": language_spec_file = "language_spec_nightly" else: diff --git a/pyagentspec/src/pyagentspec/__init__.py b/pyagentspec/src/pyagentspec/__init__.py index bb22c86b..2fc3420b 100644 --- a/pyagentspec/src/pyagentspec/__init__.py +++ b/pyagentspec/src/pyagentspec/__init__.py @@ -19,6 +19,12 @@ from .a2aagent import A2AAgent, A2AConnectionConfig, A2ASessionParameters from .agent import Agent from .component import Component +from .tools.codeexecutors import ( + CodeExecutor, + EndpointCodeExecutor, + LocalContainerCodeExecutor, + SubProcessCodeExecutor, +) from .managerworkers import ManagerWorkers from .ociagent import OciAgent from .property import Property @@ -35,6 +41,10 @@ "Property", "RetryPolicy", "Component", + "CodeExecutor", + "SubProcessCodeExecutor", + "LocalContainerCodeExecutor", + "EndpointCodeExecutor", "Agent", "OpenAiAgent", "OciAgent", diff --git a/pyagentspec/src/pyagentspec/_component_registry.py b/pyagentspec/src/pyagentspec/_component_registry.py index be871a30..a85199e8 100644 --- a/pyagentspec/src/pyagentspec/_component_registry.py +++ b/pyagentspec/src/pyagentspec/_component_registry.py @@ -13,6 +13,12 @@ from pyagentspec.agenticcomponent import AgenticComponent from pyagentspec.auth import OAuthClientConfig, OAuthConfig from pyagentspec.component import Component, ComponentWithIO +from pyagentspec.tools.codeexecutors import ( + CodeExecutor, + EndpointCodeExecutor, + LocalContainerCodeExecutor, + SubProcessCodeExecutor, +) from pyagentspec.datastores.datastore import Datastore, InMemoryCollectionDatastore from pyagentspec.datastores.oracle import ( MTlsOracleDatabaseConnectionConfig, @@ -100,16 +106,19 @@ "ClientTransport": ClientTransport, "Component": Component, "ComponentWithIO": ComponentWithIO, + "CodeExecutor": CodeExecutor, "ClientTool": ClientTool, "BuiltinTool": BuiltinTool, "ControlFlowEdge": ControlFlowEdge, "DataFlowEdge": DataFlowEdge, "Datastore": Datastore, + "EndpointCodeExecutor": EndpointCodeExecutor, "EndNode": EndNode, "Flow": Flow, "FlowNode": FlowNode, "InMemoryCollectionDatastore": InMemoryCollectionDatastore, "InputMessageNode": InputMessageNode, + "LocalContainerCodeExecutor": LocalContainerCodeExecutor, "LlmConfig": LlmConfig, "LlmNode": LlmNode, "MapNode": MapNode, @@ -145,6 +154,7 @@ "StdioTransport": StdioTransport, "StreamableHTTPTransport": StreamableHTTPTransport, "StreamableHTTPmTLSTransport": StreamableHTTPmTLSTransport, + "SubProcessCodeExecutor": SubProcessCodeExecutor, "Tool": Tool, "ToolBox": ToolBox, "ToolNode": ToolNode, diff --git a/pyagentspec/src/pyagentspec/tools/__init__.py b/pyagentspec/src/pyagentspec/tools/__init__.py index d3a768dd..fb1d8b75 100644 --- a/pyagentspec/src/pyagentspec/tools/__init__.py +++ b/pyagentspec/src/pyagentspec/tools/__init__.py @@ -8,6 +8,12 @@ from .builtintool import BuiltinTool from .clienttool import ClientTool +from .codeexecutors import ( + CodeExecutor, + EndpointCodeExecutor, + LocalContainerCodeExecutor, + SubProcessCodeExecutor, +) from .remotetool import RemoteTool from .servertool import ServerTool from .tool import Tool @@ -15,6 +21,10 @@ __all__ = [ "ClientTool", + "CodeExecutor", + "SubProcessCodeExecutor", + "LocalContainerCodeExecutor", + "EndpointCodeExecutor", "ServerTool", "BuiltinTool", "RemoteTool", diff --git a/pyagentspec/src/pyagentspec/tools/codeexecutors.py b/pyagentspec/src/pyagentspec/tools/codeexecutors.py new file mode 100644 index 00000000..a685e34c --- /dev/null +++ b/pyagentspec/src/pyagentspec/tools/codeexecutors.py @@ -0,0 +1,71 @@ +# Copyright © 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (https://oss.oracle.com/licenses/upl), at your option. + +"""Code execution backend configuration components.""" + +from typing import Dict, Optional + +from pydantic import Field +from pydantic.json_schema import SkipJsonSchema + +from pyagentspec.component import Component +from pyagentspec.retrypolicy import RetryPolicy +from pyagentspec.sensitive_field import SensitiveField +from pyagentspec.versioning import AgentSpecVersionEnum + +__all__ = [ + "CodeExecutor", + "SubProcessCodeExecutor", + "LocalContainerCodeExecutor", + "EndpointCodeExecutor", +] + + +class CodeExecutor(Component, abstract=True): + """Base component for code execution backends.""" + + timeout_seconds: float = Field(default=30.0, gt=0) + """Maximum wall-clock seconds allowed for one execution.""" + + max_code_chars: int = Field(default=50_000, gt=0) + """Maximum accepted source length in characters.""" + + min_agentspec_version: SkipJsonSchema[AgentSpecVersionEnum] = Field( + default=AgentSpecVersionEnum.v26_2_0, + init=False, + exclude=True, + ) + + +class SubProcessCodeExecutor(CodeExecutor): + """Code executor that declares local process execution. + + Note: The subprocess executor is intended for prototyping only and must + not be used in production deployments. + """ + + +class LocalContainerCodeExecutor(CodeExecutor): + """Code executor that declares local container execution.""" + + image: str + """Local container image used by the runtime for code execution.""" + + +class EndpointCodeExecutor(CodeExecutor): + """Code executor that sends execution requests to an endpoint.""" + + url: str + """Code execution endpoint URL.""" + + headers: Optional[Dict[str, str]] = None + """Non-sensitive headers sent to the endpoint.""" + + sensitive_headers: SensitiveField[Optional[Dict[str, str]]] = None + """Sensitive headers sent to the endpoint.""" + + retry_policy: Optional[RetryPolicy] = None + """Optional retry configuration for requests sent to the endpoint.""" diff --git a/pyagentspec/tests/test_codeexecutors.py b/pyagentspec/tests/test_codeexecutors.py new file mode 100644 index 00000000..738d0f43 --- /dev/null +++ b/pyagentspec/tests/test_codeexecutors.py @@ -0,0 +1,183 @@ +# Copyright © 2026 Oracle and/or its affiliates. +# +# This software is under the Apache License 2.0 +# (http://www.apache.org/licenses/LICENSE-2.0) or Universal Permissive License +# (https://oss.oracle.com/licenses/upl), at your option. + +import json +from typing import Callable + +import pytest +from pydantic import ValidationError + +from pyagentspec import ( + AgentSpecDeserializer, + AgentSpecSerializer, + CodeExecutor, + EndpointCodeExecutor, + LocalContainerCodeExecutor, + RetryPolicy, + SubProcessCodeExecutor, +) +from pyagentspec._component_registry import BUILTIN_CLASS_MAP +from pyagentspec.versioning import AgentSpecVersionEnum + + +def test_code_executor_defaults() -> None: + executor = SubProcessCodeExecutor(name="subprocess") + + assert executor.timeout_seconds == 30.0 + assert executor.max_code_chars == 50_000 + assert executor.component_type == "SubProcessCodeExecutor" + assert executor.min_agentspec_version == AgentSpecVersionEnum.v26_2_0 + + +@pytest.mark.parametrize( + ("field_name", "invalid_value"), + [ + ("timeout_seconds", 0), + ("timeout_seconds", -1), + ("max_code_chars", 0), + ("max_code_chars", -1), + ], +) +def test_code_executor_rejects_non_positive_limits(field_name: str, invalid_value: int) -> None: + with pytest.raises(ValidationError): + SubProcessCodeExecutor(name="subprocess", **{field_name: invalid_value}) + + +@pytest.mark.parametrize( + "executor_class", + [LocalContainerCodeExecutor, EndpointCodeExecutor], +) +def test_code_executor_required_backend_fields(executor_class: type[CodeExecutor]) -> None: + with pytest.raises(ValidationError): + executor_class(name="executor") + + +def test_local_container_executor_and_endpoint_executor_configuration() -> None: + container = LocalContainerCodeExecutor(name="container", image="python:3.12") + endpoint = EndpointCodeExecutor( + name="endpoint", + url="https://executor.example.invalid/run", + headers={"X-Request-Id": "request-id"}, + sensitive_headers={"Authorization": "Bearer secret"}, + ) + + assert container.image == "python:3.12" + assert endpoint.url == "https://executor.example.invalid/run" + assert endpoint.headers == {"X-Request-Id": "request-id"} + assert endpoint.sensitive_headers == {"Authorization": "Bearer secret"} + + +def test_endpoint_sensitive_headers_are_excluded_by_default_and_exported_on_opt_in() -> None: + endpoint = EndpointCodeExecutor( + name="endpoint", + url="https://executor.example.invalid/run", + headers={"X-Request-Id": "request-id"}, + sensitive_headers={"Authorization": "Bearer secret"}, + ) + + serialized = AgentSpecSerializer().to_dict(endpoint) + assert serialized["headers"] == {"X-Request-Id": "request-id"} + # Default serialization replaces non-empty sensitive values with references. + assert serialized["sensitive_headers"] == {"$component_ref": f"{endpoint.id}.sensitive_headers"} + + # Sensitive values require an explicit opt-in before they are serialized. + with pytest.warns(UserWarning): + serialized_with_sensitive_headers = AgentSpecSerializer().to_dict( + endpoint, include_sensitive_fields=True + ) + assert serialized_with_sensitive_headers["sensitive_headers"] == { + "Authorization": "Bearer secret" + } + + +def test_endpoint_retry_policy_roundtrip() -> None: + endpoint = EndpointCodeExecutor( + name="endpoint", + url="https://executor.example.invalid/run", + retry_policy=RetryPolicy(max_attempts=3, request_timeout=0.5), + ) + + serialized = AgentSpecSerializer().to_dict(endpoint) + deserialized = AgentSpecDeserializer().from_dict(serialized) + + assert isinstance(deserialized, EndpointCodeExecutor) + assert deserialized.retry_policy == endpoint.retry_policy + assert AgentSpecSerializer().to_dict(deserialized) == serialized + + +def test_code_executor_is_abstract() -> None: + with pytest.raises(TypeError, match="meant to be abstract"): + CodeExecutor(name="executor") + + +@pytest.mark.parametrize( + "executor_class", + [ + CodeExecutor, + SubProcessCodeExecutor, + LocalContainerCodeExecutor, + EndpointCodeExecutor, + ], +) +def test_code_executors_are_builtin_components(executor_class: type[CodeExecutor]) -> None: + assert BUILTIN_CLASS_MAP[executor_class.__name__] is executor_class + + +@pytest.mark.parametrize( + ("executor", "serializer", "deserializer"), + [ + ( + SubProcessCodeExecutor(name="subprocess"), + AgentSpecSerializer().to_json, + AgentSpecDeserializer().from_json, + ), + ( + LocalContainerCodeExecutor(name="container", image="python:3.12"), + AgentSpecSerializer().to_yaml, + AgentSpecDeserializer().from_yaml, + ), + ( + EndpointCodeExecutor( + name="endpoint", + url="https://executor.example.invalid/run", + headers={"X-Request-Id": "request-id"}, + retry_policy=RetryPolicy(max_attempts=3), + ), + AgentSpecSerializer().to_json, + AgentSpecDeserializer().from_json, + ), + ], + ids=["subprocess-json", "container-yaml", "endpoint-json"], +) +def test_code_executor_json_and_yaml_roundtrips( + executor: CodeExecutor, + serializer: Callable[[CodeExecutor], str], + deserializer: Callable[[str], CodeExecutor], +) -> None: + serialized = serializer(executor) + deserialized = deserializer(serialized) + + assert deserialized == executor + if serialized.lstrip().startswith("{"): + assert json.loads(serialized)["component_type"] == executor.component_type + else: + assert "component_type: " + executor.component_type in serialized + + +def test_code_executor_rejects_serialization_before_v26_2_0() -> None: + executor = SubProcessCodeExecutor(name="subprocess") + + with pytest.raises(ValueError, match="Invalid agentspec_version"): + AgentSpecSerializer().to_dict(executor, agentspec_version=AgentSpecVersionEnum.v26_1_2) + + +def test_code_executor_rejects_deserialization_before_v26_2_0() -> None: + executor = SubProcessCodeExecutor(name="subprocess") + serialized = AgentSpecSerializer().to_dict(executor) + serialized["agentspec_version"] = AgentSpecVersionEnum.v26_1_2.value + + with pytest.raises(ValueError, match="Invalid agentspec_version"): + AgentSpecDeserializer().from_dict(serialized)