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
30 changes: 30 additions & 0 deletions docs/runtime-guides/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,39 @@ Under the hood, `CodexCliRuntime` still talks to the local `codex` executable, b
- Installs managed Ouroboros skills into `~/.codex/skills/`
- Registers the Ouroboros MCP/env hookup in `~/.codex/config.toml` when absent, refreshes setup-managed stdio blocks, and preserves user-managed URL/custom entries by default
- Adds missing `profiles.ouroboros-*` Codex profile anchors without overwriting existing profiles
- Registers a managed `[profiles.ouroboros-worker]` section in the same file so Agent OS worker subprocesses can opt out of interactive Codex defaults without losing the MCP/env hookup

`~/.codex/config.toml` is not where Ouroboros per-role model overrides belong. Keep `clarification`, `qa`, `semantic`, `consensus`, `llm_profiles`, and `llm_role_profiles` settings in `~/.ouroboros/config.yaml`. If you manage a long-running URL-based Ouroboros MCP server, keep that URL entry in `~/.codex/config.toml`; `ouroboros setup --runtime codex` preserves it by default. Use `--mcp-mode stdio` only when you intentionally want setup to replace the entry with the managed command-spawned server.

### Worker subprocess isolation (Agent OS `runtime_profile`)

Interactive `codex` sessions and Ouroboros-managed worker subprocesses sometimes want different defaults — for example a different model, sandbox, or notify hook. Set the orchestrator-level runtime profile to `worker` to opt every Ouroboros-spawned `codex exec` invocation into the managed `[profiles.ouroboros-worker]` block:

```yaml
# ~/.ouroboros/config.yaml
orchestrator:
runtime_backend: codex
runtime_profile:
backend_profile: worker # optional; default unset preserves today's behavior
```

Or via the environment for one-off runs:

```bash
OUROBOROS_RUNTIME_PROFILE=worker ouroboros run workflow --runtime codex seed.yaml
```

Customize the worker overrides directly in `~/.codex/config.toml`:

```toml
[profiles.ouroboros-worker]
model = "o3-mini"
notify = []
sandbox = "workspace-write"
```

When `runtime_profile` is unset (the default), Ouroboros emits `codex exec` exactly as before — no profile flag, full user-config inheritance. This is the Codex-side mapping of the cross-runtime Agent OS profile contract; OpenCode, Hermes, Claude Code, and LiteLLM mappings can add their own backend-local mappings separately.

### `ooo` Skill Availability on Codex

After running `ouroboros setup --runtime codex`, the bundled `ooo` skills are installed into `~/.codex/skills/ouroboros-*` and the routing rules into `~/.codex/rules/`. To refresh only those artifacts after upgrading Ouroboros, run `ouroboros codex refresh`; it does not modify `~/.codex/config.toml` or `~/.ouroboros/config.yaml`. The table below shows each skill and its CLI equivalent for terminal-only workflows.
Expand Down
123 changes: 122 additions & 1 deletion src/ouroboros/cli/commands/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,88 @@ def _is_setup_managed_codex_mcp_entry(entry: dict[str, object]) -> bool:
return len(args) >= 3 and args[-3:] == ["ouroboros", "mcp", "serve"]


_CODEX_WORKER_PROFILE_SECTION = """# Ouroboros Agent OS runtime profile for Codex worker subprocesses.
# Activated when ~/.ouroboros/config.yaml sets `orchestrator.runtime_profile.backend_profile: worker`
# (or the OUROBOROS_RUNTIME_PROFILE=worker env var). Add per-worker Codex
# overrides below — for example a different model, sandbox, or notify hook —
# without affecting interactive `codex` sessions that share this config file.

[profiles.ouroboros-worker]
"""

_CODEX_WORKER_PROFILE_COMMENT_LINES = (
"# Ouroboros Agent OS runtime profile for Codex worker subprocesses.",
"# Activated when ~/.ouroboros/config.yaml sets `orchestrator.runtime_profile.backend_profile: worker`",
"# (or the OUROBOROS_RUNTIME_PROFILE=worker env var). Add per-worker Codex",
"# overrides below — for example a different model, sandbox, or notify hook —",
"# without affecting interactive `codex` sessions that share this config file.",
)


def _is_codex_ouroboros_worker_profile_header(line: str) -> bool:
"""Return True when the line starts the managed Codex worker profile table."""
return line == "[profiles.ouroboros-worker]" or line.startswith("[profiles.ouroboros-worker.")


def _trim_managed_codex_worker_profile_comments(lines: list[str]) -> None:
"""Excise every managed worker-profile comment block from ``lines``."""
expected = list(_CODEX_WORKER_PROFILE_COMMENT_LINES)
block_len = len(expected)
if block_len == 0:
return

index = 0
while index <= len(lines) - block_len:
if lines[index : index + block_len] == expected:
end = index + block_len
if end < len(lines) and not lines[end].strip():
end += 1
del lines[index:end]
else:
index += 1


def _upsert_codex_worker_profile_section(raw: str) -> tuple[str, bool]:
"""Refresh the managed comment block for ``[profiles.ouroboros-worker]``.

Setup owns only the managed comment/header. Existing user-authored keys
and subtables under ``[profiles.ouroboros-worker]`` are preserved verbatim.
"""
section_lines = _CODEX_WORKER_PROFILE_SECTION.strip("\n").splitlines()
input_lines = raw.splitlines()
output_lines: list[str] = []
index = 0
existed_before = False
refreshed = False

while index < len(input_lines):
line = input_lines[index]
stripped = line.strip()
if stripped == "[profiles.ouroboros-worker]" and not refreshed:
existed_before = True
refreshed = True
_trim_managed_codex_worker_profile_comments(output_lines)
if output_lines and output_lines[-1].strip():
output_lines.append("")
output_lines.extend(section_lines)
index += 1
continue
if _is_codex_ouroboros_worker_profile_header(stripped):
existed_before = True
output_lines.append(line)
index += 1
continue
output_lines.append(line)
index += 1

if not refreshed:
if output_lines and output_lines[-1].strip():
output_lines.append("")
output_lines.extend(section_lines)

return "\n".join(output_lines).rstrip() + "\n", existed_before


def _is_codex_ouroboros_table_header(line: str) -> bool:
"""Return True when the line starts the managed Codex MCP table."""
return line == "[mcp_servers.ouroboros]" or line.startswith("[mcp_servers.ouroboros.")
Expand Down Expand Up @@ -518,6 +600,44 @@ def _register_codex_default_profiles() -> None:
print_success(f"Registered Codex task profiles in {codex_config}: {', '.join(added_profiles)}")


def _register_codex_worker_profile() -> None:
"""Register the managed Codex worker profile in ~/.codex/config.toml."""
import tomllib

codex_config = Path.home() / ".codex" / "config.toml"
codex_config.parent.mkdir(parents=True, exist_ok=True)

if codex_config.exists():
raw = codex_config.read_text(encoding="utf-8")
try:
_existing_codex_profile_names(raw)
except (tomllib.TOMLDecodeError, ValueError) as exc:
print_error(f"Could not parse {codex_config} — skipping worker-profile registration.")
print_info(str(exc))
return
else:
raw = ""

updated_raw, existed_before = _upsert_codex_worker_profile_section(raw)
try:
tomllib.loads(updated_raw)
except tomllib.TOMLDecodeError as exc:
print_error(
f"Could not update {codex_config} — worker-profile registration would create invalid TOML."
)
print_info(str(exc))
return
if updated_raw == raw:
print_info("Codex worker profile already up to date.")
return

codex_config.write_text(updated_raw, encoding="utf-8")
if existed_before:
print_success(f"Updated Codex worker profile in {codex_config}")
else:
print_success(f"Registered Codex worker profile in {codex_config}")


def _ensure_mapping_section(config_dict: dict, key: str) -> dict:
"""Ensure a top-level YAML section is a mapping before mutating it."""
section = config_dict.get(key)
Expand Down Expand Up @@ -617,7 +737,7 @@ def _print_codex_config_guidance(config_path: Path) -> None:
"""Explain where Codex users should configure Ouroboros vs. Codex settings."""
print_info(f"Configure Ouroboros runtime and per-role model overrides in {config_path}.")
print_info(
"Use ~/.codex/config.toml only for the Codex MCP/env hookup and Codex profile anchors."
"Use ~/.codex/config.toml for the Codex MCP/env hookup, Codex profile anchors, and [profiles.ouroboros-worker] worker overrides."
)


Expand Down Expand Up @@ -692,6 +812,7 @@ def _setup_codex(codex_path: str, *, mcp_mode: CodexMcpMode = "auto") -> None:
# Register MCP server in Codex config (~/.codex/config.toml)
_register_codex_mcp_server(mode=mcp_mode)
_register_codex_default_profiles()
_register_codex_worker_profile()
_print_codex_config_guidance(config_path)


Expand Down
76 changes: 76 additions & 0 deletions src/ouroboros/codex/runtime_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Codex-side mapping for the orchestrator-level ``runtime_profile``.

The ``OrchestratorConfig.runtime_profile`` setting names a profile in the
orchestrator's own vocabulary (e.g. ``"worker"``). The Codex backend
translates that name to its own ``--profile`` identifier and applies it
at command-build time. Both the orchestrator runtime
(``ouroboros.orchestrator.codex_cli_runtime``) and the LLM provider
adapter (``ouroboros.providers.codex_cli_adapter``) share this module so
the mapping is single-sourced.

The module is intentionally Codex-local. Future Agent OS phases that add
OpenCode, Hermes, Claude Code, or LiteLLM mappings should each provide
their own backend-local mapping module rather than expanding this one —
the orchestrator surface only owns the *name*, not the per-backend
translation.

It also lives outside of ``ouroboros.orchestrator`` to avoid a circular
import: ``ouroboros.orchestrator.__init__`` pulls in the runner, which
pulls in the providers package, which is what imports this module from
the Codex LLM adapter.
"""

from __future__ import annotations

from typing import Any

# Maps the orchestrator-level ``runtime_profile`` value to the Codex-side
# ``--profile`` name. Phase 1 only ships ``worker``; new entries should land
# alongside the matching ``[profiles.<name>]`` section written by setup so
# operators always have a managed home for the per-profile overrides.
RUNTIME_PROFILE_TO_CODEX_PROFILE: dict[str, str] = {
"worker": "ouroboros-worker",
}


def resolve_codex_profile(
runtime_profile: str | None,
*,
logger: Any,
log_namespace: str,
) -> str | None:
"""Translate an orchestrator runtime_profile to a Codex ``--profile`` name.

Args:
runtime_profile: The orchestrator-level profile name. ``None`` or an
empty string means "no profile requested" — every Codex code path
then preserves its current default user-config behaviour.
logger: The caller's structured logger (the same object the call site
uses for its own warnings). Passing it through keeps the warning
attributable to that namespace and keeps existing
``patch("module.log.warning")`` test seams intact.
log_namespace: Caller's structured-log namespace (e.g.
``"codex_cli_runtime"`` or ``"codex_cli_adapter"``). Used as the
event prefix for the ``runtime_profile_unmapped`` warning so the
event name carries the call site even though the function lives
in a shared module.

Returns:
The Codex profile name when the orchestrator profile maps to one,
otherwise ``None``. An unmapped non-empty value emits a structured
warning via the caller's logger so the existing fallback path runs
without surprises.
"""
if not runtime_profile:
return None
mapped = RUNTIME_PROFILE_TO_CODEX_PROFILE.get(runtime_profile)
if mapped is None:
logger.warning(
f"{log_namespace}.runtime_profile_unmapped",
runtime_profile=runtime_profile,
hint="No Codex backend mapping; running without --profile.",
)
return mapped


__all__ = ["RUNTIME_PROFILE_TO_CODEX_PROFILE", "resolve_codex_profile"]
4 changes: 4 additions & 0 deletions src/ouroboros/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
get_qa_model,
get_reflect_model,
get_runtime_controls_config,
get_runtime_profile,
get_semantic_model,
get_usage_limit_pause_seconds,
get_wonder_model,
Expand All @@ -80,6 +81,7 @@
ProviderCredentials,
ResilienceConfig,
RuntimeControlsConfig,
RuntimeProfileConfig,
TierConfig,
get_config_dir,
get_default_config,
Expand All @@ -101,6 +103,7 @@
"ExecutionConfig",
"ResilienceConfig",
"RuntimeControlsConfig",
"RuntimeProfileConfig",
"EvaluationConfig",
"ConsensusConfig",
"PersistenceConfig",
Expand Down Expand Up @@ -141,6 +144,7 @@
"get_ontology_analysis_model",
"get_reflect_model",
"get_runtime_controls_config",
"get_runtime_profile",
"get_semantic_model",
"get_usage_limit_pause_seconds",
"get_wonder_model",
Expand Down
27 changes: 27 additions & 0 deletions src/ouroboros/config/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
create_default_config: Create default configuration files
ensure_config_dir: Ensure ~/.ouroboros/ directory exists
get_agent_runtime_backend: Get orchestrator runtime backend from env var or config
get_runtime_profile: Get orchestrator backend profile (e.g. "worker") from env var or config
get_agent_permission_mode: Get orchestrator permission mode from env var or config
get_llm_backend: Get LLM-only backend from env var or config
get_llm_permission_mode: Get LLM-only permission mode from env var or config
Expand Down Expand Up @@ -800,6 +801,32 @@ def get_max_parallel_workers() -> int:
)


def get_runtime_profile() -> str | None:
"""Get the orchestrator backend profile from env var or config file.

Priority:
1. OUROBOROS_RUNTIME_PROFILE environment variable
2. config.yaml orchestrator.runtime_profile.backend_profile
3. None (no profile — backends keep their default user-config behavior)

Returns:
The backend profile name (e.g. ``"worker"``) or None.
"""
env_value = os.environ.get("OUROBOROS_RUNTIME_PROFILE", "").strip()
if env_value:
return env_value

try:
config = load_config()
profile = config.orchestrator.runtime_profile
if profile is not None and profile.backend_profile:
return profile.backend_profile
except ConfigError:
pass

return None


def get_codex_cli_path() -> str | None:
"""Get Codex CLI path from environment variable or config file.

Expand Down
Loading
Loading