diff --git a/python/AGENTS.md b/python/AGENTS.md index e4697e18d5..0b8ff4a5aa 100644 --- a/python/AGENTS.md +++ b/python/AGENTS.md @@ -64,6 +64,7 @@ python/ - [anthropic](packages/anthropic/AGENTS.md) - Anthropic Claude API - [bedrock](packages/bedrock/AGENTS.md) - AWS Bedrock - [claude](packages/claude/AGENTS.md) - Claude Agent SDK +- [codex](packages/codex/AGENTS.md) - OpenAI Codex SDK - [foundry_local](packages/foundry_local/AGENTS.md) - Azure AI Foundry Local - [ollama](packages/ollama/AGENTS.md) - Local Ollama inference diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 661cebe53a..396a435984 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -24,6 +24,7 @@ Status is grouped into these buckets: | `agent-framework-bedrock` | `python/packages/bedrock` | `beta` | | `agent-framework-chatkit` | `python/packages/chatkit` | `beta` | | `agent-framework-claude` | `python/packages/claude` | `beta` | +| `agent-framework-codex` | `python/packages/codex` | `alpha` | | `agent-framework-copilotstudio` | `python/packages/copilotstudio` | `beta` | | `agent-framework-core` | `python/packages/core` | `released` | | `agent-framework-declarative` | `python/packages/declarative` | `beta` | diff --git a/python/packages/codex/AGENTS.md b/python/packages/codex/AGENTS.md new file mode 100644 index 0000000000..ca15e6e7db --- /dev/null +++ b/python/packages/codex/AGENTS.md @@ -0,0 +1,28 @@ +# Codex Package (agent-framework-codex) + +Integration with OpenAI Codex as a managed agent (Codex SDK). + +## Main Classes + +- **`CodexAgent`** - Agent using Codex's native agent capabilities +- **`CodexAgentOptions`** - Options for Codex agent configuration +- **`CodexAgentSettings`** - TypedDict-based settings populated via the framework's `load_settings()` helper + +## Usage + +```python +from agent_framework_codex import CodexAgent + +agent = CodexAgent(...) +response = await agent.run("Hello") +``` + +## Import Path + +```python +from agent_framework_codex import CodexAgent +``` + +## Note + +This package is for Codex's managed agent functionality. For basic OpenAI chat, use `agent-framework-openai` instead. diff --git a/python/packages/codex/LICENSE b/python/packages/codex/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/codex/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/codex/README.md b/python/packages/codex/README.md new file mode 100644 index 0000000000..02452c0ad1 --- /dev/null +++ b/python/packages/codex/README.md @@ -0,0 +1,11 @@ +# Get Started with Microsoft Agent Framework Codex + +Please install this package via pip: + +```bash +pip install agent-framework-codex --pre +``` + +## Codex Agent + +The Codex agent enables integration with OpenAI Codex SDK, allowing you to interact with Codex's agentic coding capabilities through the Agent Framework. diff --git a/python/packages/codex/agent_framework_codex/__init__.py b/python/packages/codex/agent_framework_codex/__init__.py new file mode 100644 index 0000000000..eac7165458 --- /dev/null +++ b/python/packages/codex/agent_framework_codex/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib.metadata + +from ._agent import CodexAgent, CodexAgentOptions, CodexAgentSettings, RawCodexAgent + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + +__all__ = [ + "CodexAgent", + "CodexAgentOptions", + "CodexAgentSettings", + "RawCodexAgent", + "__version__", +] diff --git a/python/packages/codex/agent_framework_codex/_agent.py b/python/packages/codex/agent_framework_codex/_agent.py new file mode 100644 index 0000000000..a498942158 --- /dev/null +++ b/python/packages/codex/agent_framework_codex/_agent.py @@ -0,0 +1,713 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import logging +import sys +from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload + +from agent_framework import ( + AgentMiddlewareLayer, + AgentMiddlewareTypes, + AgentResponse, + AgentResponseUpdate, + AgentRunInputs, + AgentSession, + BaseAgent, + Content, + ContextProvider, + Message, + MiddlewareTypes, + ResponseStream, + ToolTypes, + load_settings, + normalize_messages, + normalize_tools, +) +from agent_framework.exceptions import AgentException +from agent_framework.observability import AgentTelemetryLayer +from codex_sdk import ( + Codex, + CodexOptions, + Thread, + ThreadOptions, +) +from codex_sdk.events import ( + ItemUpdatedEvent, + ThreadErrorEvent, + TurnCompletedEvent, + TurnFailedEvent, +) +from codex_sdk.items import ( + AgentMessageItem, + ErrorItem, + ReasoningItem, +) + +if sys.version_info >= (3, 13): + from typing import TypeVar # type: ignore # pragma: no cover +else: + from typing_extensions import TypeVar # type: ignore # pragma: no cover +if sys.version_info >= (3, 11): + from typing import TypedDict # pragma: no cover +else: + from typing_extensions import TypedDict # pragma: no cover + +if TYPE_CHECKING: + from codex_sdk import ( + ApprovalMode, + ModelReasoningEffort, + SandboxMode, + ) + + +logger = logging.getLogger("agent_framework.codex") + + +class CodexAgentSettings(TypedDict, total=False): + """Codex Agent settings. + + Settings are resolved in this order: explicit keyword arguments, values from an + explicitly provided .env file, then environment variables with the prefix + 'CODEX_AGENT_'. + + Keys: + codex_path: The path to Codex CLI executable. + model: The model to use (codex-mini-latest, gpt-5.1-codex). + cwd: The working directory for Codex CLI. + approval_policy: Approval policy (default, full-auto, plan). + """ + + codex_path: str | None + model: str | None + cwd: str | None + approval_policy: str | None + + +class CodexAgentOptions(TypedDict, total=False): + """Codex Agent-specific options.""" + + system_prompt: str + """System prompt for the agent.""" + + codex_path: str + """Path to Codex CLI executable. Default: auto-detected.""" + + cwd: str + """Working directory for Codex CLI. Default: current working directory.""" + + env: dict[str, str] + """Environment variables to pass to CLI.""" + + model: str + """Model to use ("codex-mini-latest", "gpt-5.1-codex"). Default: "codex-mini-latest".""" + + sandbox_mode: SandboxMode + """Sandbox mode for code execution.""" + + model_reasoning_effort: ModelReasoningEffort + """Model reasoning effort preset.""" + + approval_policy: ApprovalMode + """Approval policy for tool execution.""" + + additional_directories: list[str] + """Additional directories to add to context.""" + + config_overrides: dict[str, Any] + """Additional configuration overrides passed to the Codex CLI.""" + + +OptionsT = TypeVar( + "OptionsT", + bound=TypedDict, # type: ignore[valid-type] + default="CodexAgentOptions", + covariant=True, +) + + +class RawCodexAgent(BaseAgent, Generic[OptionsT]): + """OpenAI Codex Agent using Codex CLI without telemetry layers. + + This is the core Codex agent implementation without OpenTelemetry instrumentation. + For most use cases, prefer :class:`CodexAgent` which includes telemetry and + middleware support. + + Wraps the Codex SDK to provide agentic coding capabilities including + tool use, session management, and streaming responses. + + This agent communicates with Codex through the Codex CLI, + enabling access to Codex's full agentic capabilities like file + editing, code execution, and tool use. + + The agent can be used as an async context manager to ensure proper cleanup: + + Examples: + Basic usage with context manager: + + .. code-block:: python + + from agent_framework_codex import CodexAgent + + async with CodexAgent( + instructions="You are a helpful coding assistant.", + ) as agent: + response = await agent.run("Hello!") + print(response.text) + + With streaming: + + .. code-block:: python + + async with CodexAgent() as agent: + async for update in agent.run("Write a poem", stream=True): + print(update.text, end="", flush=True) + + With session management: + + .. code-block:: python + + async with CodexAgent() as agent: + session = agent.create_session() + await agent.run("Remember my name is Alice", session=session) + response = await agent.run("What's my name?", session=session) + # Codex will remember "Alice" from the same thread + + With Agent Framework tools: + + .. code-block:: python + + from agent_framework import tool + + @tool + def greet(name: str) -> str: + \"\"\"Greet someone by name.\"\"\" + return f"Hello, {name}!" + + async with CodexAgent(tools=[greet]) as agent: + response = await agent.run("Greet Alice") + """ + + AGENT_PROVIDER_NAME: ClassVar[str] = "openai" + + def __init__( + self, + instructions: str | None = None, + *, + client: Codex | None = None, + id: str | None = None, + name: str | None = None, + description: str | None = None, + context_providers: Sequence[ContextProvider] | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | str | Sequence[ToolTypes | Callable[..., Any] | str] | None = None, + default_options: OptionsT | MutableMapping[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a RawCodexAgent instance. + + Args: + instructions: System prompt for the agent. + + Keyword Args: + client: Optional pre-configured Codex instance. If not provided, + a new client will be created using the other parameters. + id: Unique identifier for the agent. + name: Name of the agent. + description: Description of the agent. + context_providers: Context providers for the agent. + middleware: List of middleware. + tools: Tools for the agent. Can be: + - Strings for built-in tools (e.g., "Read", "Write", "Bash", "Glob") + - Functions for custom tools + default_options: Default CodexAgentOptions including system_prompt, model, etc. + env_file_path: Path to .env file. + env_file_encoding: Encoding of .env file. + """ + super().__init__( + id=id, + name=name, + description=description, + context_providers=context_providers, + middleware=middleware, + ) + + self._client = client + self._owns_client = client is None + + # Parse options + opts: dict[str, Any] = dict(default_options) if default_options else {} + + # Handle instructions parameter - set as system_prompt in options + if instructions is not None: + opts["system_prompt"] = instructions + + codex_path = opts.pop("codex_path", None) + model = opts.pop("model", None) + cwd = opts.pop("cwd", None) + approval_policy = opts.pop("approval_policy", None) + + # Load settings from environment and options + self._settings = load_settings( + CodexAgentSettings, + env_prefix="CODEX_AGENT_", + codex_path=codex_path, + model=model, + cwd=cwd, + approval_policy=approval_policy, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + # Separate built-in tools (strings) from custom tools (callables/FunctionTool) + self._builtin_tools: list[str] = [] + self._custom_tools: list[ToolTypes] = [] + self._normalize_tools(tools) + + self._default_options = opts + self._current_thread: Thread | None = None + self._current_thread_id: str | None = None + + def _normalize_tools( + self, + tools: ToolTypes | Callable[..., Any] | str | Sequence[ToolTypes | Callable[..., Any] | str] | None, + ) -> None: + """Separate built-in tools (strings) from custom tools. + + Args: + tools: Mixed list of tool names and custom tools. + """ + if tools is None: + return + + # Normalize to sequence + if isinstance(tools, str): + tools_list: Sequence[Any] = [tools] + elif isinstance(tools, Sequence): + tools_list = list(tools) + else: + tools_list = [tools] + + for tool in tools_list: + if isinstance(tool, str): + self._builtin_tools.append(tool) + else: + # Use normalize_tools for custom tools + normalized = normalize_tools(tool) + self._custom_tools.extend(normalized) + + async def __aenter__(self) -> RawCodexAgent[OptionsT]: + """Start the agent when entering async context.""" + await self.start() + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Stop the agent when exiting async context.""" + await self.stop() + + async def start(self) -> None: + """Start the Codex SDK client. + + This method initializes the Codex client. It is called automatically + when using the agent as an async context manager. + + Raises: + AgentException: If the client fails to start. + """ + if self._client is None: + try: + self._client = self._create_codex_client() + self._owns_client = True + except Exception as ex: + raise AgentException(f"Failed to create Codex client: {ex}") from ex + + async def stop(self) -> None: + """Stop the Codex SDK client and clean up resources. + + Stops the client if owned by this agent. Called automatically when + using the agent as an async context manager. + """ + self._current_thread = None + self._current_thread_id = None + if self._owns_client: + self._client = None + + def _create_codex_client(self) -> Codex: + """Create a Codex client with configured options. + + Returns: + A configured Codex instance. + """ + codex_path = self._settings.get("codex_path") + env = self._default_options.get("env") + + codex_opts = CodexOptions( + codex_path_override=codex_path, + env=env, + ) + + return Codex(options=codex_opts) + + def _prepare_thread_options(self) -> ThreadOptions: + """Prepare ThreadOptions from settings and default options. + + Returns: + ThreadOptions instance configured for the thread. + """ + thread_opts_kwargs: dict[str, Any] = {} + + # Apply settings from environment + if model := self._settings.get("model"): + thread_opts_kwargs["model"] = model + if cwd := self._settings.get("cwd"): + thread_opts_kwargs["working_directory"] = cwd + if approval_policy := self._settings.get("approval_policy"): + thread_opts_kwargs["approval_policy"] = approval_policy + + # Apply default options (those not consumed by settings) + for key in ("sandbox_mode", "model_reasoning_effort", "additional_directories", "config_overrides"): + if key in self._default_options and self._default_options[key] is not None: + thread_opts_kwargs[key] = self._default_options[key] + + # Pass system prompt via config_overrides if set + system_prompt = self._default_options.get("system_prompt") + if system_prompt: + overrides = dict(thread_opts_kwargs.get("config_overrides") or {}) + overrides["instructions"] = system_prompt + thread_opts_kwargs["config_overrides"] = overrides + + # Write a temporary instructions file if we have a system prompt + if system_prompt and "model_instructions_file" not in thread_opts_kwargs: + import os + import tempfile + + fd, path = tempfile.mkstemp(suffix=".md", prefix="codex_instructions_") + try: + os.write(fd, system_prompt.encode("utf-8")) + finally: + os.close(fd) + thread_opts_kwargs["model_instructions_file"] = path + + return ThreadOptions(**thread_opts_kwargs) + + def _get_or_create_thread(self, session_id: str | None = None) -> Thread: + """Get or create a thread for the given session. + + If session_id matches the current thread, reuse it. + Otherwise, create a new thread or resume an existing one. + + Args: + session_id: The thread/session ID to resume, or None for a new thread. + + Returns: + A Thread instance. + """ + if self._client is None: + raise RuntimeError("Codex client not initialized. Call start() first.") + + # Reuse current thread if session matches + if self._current_thread is not None and session_id == self._current_thread_id: + return self._current_thread + + thread_opts = self._prepare_thread_options() + + if session_id: + thread = self._client.resume_thread(session_id, options=thread_opts) + else: + thread = self._client.start_thread(options=thread_opts) + + self._current_thread = thread + self._current_thread_id = session_id + + return thread + + def _format_prompt(self, messages: list[Message] | None) -> str: + """Format messages into a prompt string. + + Args: + messages: List of chat messages. + + Returns: + Formatted prompt string. + """ + if not messages: + return "" + return "\n".join([msg.text or "" for msg in messages]) + + @property + def default_options(self) -> dict[str, Any]: + """Expose options with ``instructions`` key. + + Maps ``system_prompt`` to ``instructions`` for compatibility with + :class:`AgentTelemetryLayer`, which reads the system prompt from + the ``instructions`` key. + """ + opts = dict(self._default_options) + system_prompt = opts.pop("system_prompt", None) + if system_prompt is not None: + opts["instructions"] = system_prompt + return opts + + def _finalize_response(self, updates: Sequence[AgentResponseUpdate]) -> AgentResponse[Any]: + """Build AgentResponse from collected updates. + + Args: + updates: The collected stream updates. + + Returns: + An AgentResponse built from the updates. + """ + return AgentResponse.from_updates(updates) + + @overload + def run( # type: ignore[override] + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + options: OptionsT | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]]: ... + + @overload + def run( # type: ignore[override] + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[True], + session: AgentSession | None = None, + options: OptionsT | None = None, + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + options: OptionsT | None = None, + **kwargs: Any, # type: ignore + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + """Run the agent with the given messages. + + Args: + messages: The messages to process. + + Keyword Args: + stream: If True, returns an async iterable of updates. If False (default), + returns an awaitable AgentResponse. + session: The conversation session. If session has service_session_id set, + the agent will resume that thread. + options: Runtime options (model can be changed per-request via config_overrides). + kwargs: Additional keyword arguments for compatibility with the shared agent + interface (e.g. compaction_strategy, tokenizer). Not used by CodexAgent. + + Returns: + When stream=True: An ResponseStream for streaming updates. + When stream=False: An Awaitable[AgentResponse] with the complete response. + """ + response = ResponseStream( + self._get_stream(messages, session=session, options=options), + finalizer=self._finalize_response, + ) + if stream: + return response + return response.get_final_response() + + async def _get_stream( + self, + messages: AgentRunInputs | None = None, + *, + session: AgentSession | None = None, + options: OptionsT | None = None, + ) -> AsyncIterable[AgentResponseUpdate]: + """Internal streaming implementation.""" + session = session or self.create_session() + + # Ensure client is initialized + if self._client is None: + await self.start() + + # Get or create thread for this session + thread = self._get_or_create_thread(session.service_session_id) + + prompt = self._format_prompt(normalize_messages(messages)) + + async for event in thread.run_streamed_events(prompt): + if isinstance(event, ItemUpdatedEvent): + item = event.item + if isinstance(item, AgentMessageItem): + # Yield text content from agent messages + if item.text: + yield AgentResponseUpdate( + role="assistant", + contents=[Content.from_text(text=item.text, raw_representation=event)], + raw_representation=event, + ) + elif isinstance(item, ReasoningItem): + # Yield reasoning/thinking content + if item.text: + yield AgentResponseUpdate( + role="assistant", + contents=[Content.from_text_reasoning(text=item.text, raw_representation=event)], + raw_representation=event, + ) + elif isinstance(item, ErrorItem): + raise AgentException(f"Codex API error: {item.message}") + + elif isinstance(event, TurnFailedEvent): + error = event.error + raise AgentException(f"Codex turn failed: {error}") + + elif isinstance(event, ThreadErrorEvent): + raise AgentException(f"Codex thread error: {event}") + + elif isinstance(event, TurnCompletedEvent): + # Turn completed — update session with thread ID + if thread.id: + session.service_session_id = thread.id + self._current_thread_id = thread.id + + +class CodexAgent(AgentMiddlewareLayer, AgentTelemetryLayer, RawCodexAgent[OptionsT], Generic[OptionsT]): + """OpenAI Codex Agent with middleware and OpenTelemetry instrumentation. + + This is the recommended agent class for most use cases. It includes + OpenTelemetry-based telemetry for observability and middleware support + for intercepting agent invocations. For a minimal implementation + without telemetry or middleware, use :class:`RawCodexAgent`. + + Examples: + Basic usage with context manager: + + .. code-block:: python + + from agent_framework_codex import CodexAgent + + async with CodexAgent( + instructions="You are a helpful coding assistant.", + ) as agent: + response = await agent.run("Hello!") + print(response.text) + """ + + def __init__( + self, + instructions: str | None = None, + *, + client: Codex | None = None, + id: str | None = None, + name: str | None = None, + description: str | None = None, + context_providers: Sequence[ContextProvider] | None = None, + middleware: Sequence[MiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | str | Sequence[ToolTypes | Callable[..., Any] | str] | None = None, + default_options: OptionsT | MutableMapping[str, Any] | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a CodexAgent with middleware and telemetry. + + Args: + instructions: System prompt for the agent. + + Keyword Args: + client: Optional pre-configured Codex instance. If not provided, + a new client will be created using the other parameters. + id: Unique identifier for the agent. + name: Name of the agent. + description: Description of the agent. + context_providers: Context providers for the agent. + middleware: Optional agent-level middleware for intercepting invocations. + tools: Tools for the agent. Can be: + - Strings for built-in tools (e.g., "Read", "Write", "Bash", "Glob") + - Functions for custom tools + default_options: Default CodexAgentOptions including system_prompt, model, etc. + env_file_path: Path to .env file. + env_file_encoding: Encoding of .env file. + """ + super().__init__( + instructions=instructions, + client=client, + id=id, + name=name, + description=description, + context_providers=context_providers, + middleware=middleware, + tools=tools, + default_options=default_options, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + @overload # type: ignore[override] + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[False] = ..., + session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + options: OptionsT | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + compaction_strategy: Any = None, + tokenizer: Any = None, + function_invocation_kwargs: dict[str, Any] | None = None, + client_kwargs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]]: ... + + @overload # type: ignore[override] + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: Literal[True], + session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + options: OptionsT | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + compaction_strategy: Any = None, + tokenizer: Any = None, + function_invocation_kwargs: dict[str, Any] | None = None, + client_kwargs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( # pyright: ignore[reportIncompatibleMethodOverride] # type: ignore[override] + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + options: OptionsT | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + compaction_strategy: Any = None, + tokenizer: Any = None, + function_invocation_kwargs: dict[str, Any] | None = None, + client_kwargs: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + """Run the Codex agent with middleware and telemetry enabled.""" + super_run = cast( + "Callable[..., Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]]", + super().run, + ) + return super_run( + messages=messages, + stream=stream, + session=session, + middleware=middleware, + options=options, + tools=tools, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, + **kwargs, + ) diff --git a/python/packages/codex/pyproject.toml b/python/packages/codex/pyproject.toml new file mode 100644 index 0000000000..bb1219c74e --- /dev/null +++ b/python/packages/codex/pyproject.toml @@ -0,0 +1,98 @@ +[project] +name = "agent-framework-codex" +description = "OpenAI Codex SDK integration for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260410" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.0.1,<2", + "codex-sdk-python>=0.117.0,<1", +] + +[tool.uv] +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [ + "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" +] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_codex"] +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_codex"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_codex" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_codex --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/codex/samples/codex_sample.py b/python/packages/codex/samples/codex_sample.py new file mode 100644 index 0000000000..fd5ca97529 --- /dev/null +++ b/python/packages/codex/samples/codex_sample.py @@ -0,0 +1,88 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Sample demonstrating CodexAgent usage with Agent Framework. + +This sample shows how to use the CodexAgent for: +- Basic non-streaming interaction +- Streaming responses +- Multi-turn conversations with sessions +- Custom tool integration + +Prerequisites: + pip install agent-framework-codex + export OPENAI_API_KEY="your-api-key" +""" + +import asyncio + +from agent_framework import tool + +from agent_framework_codex import CodexAgent + + +@tool +def get_weather(city: str) -> str: + """Get the current weather for a city.""" + # Stub implementation for demonstration + return f"The weather in {city} is 72°F and sunny." + + +async def basic_usage() -> None: + """Run a simple non-streaming query.""" + print("=== Basic Usage ===") + async with CodexAgent( + instructions="You are a helpful coding assistant.", + ) as agent: + response = await agent.run("What is a Python list comprehension? Give a short example.") + print(response.text) + print() + + +async def streaming_usage() -> None: + """Stream a response token-by-token.""" + print("=== Streaming ===") + async with CodexAgent() as agent: + async for update in agent.run( + "Write a short haiku about coding.", + stream=True, + ): + print(update.text, end="", flush=True) + print("\n") + + +async def session_usage() -> None: + """Demonstrate multi-turn conversation using sessions.""" + print("=== Session (Multi-turn) ===") + async with CodexAgent() as agent: + session = agent.create_session() + + await agent.run("My name is Alice and I'm working on a FastAPI project.", session=session) + print("(context set)") + + response = await agent.run("What's my name and what am I working on?", session=session) + print(response.text) + print() + + +async def tool_usage() -> None: + """Demonstrate custom tool integration.""" + print("=== Custom Tools ===") + async with CodexAgent( + instructions="Use the get_weather tool when asked about weather.", + tools=[get_weather], + ) as agent: + response = await agent.run("What's the weather like in Seattle?") + print(response.text) + print() + + +async def main() -> None: + """Run all samples.""" + await basic_usage() + await streaming_usage() + await session_usage() + await tool_usage() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/packages/codex/tests/test_codex_agent.py b/python/packages/codex/tests/test_codex_agent.py new file mode 100644 index 0000000000..4bd9443ba8 --- /dev/null +++ b/python/packages/codex/tests/test_codex_agent.py @@ -0,0 +1,543 @@ +# Copyright (c) Microsoft. All rights reserved. + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from agent_framework import AgentResponseUpdate, AgentSession, Content, Message, tool +from agent_framework._settings import load_settings + +from agent_framework_codex import CodexAgent, CodexAgentOptions, CodexAgentSettings + +# region Test CodexAgentSettings + + +class TestCodexAgentSettings: + """Tests for CodexAgentSettings.""" + + def test_default_values(self) -> None: + """Test default values are None.""" + settings = load_settings(CodexAgentSettings, env_prefix="CODEX_AGENT_") + assert settings["codex_path"] is None + assert settings["model"] is None + assert settings["cwd"] is None + assert settings["approval_policy"] is None + + def test_explicit_values(self) -> None: + """Test explicit values override defaults.""" + settings = load_settings( + CodexAgentSettings, + env_prefix="CODEX_AGENT_", + codex_path="/usr/local/bin/codex", + model="codex-mini-latest", + cwd="/home/user/project", + approval_policy="full-auto", + ) + assert settings["codex_path"] == "/usr/local/bin/codex" + assert settings["model"] == "codex-mini-latest" + assert settings["cwd"] == "/home/user/project" + assert settings["approval_policy"] == "full-auto" + + def test_env_variable_loading(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test loading from environment variables.""" + monkeypatch.setenv("CODEX_AGENT_MODEL", "gpt-5.1-codex") + settings = load_settings(CodexAgentSettings, env_prefix="CODEX_AGENT_") + assert settings["model"] == "gpt-5.1-codex" + + +# region Test CodexAgent Initialization + + +class TestCodexAgentInit: + """Tests for CodexAgent initialization.""" + + def test_default_initialization(self) -> None: + """Test agent initializes with defaults.""" + agent = CodexAgent() + assert agent.id is not None + assert agent.name is None + assert agent.description is None + + def test_with_name_and_description(self) -> None: + """Test agent with name and description.""" + agent = CodexAgent(name="test-agent", description="A test agent") + assert agent.name == "test-agent" + assert agent.description == "A test agent" + + def test_with_instructions_parameter(self) -> None: + """Test agent with instructions parameter.""" + agent = CodexAgent(instructions="You are a helpful assistant.") + assert agent._default_options.get("system_prompt") == "You are a helpful assistant." # type: ignore[reportPrivateUsage] + + def test_with_system_prompt_in_options(self) -> None: + """Test agent with system_prompt in options.""" + options: CodexAgentOptions = { + "system_prompt": "You are a helpful assistant.", + } + agent = CodexAgent(default_options=options) + assert agent._default_options.get("system_prompt") == "You are a helpful assistant." # type: ignore[reportPrivateUsage] + + def test_with_default_options(self) -> None: + """Test agent with default options.""" + options: CodexAgentOptions = { + "model": "codex-mini-latest", + } + agent = CodexAgent(default_options=options) + assert agent._settings["model"] == "codex-mini-latest" # type: ignore[reportPrivateUsage] + + def test_with_function_tool(self) -> None: + """Test agent with function tool.""" + + @tool + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + agent = CodexAgent(tools=[greet]) + assert len(agent._custom_tools) == 1 # type: ignore[reportPrivateUsage] + + def test_with_single_tool(self) -> None: + """Test agent with single tool (not in list).""" + + @tool + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + agent = CodexAgent(tools=greet) + assert len(agent._custom_tools) == 1 # type: ignore[reportPrivateUsage] + + def test_with_builtin_tools(self) -> None: + """Test agent with built-in tool names.""" + agent = CodexAgent(tools=["Read", "Write", "Bash"]) + assert agent._builtin_tools == ["Read", "Write", "Bash"] # type: ignore[reportPrivateUsage] + assert agent._custom_tools == [] # type: ignore[reportPrivateUsage] + + def test_with_mixed_tools(self) -> None: + """Test agent with both built-in and custom tools.""" + + @tool + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + agent = CodexAgent(tools=["Read", greet, "Bash"]) + assert agent._builtin_tools == ["Read", "Bash"] # type: ignore[reportPrivateUsage] + assert len(agent._custom_tools) == 1 # type: ignore[reportPrivateUsage] + + +# region Test CodexAgent Lifecycle + + +class TestCodexAgentLifecycle: + """Tests for CodexAgent lifecycle management.""" + + def test_custom_tools_stored_from_constructor(self) -> None: + """Test that custom tools from constructor are stored.""" + + @tool + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + agent = CodexAgent(tools=[greet]) + assert len(agent._custom_tools) == 1 # type: ignore[reportPrivateUsage] + + def test_multiple_custom_tools(self) -> None: + """Test agent with multiple custom tools.""" + + @tool + def greet(name: str) -> str: + """Greet someone.""" + return f"Hello, {name}!" + + @tool + def farewell(name: str) -> str: + """Say goodbye.""" + return f"Goodbye, {name}!" + + agent = CodexAgent(tools=[greet, farewell]) + assert len(agent._custom_tools) == 2 # type: ignore[reportPrivateUsage] + + def test_no_tools(self) -> None: + """Test agent without tools.""" + agent = CodexAgent() + assert agent._custom_tools == [] # type: ignore[reportPrivateUsage] + assert agent._builtin_tools == [] # type: ignore[reportPrivateUsage] + + +# region Test CodexAgent Run + + +def _make_mock_thread(events: list[Any]) -> MagicMock: + """Create a mock Thread that yields given events via run_streamed_events.""" + + async def _stream_events(*args: Any, **kwargs: Any) -> Any: + for event in events: + yield event + + mock_thread = MagicMock() + mock_thread.run_streamed_events = _stream_events + mock_thread.id = "thread-123" + return mock_thread + + +def _make_mock_codex(mock_thread: MagicMock) -> MagicMock: + """Create a mock Codex client that returns the given thread.""" + mock_codex = MagicMock() + mock_codex.start_thread.return_value = mock_thread + mock_codex.resume_thread.return_value = mock_thread + return mock_codex + + +class TestCodexAgentRun: + """Tests for CodexAgent run method.""" + + async def test_run_with_string_message(self) -> None: + """Test run with string message yields text from ItemUpdatedEvent.""" + from codex_sdk.events import ItemUpdatedEvent, TurnCompletedEvent, Usage + from codex_sdk.items import AgentMessageItem + + events = [ + ItemUpdatedEvent( + type="item.updated", + item=AgentMessageItem(id="msg-1", type="agent_message", text="Hello!"), + ), + TurnCompletedEvent( + type="turn.completed", + usage=Usage(input_tokens=10, cached_input_tokens=0, output_tokens=5), + ), + ] + mock_thread = _make_mock_thread(events) + mock_codex = _make_mock_codex(mock_thread) + + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): + agent = CodexAgent() + response = await agent.run("Hello") + assert response.text == "Hello!" + + async def test_run_captures_thread_id(self) -> None: + """Test that thread ID is captured from completed turn.""" + from codex_sdk.events import ItemUpdatedEvent, TurnCompletedEvent, Usage + from codex_sdk.items import AgentMessageItem + + events = [ + ItemUpdatedEvent( + type="item.updated", + item=AgentMessageItem(id="msg-1", type="agent_message", text="Response"), + ), + TurnCompletedEvent( + type="turn.completed", + usage=Usage(input_tokens=10, cached_input_tokens=0, output_tokens=5), + ), + ] + mock_thread = _make_mock_thread(events) + mock_thread.id = "test-thread-id" + mock_codex = _make_mock_codex(mock_thread) + + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): + agent = CodexAgent() + session = agent.create_session() + await agent.run("Hello", session=session) + assert session.service_session_id == "test-thread-id" + + async def test_run_with_existing_session(self) -> None: + """Test run with existing session resumes thread.""" + from codex_sdk.events import ItemUpdatedEvent, TurnCompletedEvent, Usage + from codex_sdk.items import AgentMessageItem + + events = [ + ItemUpdatedEvent( + type="item.updated", + item=AgentMessageItem(id="msg-1", type="agent_message", text="Response"), + ), + TurnCompletedEvent( + type="turn.completed", + usage=Usage(input_tokens=10, cached_input_tokens=0, output_tokens=5), + ), + ] + mock_thread = _make_mock_thread(events) + mock_codex = _make_mock_codex(mock_thread) + + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): + agent = CodexAgent() + session = agent.create_session() + session.service_session_id = "existing-thread" + await agent.run("Hello", session=session) + mock_codex.resume_thread.assert_called_once() + + +# region Test CodexAgent Run Stream + + +class TestCodexAgentRunStream: + """Tests for CodexAgent streaming run method.""" + + async def test_run_stream_yields_updates(self) -> None: + """Test run(stream=True) yields AgentResponseUpdate objects.""" + from codex_sdk.events import ItemUpdatedEvent, TurnCompletedEvent, Usage + from codex_sdk.items import AgentMessageItem + + events = [ + ItemUpdatedEvent( + type="item.updated", + item=AgentMessageItem(id="msg-1", type="agent_message", text="Streaming "), + ), + ItemUpdatedEvent( + type="item.updated", + item=AgentMessageItem(id="msg-2", type="agent_message", text="response"), + ), + TurnCompletedEvent( + type="turn.completed", + usage=Usage(input_tokens=10, cached_input_tokens=0, output_tokens=5), + ), + ] + mock_thread = _make_mock_thread(events) + mock_codex = _make_mock_codex(mock_thread) + + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): + agent = CodexAgent() + updates: list[AgentResponseUpdate] = [] + async for update in agent.run("Hello", stream=True): + updates.append(update) + assert len(updates) == 2 + assert updates[0].role == "assistant" + assert updates[0].text == "Streaming " + assert updates[1].text == "response" + + async def test_run_stream_yields_reasoning(self) -> None: + """Test run(stream=True) yields reasoning updates.""" + from codex_sdk.events import ItemUpdatedEvent, TurnCompletedEvent, Usage + from codex_sdk.items import AgentMessageItem, ReasoningItem + + events = [ + ItemUpdatedEvent( + type="item.updated", + item=ReasoningItem(id="reason-1", type="reasoning", text="Let me think..."), + ), + ItemUpdatedEvent( + type="item.updated", + item=AgentMessageItem(id="msg-1", type="agent_message", text="Hello!"), + ), + TurnCompletedEvent( + type="turn.completed", + usage=Usage(input_tokens=10, cached_input_tokens=0, output_tokens=5), + ), + ] + mock_thread = _make_mock_thread(events) + mock_codex = _make_mock_codex(mock_thread) + + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): + agent = CodexAgent() + updates: list[AgentResponseUpdate] = [] + async for update in agent.run("Hello", stream=True): + updates.append(update) + assert len(updates) == 2 + + async def test_run_stream_raises_on_error_item(self) -> None: + """Test run raises AgentException when ErrorItem is received.""" + from agent_framework.exceptions import AgentException + from codex_sdk.events import ItemUpdatedEvent + from codex_sdk.items import ErrorItem + + events = [ + ItemUpdatedEvent( + type="item.updated", + item=ErrorItem(id="err-1", type="error", message="API rate limit exceeded"), + ), + ] + mock_thread = _make_mock_thread(events) + mock_codex = _make_mock_codex(mock_thread) + + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): + agent = CodexAgent() + with pytest.raises(AgentException) as exc_info: + async for _ in agent.run("Hello", stream=True): + pass + assert "API rate limit exceeded" in str(exc_info.value) + + async def test_run_stream_raises_on_turn_failed(self) -> None: + """Test run raises AgentException when TurnFailedEvent is received.""" + from agent_framework.exceptions import AgentException + from codex_sdk.events import ThreadError, TurnFailedEvent + + events = [ + TurnFailedEvent( + type="turn.failed", + error=ThreadError(message="Model not found"), + ), + ] + mock_thread = _make_mock_thread(events) + mock_codex = _make_mock_codex(mock_thread) + + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): + agent = CodexAgent() + with pytest.raises(AgentException) as exc_info: + async for _ in agent.run("Hello", stream=True): + pass + assert "turn failed" in str(exc_info.value).lower() + + +# region Test CodexAgent Session Management + + +class TestCodexAgentSessionManagement: + """Tests for CodexAgent session management.""" + + def test_create_session(self) -> None: + """Test create_session creates a new session.""" + agent = CodexAgent() + session = agent.create_session() + assert isinstance(session, AgentSession) + assert session.service_session_id is None + + def test_create_session_with_service_session_id(self) -> None: + """Test create_session with existing service_session_id.""" + agent = CodexAgent() + session = agent.create_session(session_id="existing-session-123") + assert isinstance(session, AgentSession) + + def test_get_or_create_thread_starts_new_thread(self) -> None: + """Test _get_or_create_thread starts new thread when no session.""" + mock_codex = MagicMock() + mock_thread = MagicMock() + mock_codex.start_thread.return_value = mock_thread + + agent = CodexAgent() + agent._client = mock_codex # type: ignore[reportPrivateUsage] + + thread = agent._get_or_create_thread(None) # type: ignore[reportPrivateUsage] + mock_codex.start_thread.assert_called_once() + assert thread is mock_thread + + def test_get_or_create_thread_resumes_existing(self) -> None: + """Test _get_or_create_thread resumes thread when session ID provided.""" + mock_codex = MagicMock() + mock_thread = MagicMock() + mock_codex.resume_thread.return_value = mock_thread + + agent = CodexAgent() + agent._client = mock_codex # type: ignore[reportPrivateUsage] + + thread = agent._get_or_create_thread("existing-thread-123") # type: ignore[reportPrivateUsage] + # Verify resume_thread was called with the correct thread ID + args = mock_codex.resume_thread.call_args + assert args[0][0] == "existing-thread-123" + assert thread is mock_thread + + def test_get_or_create_thread_reuses_for_same_session(self) -> None: + """Test _get_or_create_thread reuses thread for same session.""" + mock_codex = MagicMock() + mock_thread = MagicMock() + mock_codex.start_thread.return_value = mock_thread + + agent = CodexAgent() + agent._client = mock_codex # type: ignore[reportPrivateUsage] + + # First call + thread1 = agent._get_or_create_thread(None) # type: ignore[reportPrivateUsage] + + # Same session should reuse + thread2 = agent._get_or_create_thread(None) # type: ignore[reportPrivateUsage] + + assert thread1 is thread2 + assert mock_codex.start_thread.call_count == 1 + + +# region Test CodexAgent Error Handling + + +class TestCodexAgentErrorHandling: + """Tests for CodexAgent error handling.""" + + async def test_handles_empty_response(self) -> None: + """Test handling of empty response (no events).""" + mock_thread = _make_mock_thread([]) + mock_codex = _make_mock_codex(mock_thread) + + with patch("agent_framework_codex._agent.Codex", return_value=mock_codex): + agent = CodexAgent() + response = await agent.run("Hello") + assert response.messages == [] + + +# region Test Format Prompt + + +class TestFormatPrompt: + """Tests for _format_prompt method.""" + + def test_format_empty_messages(self) -> None: + """Test formatting empty messages.""" + agent = CodexAgent() + result = agent._format_prompt([]) # type: ignore[reportPrivateUsage] + assert result == "" + + def test_format_none_messages(self) -> None: + """Test formatting None messages.""" + agent = CodexAgent() + result = agent._format_prompt(None) # type: ignore[reportPrivateUsage] + assert result == "" + + def test_format_user_message(self) -> None: + """Test formatting user message.""" + agent = CodexAgent() + msg = Message( + role="user", + contents=[Content.from_text(text="Hello")], + ) + result = agent._format_prompt([msg]) # type: ignore[reportPrivateUsage] + assert "Hello" in result + + def test_format_multiple_messages(self) -> None: + """Test formatting multiple messages.""" + agent = CodexAgent() + messages = [ + Message(role="user", contents=[Content.from_text(text="Hi")]), + Message(role="assistant", contents=[Content.from_text(text="Hello!")]), + Message(role="user", contents=[Content.from_text(text="How are you?")]), + ] + result = agent._format_prompt(messages) # type: ignore[reportPrivateUsage] + assert "Hi" in result + assert "Hello!" in result + assert "How are you?" in result + + +# region Test Default Options Property + + +class TestDefaultOptionsProperty: + """Tests for the default_options property.""" + + def test_default_options_maps_system_prompt_to_instructions(self) -> None: + """Test that default_options maps system_prompt to instructions.""" + agent = CodexAgent(instructions="Be helpful") + opts = agent.default_options + assert "instructions" in opts + assert opts["instructions"] == "Be helpful" + assert "system_prompt" not in opts + + def test_default_options_without_system_prompt(self) -> None: + """Test default_options without system_prompt.""" + agent = CodexAgent() + opts = agent.default_options + assert "instructions" not in opts + assert "system_prompt" not in opts + + +# region Test Approval Policy + + +class TestCodexAgentApprovalPolicy: + """Tests for CodexAgent approval policy handling.""" + + def test_default_approval_policy(self) -> None: + """Test default approval policy is None.""" + agent = CodexAgent() + assert agent._settings["approval_policy"] is None # type: ignore[reportPrivateUsage] + + def test_approval_policy_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test approval policy from environment settings.""" + monkeypatch.setenv("CODEX_AGENT_APPROVAL_POLICY", "full-auto") + settings = load_settings(CodexAgentSettings, env_prefix="CODEX_AGENT_") + assert settings["approval_policy"] == "full-auto" diff --git a/python/pyproject.toml b/python/pyproject.toml index a3876f34dd..3ec7f5c6b2 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -74,6 +74,7 @@ agent-framework-azurefunctions = { workspace = true } agent-framework-bedrock = { workspace = true } agent-framework-chatkit = { workspace = true } agent-framework-claude = { workspace = true } +agent-framework-codex = { workspace = true } agent-framework-copilotstudio = { workspace = true } agent-framework-declarative = { workspace = true } agent-framework-devui = { workspace = true } diff --git a/python/uv.lock b/python/uv.lock index 370fd7e46d..8f1f6952f4 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -36,6 +36,7 @@ members = [ "agent-framework-bedrock", "agent-framework-chatkit", "agent-framework-claude", + "agent-framework-codex", "agent-framework-copilotstudio", "agent-framework-core", "agent-framework-declarative", @@ -303,6 +304,21 @@ requires-dist = [ { name = "claude-agent-sdk", specifier = ">=0.1.36,<0.1.49" }, ] +[[package]] +name = "agent-framework-codex" +version = "1.0.0a260410" +source = { editable = "packages/codex" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "codex-sdk-python", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "codex-sdk-python", specifier = ">=0.117.0,<1" }, +] + [[package]] name = "agent-framework-copilotstudio" version = "1.0.0b260409" @@ -543,7 +559,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.2.1,<=0.2.1" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=0.2.1,>=0.2.1" }, ] [[package]] @@ -1466,6 +1482,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/61/cf819f8e8bb4d4c74661acf2498ba8d4a296714be3478d21eaabf64f5b9b/clr_loader-0.2.10-py3-none-any.whl", hash = "sha256:ebbbf9d511a7fe95fa28a95a4e04cd195b097881dfe66158dc2c281d3536f282", size = 56483, upload-time = "2026-01-03T23:13:05.439Z" }, ] +[[package]] +name = "codex-sdk-python" +version = "0.117.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/2a/6bc2c6695eb4b551fed25ce1e96f8026dc1b99e16595b29a862857e748b3/codex_sdk_python-0.117.0.tar.gz", hash = "sha256:ecbf0f023cbdbe30eb4dc9660933e2df83dfcc8b8b380c37e859366831aef722", size = 71231, upload-time = "2026-03-30T01:42:24.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/d6/88053336495eabace0178421df746c060ee1c7d2865bdb0796b37bd01bd3/codex_sdk_python-0.117.0-py3-none-any.whl", hash = "sha256:c25c7ace9d01d4d6e93a5ee6d8d75236175bbff6049c607b172e104937c52d27", size = 67399, upload-time = "2026-03-30T01:42:23.826Z" }, +] + [[package]] name = "colorama" version = "0.4.6"