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
1 change: 1 addition & 0 deletions src/ouroboros/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
"DriftConfig",
"LoggingConfig",
"OrchestratorConfig",
"RuntimeProfileConfig",
# Loader functions
"load_config",
"load_credentials",
Expand Down
86 changes: 76 additions & 10 deletions src/ouroboros/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@

from pydantic import BaseModel, Field, field_validator

from ouroboros.orchestrator_stage import VALID_STAGE_KEYS


class ModelConfig(BaseModel, frozen=True):
"""Configuration for a single LLM model.
Expand Down Expand Up @@ -330,16 +332,46 @@ class LoggingConfig(BaseModel, frozen=True):
include_reasoning: bool = True


VALID_RUNTIME_BACKENDS = frozenset(
{
"claude",
"claude_code",
"codex",
"codex_cli",
"opencode",
"opencode_cli",
"hermes",
"hermes_cli",
"gemini",
"gemini_cli",
}
)


class RuntimeProfileConfig(BaseModel, frozen=True):
"""Runtime profile configuration shared by backend profiles and stage routing.

``backend_profile`` carries backend-native profile names such as PR #505's
Codex ``worker`` mapping. Unknown names are intentionally accepted here so
backend-local resolvers can warn and fall back without making the shared
config object reject future backend slices. ``default`` and ``stages``
reserve the same object shape used by the stage-routing contract from PR
#538 so the public ``orchestrator.runtime_profile`` key has one stable
table/object form.
"""Runtime profile configuration (issue #519 / M4 / S3).

The Agent OS architecture diagram agreed in #476 lets each pipeline
stage (``interview`` / ``execute`` / ``evaluate`` / ``reflect``) be
served by a different harness. This block exposes that decision as
a configuration surface; the resolution helper in
``ouroboros.orchestrator.stage`` reads it.

This object also reserves ``backend_profile`` for backend-native
profile selection (for example PR #505's Codex ``worker`` profile),
so the public ``orchestrator.runtime_profile`` key has one stable
object shape instead of conflicting string-vs-table meanings.

Attributes:
backend_profile: Optional backend-native profile name. Stage
routing does not interpret it; backend adapters may map it
to their own profile mechanism.
default: Optional runtime backend that serves any stage missing
from ``stages``. ``None`` means "fall through to the
orchestrator's top-level ``runtime_backend``".
stages: Explicit per-stage mapping. Keys must be members of the
closed stage vocabulary; unknown keys raise ``ValueError``
during Pydantic validation at startup.
"""

backend_profile: str | None = None
Expand All @@ -349,14 +381,48 @@ class RuntimeProfileConfig(BaseModel, frozen=True):
@field_validator("backend_profile")
@classmethod
def _validate_backend_profile(cls, value: str | None) -> str | None:
"""Normalize backend-native profile names without constraining vocabulary."""
"""Normalize optional backend-native profile names."""
if value is None:
return None
candidate = value.strip()
if not candidate:
raise ValueError("runtime_profile.backend_profile must not be empty")
return candidate

@field_validator("default")
@classmethod
def _validate_default_backend(cls, value: str | None) -> str | None:
"""Reject invalid runtime_profile.default backend names at startup."""
if value is None:
return None
return _validate_runtime_backend(value, field_name="runtime_profile.default")

@field_validator("stages")
@classmethod
def _validate_stage_keys(cls, value: dict[str, str]) -> dict[str, str]:
"""Reject unknown stage names and invalid backend names at startup."""
validated: dict[str, str] = {}
for key, backend in value.items():
if key not in VALID_STAGE_KEYS:
valid_list = ", ".join(sorted(VALID_STAGE_KEYS))
raise ValueError(
f"Unknown runtime_profile.stages key: {key!r}. Valid keys are: {valid_list}.",
)
validated[key] = _validate_runtime_backend(
backend,
field_name=f"runtime_profile.stages[{key!r}]",
)
return validated


def _validate_runtime_backend(value: str, *, field_name: str) -> str:
"""Validate runtime_profile backend names against orchestrator backends."""
candidate = value.strip().lower()
if candidate not in VALID_RUNTIME_BACKENDS:
valid_list = ", ".join(sorted(VALID_RUNTIME_BACKENDS))
raise ValueError(f"{field_name} must be one of: {valid_list}")
return candidate


class OrchestratorConfig(BaseModel, frozen=True):
"""Orchestrator runtime configuration.
Expand Down
22 changes: 22 additions & 0 deletions src/ouroboros/orchestrator/stage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Compatibility re-export for orchestrator stage routing primitives.

The canonical definitions live in :mod:`ouroboros.orchestrator_stage` so
configuration validation can import the closed stage vocabulary without
importing the full :mod:`ouroboros.orchestrator` package graph.
"""

from ouroboros.orchestrator_stage import (
VALID_STAGE_KEYS,
Stage,
UnknownStageError,
parse_stage,
resolve_runtime_for_stage,
)

__all__ = [
"Stage",
"VALID_STAGE_KEYS",
"UnknownStageError",
"parse_stage",
"resolve_runtime_for_stage",
]
119 changes: 119 additions & 0 deletions src/ouroboros/orchestrator_stage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""``Stage`` — closed enumeration of orchestrator pipeline stages.

Issue #519 — slice 1 of M4 / S3. The Agent OS architecture diagram
agreed in #476 assigns a different harness per pipeline stage:

* **interview** — Codex (clarification, ambiguity reduction)
* **execute** — OpenCode / OMX (the running of the AC tree)
* **evaluate** — Claude Code (Stage 1/2/3 verification)
* **reflect** — Hermes (Wonder/Reflect generation)

This module is the *binding-table primitive* the orchestrator reads to
pick a runtime per stage. The four stages above are the **closed**
initial vocabulary; adding a new stage is an explicit, justified PR
(per the narrow-membership rule the maintainer alignment in #476 Q1
applied to ``AgentRuntimeContext``). That stops the table from
sprawling into per-handler entries (``qa_judge``, ``unstuck`` …)
which belong inside an :class:`AgentProcess` (#518), not in the
binding table.

The module deliberately exposes nothing more than the enum and a
small resolution helper. The resolution rule itself is pinned by the
sub-thread:

::

runtime = (
runtime_profile.stages.get(stage) # explicit per-stage
or runtime_profile.default # opt-in default
or current_orchestrator_runtime_backend # today's behaviour
)

When a config has ``runtime_profile=None`` (or omits the block
entirely), :func:`resolve_runtime_for_stage` falls back to the
existing ``orchestrator.runtime_backend`` byte-for-byte — that is the
backwards-compat commitment carried forward from PR #505.
"""

from __future__ import annotations

from enum import StrEnum
from typing import Final


class Stage(StrEnum):
"""Closed enumeration of pipeline stages routed by ``runtime_profile``.

Adding a member requires (a) a stage name, (b) documentation of
which workflow phase it covers, (c) a justification line in the
PR body explaining why an existing stage cannot host the work.
"""

INTERVIEW = "interview"
EXECUTE = "execute"
EVALUATE = "evaluate"
REFLECT = "reflect"


VALID_STAGE_KEYS: Final[frozenset[str]] = frozenset(stage.value for stage in Stage)


class UnknownStageError(ValueError):
"""Raised when a runtime_profile.stages key is not a valid stage.

The error message names the offending key and the valid set so
operators see typos at startup rather than mid-workflow.
"""


def parse_stage(value: str) -> Stage:
"""Parse a string into a :class:`Stage`, raising on unknown values.

Used at startup to validate ``runtime_profile.stages`` keys.
Unknown keys raise :class:`UnknownStageError` so a typo in
``interveiw`` fails fast at config load.
"""
if value not in VALID_STAGE_KEYS:
valid_list = ", ".join(sorted(VALID_STAGE_KEYS))
raise UnknownStageError(
f"Unknown runtime_profile stage key: {value!r}. Valid keys are: {valid_list}.",
)
return Stage(value)


def resolve_runtime_for_stage(
stage: Stage,
*,
stages: dict[Stage, str] | None,
default: str | None,
fallback: str,
) -> str:
"""Return the runtime backend that should serve ``stage``.

Resolution order locked in the #519 sub-thread:

1. ``stages[stage]`` — explicit per-stage mapping wins.
2. ``default`` — when set, the runtime_profile's own default.
3. ``fallback`` — today's hard-coded ``orchestrator.runtime_backend``.

Args:
stage: The pipeline stage being resolved.
stages: Optional explicit stage→runtime mapping. ``None`` means
"no stage block configured".
default: Optional ``runtime_profile.default``. ``None`` means
"no runtime_profile default configured".
fallback: The today-behaviour fallback (the orchestrator's
top-level ``runtime_backend``). Always provided so the
resolution function never returns ``None``.

Returns:
The runtime backend identifier (e.g. ``"codex"``, ``"opencode"``)
that should serve the given stage.
"""
if stages is not None:
explicit = stages.get(stage)
if explicit:
return explicit
if default:
return default
return fallback
Loading
Loading