Skip to content

Commit c3ccecd

Browse files
authored
feat: add experimental Codex extension and tool (#2320)
1 parent 86acfb4 commit c3ccecd

File tree

16 files changed

+4345
-0
lines changed

16 files changed

+4345
-0
lines changed

examples/tools/codex.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import asyncio
2+
from datetime import datetime
3+
4+
from agents import Agent, Runner, gen_trace_id, trace
5+
6+
# This tool is still in experimental phase and the details could be changed until being GAed.
7+
from agents.extensions.experimental.codex import (
8+
CodexToolStreamEvent,
9+
CommandExecutionItem,
10+
ErrorItem,
11+
FileChangeItem,
12+
ItemCompletedEvent,
13+
ItemStartedEvent,
14+
ItemUpdatedEvent,
15+
McpToolCallItem,
16+
ReasoningItem,
17+
ThreadErrorEvent,
18+
ThreadOptions,
19+
ThreadStartedEvent,
20+
TodoListItem,
21+
TurnCompletedEvent,
22+
TurnFailedEvent,
23+
TurnOptions,
24+
TurnStartedEvent,
25+
WebSearchItem,
26+
codex_tool,
27+
)
28+
29+
# This example runs the Codex CLI via the Codex tool wrapper.
30+
# You can configure the CLI path with CODEX_PATH or CodexOptions(codex_path_override="...").
31+
# codex_tool accepts options as keyword arguments or a plain dict.
32+
# For example: codex_tool(sandbox_mode="read-only") or codex_tool({"sandbox_mode": "read-only"}).
33+
# The prompt below asks Codex to use the $openai-knowledge skill (Docs MCP) for API lookups.
34+
35+
36+
async def on_codex_stream(payload: CodexToolStreamEvent) -> None:
37+
event = payload.event
38+
39+
if isinstance(event, ThreadStartedEvent):
40+
log(f"codex thread started: {event.thread_id}")
41+
return
42+
if isinstance(event, TurnStartedEvent):
43+
log("codex turn started")
44+
return
45+
if isinstance(event, TurnCompletedEvent):
46+
usage = event.usage
47+
log(f"codex turn completed, usage: {usage}")
48+
return
49+
if isinstance(event, TurnFailedEvent):
50+
error = event.error.message
51+
log(f"codex turn failed: {error}")
52+
return
53+
if isinstance(event, ThreadErrorEvent):
54+
log(f"codex stream error: {event.message}")
55+
return
56+
57+
if not isinstance(event, (ItemStartedEvent, ItemUpdatedEvent, ItemCompletedEvent)):
58+
return
59+
60+
item = event.item
61+
62+
if isinstance(item, ReasoningItem):
63+
text = item.text
64+
log(f"codex reasoning ({event.type}): {text}")
65+
return
66+
if isinstance(item, CommandExecutionItem):
67+
command = item.command
68+
output = item.aggregated_output
69+
output_preview = output[-200:] if isinstance(output, str) else ""
70+
status = item.status
71+
log(f"codex command {event.type}: {command} | status={status} | output={output_preview}")
72+
return
73+
if isinstance(item, McpToolCallItem):
74+
server = item.server
75+
tool = item.tool
76+
status = item.status
77+
log(f"codex mcp {event.type}: {server}.{tool} | status={status}")
78+
return
79+
if isinstance(item, FileChangeItem):
80+
changes = item.changes
81+
status = item.status
82+
log(f"codex file change {event.type}: {status} | {changes}")
83+
return
84+
if isinstance(item, WebSearchItem):
85+
log(f"codex web search {event.type}: {item.query}")
86+
return
87+
if isinstance(item, TodoListItem):
88+
items = item.items
89+
log(f"codex todo list {event.type}: {len(items)} items")
90+
return
91+
if isinstance(item, ErrorItem):
92+
log(f"codex error {event.type}: {item.message}")
93+
94+
95+
def _timestamp() -> str:
96+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
97+
98+
99+
def log(message: str) -> None:
100+
timestamp = _timestamp()
101+
lines = str(message).splitlines() or [""]
102+
for line in lines:
103+
print(f"{timestamp} {line}")
104+
105+
106+
async def main() -> None:
107+
agent = Agent(
108+
name="Codex Agent",
109+
instructions=(
110+
"Use the codex tool to inspect the workspace and answer the question. "
111+
"When skill names, which usually starts with `$`, are mentioned, "
112+
"you must rely on the codex tool to use the skill and answer the question.\n\n"
113+
"When you send the final answer, you must include the following info at the end:\n\n"
114+
"Run `codex resume <thread_id>` to continue the codex session."
115+
),
116+
tools=[
117+
# Run local Codex CLI as a sub process
118+
codex_tool(
119+
sandbox_mode="workspace-write",
120+
default_thread_options=ThreadOptions(
121+
# You can pass a Codex instance to customize CLI details
122+
# codex=Codex(executable_path="/path/to/codex", base_url="..."),
123+
model="gpt-5.2-codex",
124+
model_reasoning_effort="low",
125+
network_access_enabled=True,
126+
web_search_enabled=False,
127+
approval_policy="never", # We'll update this example once the HITL is implemented
128+
),
129+
default_turn_options=TurnOptions(
130+
# Abort Codex CLI if no events arrive within this many seconds.
131+
idle_timeout_seconds=60,
132+
),
133+
on_stream=on_codex_stream,
134+
)
135+
],
136+
)
137+
trace_id = gen_trace_id()
138+
log(f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}")
139+
140+
with trace("Codex tool example", trace_id=trace_id):
141+
# Use a skill that requires network access and MCP server settings
142+
log("Using $openai-knowledge skill to fetch the latest realtime model name...")
143+
result = await Runner.run(
144+
agent,
145+
"You must use `$openai-knowledge` skill to fetch the latest realtime model name.",
146+
)
147+
log(result.final_output)
148+
# The latest realtime model name, according to the $openai-knowledge skill, is gpt-realtime.
149+
150+
# Use a skill that runs local command and analyzes the output
151+
log(
152+
"Using $test-coverage-improver skill to analyze the test coverage of the project and improve it..."
153+
)
154+
result = await Runner.run(
155+
agent,
156+
"You must use `$test-coverage-improver` skill to analyze the test coverage of the project and improve it.",
157+
)
158+
log(result.final_output)
159+
# (Aa few suggestions for improving the test coverage will be displayed.)
160+
161+
162+
if __name__ == "__main__":
163+
asyncio.run(main())
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# This package contains experimental extensions to the agents package.
2+
# The interface and implementation details could be changed until being GAed.
3+
4+
__all__ = [
5+
"codex",
6+
]
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from .codex import Codex
2+
from .codex_options import CodexOptions
3+
from .codex_tool import (
4+
CodexToolOptions,
5+
CodexToolResult,
6+
CodexToolStreamEvent,
7+
OutputSchemaDescriptor,
8+
codex_tool,
9+
)
10+
from .events import (
11+
ItemCompletedEvent,
12+
ItemStartedEvent,
13+
ItemUpdatedEvent,
14+
ThreadError,
15+
ThreadErrorEvent,
16+
ThreadEvent,
17+
ThreadStartedEvent,
18+
TurnCompletedEvent,
19+
TurnFailedEvent,
20+
TurnStartedEvent,
21+
Usage,
22+
)
23+
from .items import (
24+
AgentMessageItem,
25+
CommandExecutionItem,
26+
ErrorItem,
27+
FileChangeItem,
28+
FileUpdateChange,
29+
McpToolCallError,
30+
McpToolCallItem,
31+
McpToolCallResult,
32+
ReasoningItem,
33+
ThreadItem,
34+
TodoItem,
35+
TodoListItem,
36+
WebSearchItem,
37+
)
38+
from .thread import Input, RunResult, RunStreamedResult, Thread, Turn, UserInput
39+
from .thread_options import (
40+
ApprovalMode,
41+
ModelReasoningEffort,
42+
SandboxMode,
43+
ThreadOptions,
44+
WebSearchMode,
45+
)
46+
from .turn_options import TurnOptions
47+
48+
__all__ = [
49+
"Codex",
50+
"CodexOptions",
51+
"Thread",
52+
"Turn",
53+
"RunResult",
54+
"RunStreamedResult",
55+
"Input",
56+
"UserInput",
57+
"ThreadOptions",
58+
"TurnOptions",
59+
"ApprovalMode",
60+
"SandboxMode",
61+
"ModelReasoningEffort",
62+
"WebSearchMode",
63+
"ThreadEvent",
64+
"ThreadStartedEvent",
65+
"TurnStartedEvent",
66+
"TurnCompletedEvent",
67+
"TurnFailedEvent",
68+
"ItemStartedEvent",
69+
"ItemUpdatedEvent",
70+
"ItemCompletedEvent",
71+
"ThreadError",
72+
"ThreadErrorEvent",
73+
"Usage",
74+
"ThreadItem",
75+
"AgentMessageItem",
76+
"ReasoningItem",
77+
"CommandExecutionItem",
78+
"FileChangeItem",
79+
"FileUpdateChange",
80+
"McpToolCallItem",
81+
"McpToolCallResult",
82+
"McpToolCallError",
83+
"WebSearchItem",
84+
"TodoItem",
85+
"TodoListItem",
86+
"ErrorItem",
87+
"codex_tool",
88+
"CodexToolOptions",
89+
"CodexToolResult",
90+
"CodexToolStreamEvent",
91+
"OutputSchemaDescriptor",
92+
]
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Mapping
4+
from typing import Any, overload
5+
6+
from agents.exceptions import UserError
7+
8+
from .codex_options import CodexOptions, coerce_codex_options
9+
from .exec import CodexExec
10+
from .thread import Thread
11+
from .thread_options import ThreadOptions, coerce_thread_options
12+
13+
14+
class _UnsetType:
15+
pass
16+
17+
18+
_UNSET = _UnsetType()
19+
20+
21+
class Codex:
22+
@overload
23+
def __init__(self, options: CodexOptions | Mapping[str, Any] | None = None) -> None: ...
24+
25+
@overload
26+
def __init__(
27+
self,
28+
*,
29+
codex_path_override: str | None = None,
30+
base_url: str | None = None,
31+
api_key: str | None = None,
32+
env: Mapping[str, str] | None = None,
33+
) -> None: ...
34+
35+
def __init__(
36+
self,
37+
options: CodexOptions | Mapping[str, Any] | None = None,
38+
*,
39+
codex_path_override: str | None | _UnsetType = _UNSET,
40+
base_url: str | None | _UnsetType = _UNSET,
41+
api_key: str | None | _UnsetType = _UNSET,
42+
env: Mapping[str, str] | None | _UnsetType = _UNSET,
43+
) -> None:
44+
kw_values = {
45+
"codex_path_override": codex_path_override,
46+
"base_url": base_url,
47+
"api_key": api_key,
48+
"env": env,
49+
}
50+
has_kwargs = any(value is not _UNSET for value in kw_values.values())
51+
if options is not None and has_kwargs:
52+
raise UserError(
53+
"Codex options must be provided as a CodexOptions/mapping or keyword arguments, "
54+
"not both."
55+
)
56+
if has_kwargs:
57+
options = {key: value for key, value in kw_values.items() if value is not _UNSET}
58+
resolved_options = coerce_codex_options(options) or CodexOptions()
59+
self._exec = CodexExec(
60+
executable_path=resolved_options.codex_path_override,
61+
env=_normalize_env(resolved_options),
62+
)
63+
self._options = resolved_options
64+
65+
def start_thread(self, options: ThreadOptions | Mapping[str, Any] | None = None) -> Thread:
66+
resolved_options = coerce_thread_options(options) or ThreadOptions()
67+
return Thread(
68+
exec_client=self._exec,
69+
options=self._options,
70+
thread_options=resolved_options,
71+
)
72+
73+
def resume_thread(
74+
self, thread_id: str, options: ThreadOptions | Mapping[str, Any] | None = None
75+
) -> Thread:
76+
resolved_options = coerce_thread_options(options) or ThreadOptions()
77+
return Thread(
78+
exec_client=self._exec,
79+
options=self._options,
80+
thread_options=resolved_options,
81+
thread_id=thread_id,
82+
)
83+
84+
85+
def _normalize_env(options: CodexOptions) -> dict[str, str] | None:
86+
if options.env is None:
87+
return None
88+
# Normalize mapping values to strings for subprocess environment.
89+
return {str(key): str(value) for key, value in options.env.items()}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Mapping
4+
from dataclasses import dataclass, fields
5+
from typing import Any
6+
7+
from agents.exceptions import UserError
8+
9+
10+
@dataclass(frozen=True)
11+
class CodexOptions:
12+
# Optional absolute path to the codex CLI binary.
13+
codex_path_override: str | None = None
14+
# Override OpenAI base URL for the Codex CLI process.
15+
base_url: str | None = None
16+
# API key passed to the Codex CLI (CODEX_API_KEY).
17+
api_key: str | None = None
18+
# Environment variables for the Codex CLI process (do not inherit os.environ).
19+
env: Mapping[str, str] | None = None
20+
21+
22+
def coerce_codex_options(
23+
options: CodexOptions | Mapping[str, Any] | None,
24+
) -> CodexOptions | None:
25+
if options is None or isinstance(options, CodexOptions):
26+
return options
27+
if not isinstance(options, Mapping):
28+
raise UserError("CodexOptions must be a CodexOptions or a mapping.")
29+
30+
allowed = {field.name for field in fields(CodexOptions)}
31+
unknown = set(options.keys()) - allowed
32+
if unknown:
33+
raise UserError(f"Unknown CodexOptions field(s): {sorted(unknown)}")
34+
35+
return CodexOptions(**dict(options))

0 commit comments

Comments
 (0)