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
27 changes: 26 additions & 1 deletion src/agent_env/agents/agents/codex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Codex reads its settings from `$CODEX_HOME/config.toml`, which the agent writes:

| Extension | Implementation |
|---|---|
| `agent-config/v1` | `CodexConfig`: `model`, `effort` (Codex's `model_reasoning_effort`; a set without it clears it), `system_prompt` (Codex's `developer_instructions`), `output_format` (a JSON schema, as `--output-schema`), `project_id`, `task_id`, `timeout_seconds` |
| `agent-config/v1` | `CodexConfig`: `model`, `effort` (Codex's `model_reasoning_effort`; a set without it clears it), `system_prompt` (Codex's `developer_instructions`), `output_format` (a JSON schema, as `--output-schema`), `project_id`, `task_id`, `timeout_seconds`, `builtin_tools` (below) |
| `mcp-config/v1` | SDK default; written to `config.toml` before each task |
| `skill-config/v1` | inline and bundle skills installed as `~/.agents/skills/<name>/SKILL.md`, where Codex finds them |
| `trajectory/v1` | the task's `codex exec --json` events, live while it runs (format `codex-exec-json/1`) |
Expand All @@ -38,3 +38,28 @@ Codex reads its settings from `$CODEX_HOME/config.toml`, which the agent writes:

The image is `python:3.11-slim` with Node, the CLIs and the agent, running as the `agent` user; Codex's state is in
`/home/agent/.codex` whatever the uid.

A `deploy_agent` `builtin_tools` selection turns the CLI's own tools off with `-c` overrides on the task's CLI alone;
config.toml stays as it is. `{"disable": "*"}` turns off every setting below, `{"only": [...]}` every one but those whose
tools it names, and a list to turn off those whose tools it names. A list names all of a setting's tools or none:

| Tools | Override |
|---|---|
| `exec_command`, `write_stdin` | `features.shell_tool=false` |
| `update_plan` | `tools.update_plan.enabled=false` |
| `request_user_input` | `tools.experimental_request_user_input.enabled=false` |
| `view_image` | `features.view_image=false` |
| `spawn_agent`, `send_message`, `wait_agent`, `followup_task`, `interrupt_agent`, `list_agents` | `features.multi_agent_v2=false` and `features.multi_agent=false`, since the v1 tools come back behind `tool_search` otherwise |
| `create_goal`, `get_goal`, `update_goal` | `features.goals=false` |
| `web_search` | `web_search="disabled"` |

No setting turns off `apply_patch`: a model's entry in Codex's model catalog offers it (every GPT model 0.147.0 ships
with does). So a selection without it (`{"disable": "*"}`, a list naming it, or an `{"only": [...]}` that doesn't)
writes a copy of the catalog the CLI would use, with `apply_patch_tool_type` null for every model, to
`$CODEX_HOME/model_catalog_without_apply_patch.json`, and points the task's CLI at it with `-c model_catalog_json=...`.
The copy is made from `codex debug models --bundled`, or from the file config.toml's own `model_catalog_json` names.

The MCP tools agent-env gives the agent stay with any selection, and so do the tools Codex adds for them:
`tool_search`, `list_mcp_resources`, `list_mcp_resource_templates` and `read_mcp_resource`. The names are `CodexTool`'s
in `codex_config.py`; a test fails when package.json's `@openai/codex` moves, until `CodexTool` and
`TOOLS_CLI_VERSION` move with it.
18 changes: 16 additions & 2 deletions src/agent_env/agents/agents/codex/a2a_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import shutil
import tarfile
import tempfile
import time
import uuid
from collections.abc import Awaitable, Callable
from contextlib import asynccontextmanager, suppress
Expand Down Expand Up @@ -235,6 +236,8 @@ class CodexConfig(AgentConfig):
project_id: str | None = None
task_id: str | None = None
timeout_seconds: int = 1800
# Which of the CLI's own tools it may use. With any selection, the MCP tools agent-env gives it stay.
builtin_tools: codex_config.CodexTools | None = None


@asynccontextmanager
Expand Down Expand Up @@ -666,6 +669,15 @@ def _record_event(event: dict) -> None:

try:
await codex_config.write_mcp_servers(request.mcp_servers)
# The catalog read a selection may need counts against the turn's time limit.
started = time.monotonic()
catalog = await codex_config.catalog_overrides(cfg.builtin_tools, cfg.timeout_seconds)
remaining_s = cfg.timeout_seconds
if catalog:
remaining_s -= time.monotonic() - started
if remaining_s <= 0:
raise RuntimeError(
f"no time left of the turn's {cfg.timeout_seconds}s after the catalog read")
async with _workspace_barrier.shared():
result = await run_codex(
prompt=prompt,
Expand All @@ -674,12 +686,14 @@ def _record_event(event: dict) -> None:
effort=cfg.effort,
system_prompt=cfg.system_prompt,
output_format=cfg.output_format,
timeout_s=cfg.timeout_seconds,
timeout_s=remaining_s,
# Forward LiteLLM attribution per subprocess.
project_id=cfg.project_id,
task_id=cfg.task_id,
event_callback=_record_event,
config_overrides=codex_config.role_overrides(request.mcp_servers, cfg.role),
config_overrides=[*codex_config.role_overrides(request.mcp_servers, cfg.role),
*codex_config.builtin_tools_overrides(cfg.builtin_tools),
*catalog],
)
except Exception as e:
logger.error(f"codex CLI invocation raised: {e}", exc_info=True)
Expand Down
126 changes: 122 additions & 4 deletions src/agent_env/agents/agents/codex/codex_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
import tomllib
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from typing import Any, Literal

import tomli_w
from pydantic import BaseModel, ConfigDict, model_validator

logger = logging.getLogger(__name__)

Expand All @@ -29,6 +30,120 @@
# certificates to trust.
_TRUST_ENV = ("NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED", "SSL_CERT_FILE")

# The CLI version CodexTool names the tools of; a test holds it to package.json's @openai/codex.
TOOLS_CLI_VERSION = "0.147.0"
# The CLI's own tools, by the names its model sees, that a selection can turn off. Some only go together, as one
# setting turns them on: the shell's two, the six collaboration tools `multi_agent_v2` brings, and the three goal tools.
# `apply_patch` is turned off through the model catalog (`catalog_overrides`), the others by `_TOOL_SWITCHES`. Left out:
# the MCP tools (`tool_search`, `list_mcp_resources`, `list_mcp_resource_templates`, `read_mcp_resource`) that come
# with MCP servers.
CodexTool = Literal["apply_patch", "create_goal", "exec_command", "followup_task", "get_goal", "interrupt_agent",
"list_agents", "request_user_input", "send_message", "spawn_agent", "update_goal", "update_plan",
"view_image", "wait_agent", "web_search", "write_stdin"]
# Each setting that turns some of the CLI's tools off: those tools, and the `-c` overrides that turn them off. Turning
# off `multi_agent_v2` alone would bring back the v1 collaboration tools behind `tool_search`, so both go.
_TOOL_SWITCHES: tuple[tuple[frozenset[str], tuple[str, ...]], ...] = (
(frozenset({"exec_command", "write_stdin"}), ("features.shell_tool=false",)),
(frozenset({"update_plan"}), ("tools.update_plan.enabled=false",)),
(frozenset({"request_user_input"}), ("tools.experimental_request_user_input.enabled=false",)),
(frozenset({"view_image"}), ("features.view_image=false",)),
(frozenset({"followup_task", "interrupt_agent", "list_agents", "send_message", "spawn_agent", "wait_agent"}),
("features.multi_agent_v2=false", "features.multi_agent=false")),
(frozenset({"create_goal", "get_goal", "update_goal"}), ("features.goals=false",)),
(frozenset({"web_search"}), ('web_search="disabled"',)),
)


class CodexTools(BaseModel):
"""The protocol's ``BuiltinTools[CodexTool]``, which the image's protocol floor doesn't have yet: the same fields
and checks, so the card's schema is the same. A list must name all the tools of a setting or none of them."""

model_config = ConfigDict(extra="forbid", frozen=True)

disable: Literal["*"] | list[CodexTool] | None = None
only: list[CodexTool] | None = None

@model_validator(mode="after")
def _one_of(self) -> CodexTools:
if (self.disable is None) == (self.only is None):
raise ValueError("builtin_tools takes exactly one of disable and only")
named = set(self.only if self.only is not None else [] if self.disable == "*" else self.disable)
for tools, _ in _TOOL_SWITCHES:
if named & tools and not tools <= named:
raise ValueError(f"codex turns {', '.join(sorted(tools))} on and off together: name all or none")
return self


def builtin_tools_overrides(builtin_tools: CodexTools | None) -> list[str]:
"""The `-c` overrides that turn off the CLI's own tools a selection doesn't keep; none without one. The MCP
tools agent-env gives the agent stay with any selection."""
if builtin_tools is None:
return []
if builtin_tools.only is not None:
off = [tools for tools, _ in _TOOL_SWITCHES if not tools <= set(builtin_tools.only)]
elif builtin_tools.disable == "*":
off = [tools for tools, _ in _TOOL_SWITCHES]
else:
off = [tools for tools, _ in _TOOL_SWITCHES if tools <= set(builtin_tools.disable)]
return [override for tools, overrides in _TOOL_SWITCHES if tools in off for override in overrides]


# No setting turns off `apply_patch`: a model's entry in the CLI's model catalog offers it when its
# `apply_patch_tool_type` is set, as it is for every GPT model 0.147.0 ships with. A selection without it points the CLI,
# with `model_catalog_json`, at a copy of the catalog it would use whose models all have the field null. The copy is
# made from the installed CLI's own catalog each time, so it follows the pinned version.
CATALOG_WITHOUT_APPLY_PATCH = "model_catalog_without_apply_patch.json"
_CATALOG_TIMEOUT_S = 60
# How much of `codex debug models`' stderr a failed catalog read keeps in its error.
_CATALOG_STDERR_CHARS = 500


def removes_apply_patch(builtin_tools: CodexTools | None) -> bool:
"""Whether a selection turns `apply_patch` off; never without one."""
if builtin_tools is None:
return False
if builtin_tools.only is not None:
return "apply_patch" not in builtin_tools.only
return builtin_tools.disable == "*" or "apply_patch" in builtin_tools.disable


async def _catalog(timeout_s: float) -> dict[str, Any]:
"""The model catalog the CLI uses: the file config.toml's `model_catalog_json` names (relative to CODEX_HOME, as
the CLI reads it), or else the one the installed CLI ships with, from `codex debug models --bundled`."""
configured = _read().get("model_catalog_json")
if configured:
return json.loads((_config_path().parent / Path(configured).expanduser()).read_text())
process = await asyncio.create_subprocess_exec(
"codex", "debug", "models", "--bundled", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout_s)
except (TimeoutError, asyncio.CancelledError) as e:
# A turn cancelled or out of time mid-read must not leave the child running.
if process.returncode is None:
process.kill()
await asyncio.shield(process.wait())
if isinstance(e, asyncio.CancelledError):
raise
raise RuntimeError(f"codex debug models gave no catalog within {timeout_s:.0f}s") from None
if process.returncode != 0:
tail = stderr.decode(errors="replace")[-_CATALOG_STDERR_CHARS:]
raise RuntimeError(f"codex debug models exited {process.returncode}: {tail}")
return json.loads(stdout)


async def catalog_overrides(builtin_tools: CodexTools | None, timeout_s: float = _CATALOG_TIMEOUT_S) -> list[str]:
"""The `-c` override that points the CLI at a catalog copy without `apply_patch`, when the selection turns it off;
none otherwise. Raises when the catalog can't be read, so a turn never gets the tool its selection turned off."""
if not removes_apply_patch(builtin_tools):
return []
catalog = await _catalog(min(timeout_s, _CATALOG_TIMEOUT_S))
for model in catalog["models"]:
model["apply_patch_tool_type"] = None
path = _config_path().with_name(CATALOG_WITHOUT_APPLY_PATCH)
async with _write_lock:
_replace(path, json.dumps(catalog, ensure_ascii=False))
return [f"model_catalog_json={json.dumps(str(path))}"]


def _config_path() -> Path:
home = os.environ.get("CODEX_HOME") or str(Path.home() / ".codex")
Expand All @@ -46,12 +161,15 @@ def _write(cfg: dict[str, Any]) -> bool:
"""Replace config.toml whole, never truncating it in place: a codex CLI starting in
another context reads it at launch. Unchanged content is left alone. Returns whether
the file was written."""
path = _config_path()
return _replace(_config_path(), tomli_w.dumps(cfg))


def _replace(path: Path, text: str) -> bool:
"""Replace a file under CODEX_HOME whole, unless its content is already ``text``; see `_write`."""
path.parent.mkdir(parents=True, exist_ok=True)
text = tomli_w.dumps(cfg)
if path.exists() and path.read_text() == text:
return False
# It carries MCP and model headers: keep the existing file's permissions (owner-only when new).
# config.toml carries MCP and model headers: keep the existing file's permissions (owner-only when new).
mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o600
staged = path.with_name(f".{path.name}.{os.getpid()}.tmp")
with os.fdopen(os.open(staged, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode), "w") as handle:
Expand Down
12 changes: 11 additions & 1 deletion src/agent_env/agents/agents/opencode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ disk:

| Extension | Implementation |
|---|---|
| `agent-config/v1` | `OpenCodeConfig`: `model`, `effort` (the provider's `reasoningEffort`), `system_prompt`, `output_format` (a JSON schema the answer is checked against), `project_id`, `task_id`, `timeout_seconds` (1800 by default) |
| `agent-config/v1` | `OpenCodeConfig`: `model`, `effort` (the provider's `reasoningEffort`), `system_prompt`, `output_format` (a JSON schema the answer is checked against), `project_id`, `task_id`, `timeout_seconds` (1800 by default), `builtin_tools` (below) |
| `mcp-config/v1` | SDK default; each server passed to the task's CLI |
| `skill-config/v1` | inline and bundle skills installed as `$XDG_CONFIG_HOME/opencode/skills/<name>/`, where OpenCode finds them |
| `trajectory/v1` | the task's `opencode run --format json` events, live while it runs (format `opencode-events/1`) |
Expand All @@ -40,3 +40,13 @@ disk:

The image is `python:3.11-slim` with Node, `pdftoppm`, the CLI and the agent, running as the `agent` user. OpenCode's
state is under `/data`, its `HOME`, whatever the uid.

A `deploy_agent` `builtin_tools` selection picks the CLI's own tools through the config's `permission`, which leaves a
denied tool out of the model's tool list rather than refusing it when called. `{"disable": "*"}` and `{"only": [...]}`
deny every tool but those named and the tools of the MCP servers agent-env gives the agent (`<server>_*`), a list to
turn off denies those alone, and `question` is denied in every case. The CLI's checks that aren't tools stay
allowed, as they are without a selection: `external_directory`, which a file tool asks before it touches a path outside
the workspace, and `doom_loop`. The names are `OpenCodeTool`'s in
`opencode_bridge.py`, the CLI's permission names: `edit` also covers `write` and `apply_patch`, which can't be selected
alone, and `read` covers the MCP resource tools. A test fails when package.json's `opencode-ai` moves, until
`OpenCodeTool` and `TOOLS_CLI_VERSION` move with it.
4 changes: 4 additions & 0 deletions src/agent_env/agents/agents/opencode/a2a_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@

from opencode_bridge import (
ERROR_MESSAGE_LIMIT,
OpenCodeTools,
event_session_id,
export_session,
get_last_attribution_snapshot,
Expand Down Expand Up @@ -138,6 +139,8 @@ class OpenCodeConfig(AgentConfig):
project_id: str | None = None
task_id: str | None = None
timeout_seconds: int = Field(default=1800, gt=0)
# Which of the CLI's own tools it may use. With any selection, the tools of the MCP servers agent-env gives it stay.
builtin_tools: OpenCodeTools | None = None

@field_validator("output_format")
@classmethod
Expand Down Expand Up @@ -378,6 +381,7 @@ def record(event: dict) -> None:
task_id=request.config.task_id,
role=request.config.role,
attachment_paths=attachments,
builtin_tools=request.config.builtin_tools,
event_callback=record,
workspace=str(WORKSPACE_DIR),
)
Expand Down
Loading
Loading