Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions examples/tools/codex.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import asyncio
from datetime import datetime

from agents import Agent, Runner, gen_trace_id, trace

# This tool is still in experimental phase and the details could be changed until being GAed.
from agents.extensions.experimental.codex import (
CodexToolStreamEvent,
CommandExecutionItem,
ErrorItem,
FileChangeItem,
ItemCompletedEvent,
ItemStartedEvent,
ItemUpdatedEvent,
McpToolCallItem,
ReasoningItem,
ThreadErrorEvent,
ThreadOptions,
ThreadStartedEvent,
TodoListItem,
TurnCompletedEvent,
TurnFailedEvent,
TurnOptions,
TurnStartedEvent,
WebSearchItem,
codex_tool,
)

# This example runs the Codex CLI via the Codex tool wrapper.
# You can configure the CLI path with CODEX_PATH or CodexOptions(codex_path_override="...").
# codex_tool accepts options as keyword arguments or a plain dict.
# For example: codex_tool(sandbox_mode="read-only") or codex_tool({"sandbox_mode": "read-only"}).
# The prompt below asks Codex to use the $openai-knowledge skill (Docs MCP) for API lookups.


async def on_codex_stream(payload: CodexToolStreamEvent) -> None:
event = payload.event

if isinstance(event, ThreadStartedEvent):
log(f"codex thread started: {event.thread_id}")
return
if isinstance(event, TurnStartedEvent):
log("codex turn started")
return
if isinstance(event, TurnCompletedEvent):
usage = event.usage
log(f"codex turn completed, usage: {usage}")
return
if isinstance(event, TurnFailedEvent):
error = event.error.message
log(f"codex turn failed: {error}")
return
if isinstance(event, ThreadErrorEvent):
log(f"codex stream error: {event.message}")
return

if not isinstance(event, (ItemStartedEvent, ItemUpdatedEvent, ItemCompletedEvent)):
return

item = event.item

if isinstance(item, ReasoningItem):
text = item.text
log(f"codex reasoning ({event.type}): {text}")
return
if isinstance(item, CommandExecutionItem):
command = item.command
output = item.aggregated_output
output_preview = output[-200:] if isinstance(output, str) else ""
status = item.status
log(f"codex command {event.type}: {command} | status={status} | output={output_preview}")
return
if isinstance(item, McpToolCallItem):
server = item.server
tool = item.tool
status = item.status
log(f"codex mcp {event.type}: {server}.{tool} | status={status}")
return
if isinstance(item, FileChangeItem):
changes = item.changes
status = item.status
log(f"codex file change {event.type}: {status} | {changes}")
return
if isinstance(item, WebSearchItem):
log(f"codex web search {event.type}: {item.query}")
return
if isinstance(item, TodoListItem):
items = item.items
log(f"codex todo list {event.type}: {len(items)} items")
return
if isinstance(item, ErrorItem):
log(f"codex error {event.type}: {item.message}")


def _timestamp() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def log(message: str) -> None:
timestamp = _timestamp()
lines = str(message).splitlines() or [""]
for line in lines:
print(f"{timestamp} {line}")


async def main() -> None:
agent = Agent(
name="Codex Agent",
instructions=(
"Use the codex tool to inspect the workspace and answer the question. "
"When skill names, which usually starts with `$`, are mentioned, "
"you must rely on the codex tool to use the skill and answer the question.\n\n"
"When you send the final answer, you must include the following info at the end:\n\n"
"Run `codex resume <thread_id>` to continue the codex session."
),
tools=[
# Run local Codex CLI as a sub process
codex_tool(
sandbox_mode="workspace-write",
default_thread_options=ThreadOptions(
# You can pass a Codex instance to customize CLI details
# codex=Codex(executable_path="/path/to/codex", base_url="..."),
model="gpt-5.2-codex",
model_reasoning_effort="low",
network_access_enabled=True,
web_search_enabled=False,
approval_policy="never", # We'll update this example once the HITL is implemented
),
default_turn_options=TurnOptions(
# Abort Codex CLI if no events arrive within this many seconds.
idle_timeout_seconds=60,
),
on_stream=on_codex_stream,
)
],
)
trace_id = gen_trace_id()
log(f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}")

with trace("Codex tool example", trace_id=trace_id):
# Use a skill that requires network access and MCP server settings
log("Using $openai-knowledge skill to fetch the latest realtime model name...")
result = await Runner.run(
agent,
"You must use `$openai-knowledge` skill to fetch the latest realtime model name.",
)
log(result.final_output)
# The latest realtime model name, according to the $openai-knowledge skill, is gpt-realtime.

# Use a skill that runs local command and analyzes the output
log(
"Using $test-coverage-improver skill to analyze the test coverage of the project and improve it..."
)
result = await Runner.run(
agent,
"You must use `$test-coverage-improver` skill to analyze the test coverage of the project and improve it.",
)
log(result.final_output)
# (Aa few suggestions for improving the test coverage will be displayed.)


if __name__ == "__main__":
asyncio.run(main())
6 changes: 6 additions & 0 deletions src/agents/extensions/experimental/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# This package contains experimental extensions to the agents package.
# The interface and implementation details could be changed until being GAed.

__all__ = [
"codex",
]
92 changes: 92 additions & 0 deletions src/agents/extensions/experimental/codex/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from .codex import Codex
from .codex_options import CodexOptions
from .codex_tool import (
CodexToolOptions,
CodexToolResult,
CodexToolStreamEvent,
OutputSchemaDescriptor,
codex_tool,
)
from .events import (
ItemCompletedEvent,
ItemStartedEvent,
ItemUpdatedEvent,
ThreadError,
ThreadErrorEvent,
ThreadEvent,
ThreadStartedEvent,
TurnCompletedEvent,
TurnFailedEvent,
TurnStartedEvent,
Usage,
)
from .items import (
AgentMessageItem,
CommandExecutionItem,
ErrorItem,
FileChangeItem,
FileUpdateChange,
McpToolCallError,
McpToolCallItem,
McpToolCallResult,
ReasoningItem,
ThreadItem,
TodoItem,
TodoListItem,
WebSearchItem,
)
from .thread import Input, RunResult, RunStreamedResult, Thread, Turn, UserInput
from .thread_options import (
ApprovalMode,
ModelReasoningEffort,
SandboxMode,
ThreadOptions,
WebSearchMode,
)
from .turn_options import TurnOptions

__all__ = [
"Codex",
"CodexOptions",
"Thread",
"Turn",
"RunResult",
"RunStreamedResult",
"Input",
"UserInput",
"ThreadOptions",
"TurnOptions",
"ApprovalMode",
"SandboxMode",
"ModelReasoningEffort",
"WebSearchMode",
"ThreadEvent",
"ThreadStartedEvent",
"TurnStartedEvent",
"TurnCompletedEvent",
"TurnFailedEvent",
"ItemStartedEvent",
"ItemUpdatedEvent",
"ItemCompletedEvent",
"ThreadError",
"ThreadErrorEvent",
"Usage",
"ThreadItem",
"AgentMessageItem",
"ReasoningItem",
"CommandExecutionItem",
"FileChangeItem",
"FileUpdateChange",
"McpToolCallItem",
"McpToolCallResult",
"McpToolCallError",
"WebSearchItem",
"TodoItem",
"TodoListItem",
"ErrorItem",
"codex_tool",
"CodexToolOptions",
"CodexToolResult",
"CodexToolStreamEvent",
"OutputSchemaDescriptor",
]
89 changes: 89 additions & 0 deletions src/agents/extensions/experimental/codex/codex.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from __future__ import annotations

from collections.abc import Mapping
from typing import Any, overload

from agents.exceptions import UserError

from .codex_options import CodexOptions, coerce_codex_options
from .exec import CodexExec
from .thread import Thread
from .thread_options import ThreadOptions, coerce_thread_options


class _UnsetType:
pass


_UNSET = _UnsetType()


class Codex:
@overload
def __init__(self, options: CodexOptions | Mapping[str, Any] | None = None) -> None: ...

@overload
def __init__(
self,
*,
codex_path_override: str | None = None,
base_url: str | None = None,
api_key: str | None = None,
env: Mapping[str, str] | None = None,
) -> None: ...

def __init__(
self,
options: CodexOptions | Mapping[str, Any] | None = None,
*,
codex_path_override: str | None | _UnsetType = _UNSET,
base_url: str | None | _UnsetType = _UNSET,
api_key: str | None | _UnsetType = _UNSET,
env: Mapping[str, str] | None | _UnsetType = _UNSET,
) -> None:
kw_values = {
"codex_path_override": codex_path_override,
"base_url": base_url,
"api_key": api_key,
"env": env,
}
has_kwargs = any(value is not _UNSET for value in kw_values.values())
if options is not None and has_kwargs:
raise UserError(
"Codex options must be provided as a CodexOptions/mapping or keyword arguments, "
"not both."
)
if has_kwargs:
options = {key: value for key, value in kw_values.items() if value is not _UNSET}
resolved_options = coerce_codex_options(options) or CodexOptions()
self._exec = CodexExec(
executable_path=resolved_options.codex_path_override,
env=_normalize_env(resolved_options),
)
self._options = resolved_options

def start_thread(self, options: ThreadOptions | Mapping[str, Any] | None = None) -> Thread:
resolved_options = coerce_thread_options(options) or ThreadOptions()
return Thread(
exec_client=self._exec,
options=self._options,
thread_options=resolved_options,
)

def resume_thread(
self, thread_id: str, options: ThreadOptions | Mapping[str, Any] | None = None
) -> Thread:
resolved_options = coerce_thread_options(options) or ThreadOptions()
return Thread(
exec_client=self._exec,
options=self._options,
thread_options=resolved_options,
thread_id=thread_id,
)


def _normalize_env(options: CodexOptions) -> dict[str, str] | None:
if options.env is None:
return None
# Normalize mapping values to strings for subprocess environment.
return {str(key): str(value) for key, value in options.env.items()}
35 changes: 35 additions & 0 deletions src/agents/extensions/experimental/codex/codex_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, fields
from typing import Any

from agents.exceptions import UserError


@dataclass(frozen=True)
class CodexOptions:
# Optional absolute path to the codex CLI binary.
codex_path_override: str | None = None
# Override OpenAI base URL for the Codex CLI process.
base_url: str | None = None
# API key passed to the Codex CLI (CODEX_API_KEY).
api_key: str | None = None
# Environment variables for the Codex CLI process (do not inherit os.environ).
env: Mapping[str, str] | None = None


def coerce_codex_options(
options: CodexOptions | Mapping[str, Any] | None,
) -> CodexOptions | None:
if options is None or isinstance(options, CodexOptions):
return options
if not isinstance(options, Mapping):
raise UserError("CodexOptions must be a CodexOptions or a mapping.")

allowed = {field.name for field in fields(CodexOptions)}
unknown = set(options.keys()) - allowed
if unknown:
raise UserError(f"Unknown CodexOptions field(s): {sorted(unknown)}")

return CodexOptions(**dict(options))
Loading