"""``spec.engines`` — SEVERAL declared backends, ONE picked at START.
WHY THIS EXISTS — a spec can declare exactly ONE backend today
(``spec.harness`` + ``spec.claude.model`` + ``spec.claude.provider``),
so switching an agent between Claude and a local Qwen means EDITING the
spec. The operator asked (Telegram 2026-09-03) for one spec to write
several backends down and pick one at start:
sac agents restart <agent> --engine qwen38-27b
FOUR OPERATOR ANSWERS this module implements, verbatim in intent:
Q1 VOCABULARY — the words are ENGINE and HARNESS. ``claude:`` was
already the wrong name the moment it held Qwen (a vendor name is a
claim about scope), so the multi-backend surface is ``engines:``
and each entry is an ENGINE: one named (harness, model, provider,
parameters) tuple.
Q2 GRANULARITY — START TIME ONLY (「起動時だけで大丈夫です」). There
is no per-turn escape hatch and no mid-session rebinding: the
selected engine is folded into the resolved ``AgentConfig`` before
the runtime is built and never consulted again.
Q3 UNHONOURABLE ENGINE — REFUSE TO START, naming what could not be
honoured (「勝手なフォールバックはしないと言うルールなので」).
The spec value is the default, ``--engine`` overrides it, and
NOTHING silently falls back — least of all onto the default when
an explicit ``--engine`` was given. The refusal itself lives on the
start path (:mod:`_lifecycle._engine_select`); this module owns the
static shape.
Q4 PARAMETERS — YES, per model, ``reasoning_effort`` above all (Qwen
is expected to run at low effort permanently).
NO SECOND VOCABULARY. An engine entry carries the SAME fields the
single-backend surface carries: ``harness`` resolves through
:mod:`_harness_registry` (not a second list), ``provider`` folds through
:func:`_provider_parse.parse_provider_value` (not a second dataclass),
``model`` is the same model id. Only the two parameters
(``reasoning_effort`` / ``max_context_tokens``) and the ``default:``
marker are new words, because they name things the old surface could
not say.
MIGRATION, NOT RENAME — exactly the rule ``_harness_types`` already
implements for ``harness``/``provider``, applied to a BLOCK instead of
a key. 123 live specs on compute-04 alone are written with the legacy
single-backend block, so a hard rename boot-reds every one of them:
* legacy block alone → works unchanged, and SILENTLY. No deprecation
line: at ~123 specs a nudge-per-start is noise, and the operator
asked for "keeps working unchanged and silently".
* ``engines:`` alone → the new path; the default engine is folded
into the config at LOAD time so every read surface (explain, birth
certificate, status) sees the effective backend.
* BOTH, agreeing → accepted, silently. The author has migrated
and kept the legacy block for an older sac; nothing is ambiguous.
* BOTH, disagreeing → HARD ERROR naming both values (see
:func:`legacy_conflict_messages`). Picking one silently would let a
spec claim one backend and run another.
WHERE THE DIAGNOSTICS FIRE. Everything decidable from the spec text
alone (two defaults, no default, unknown harness, malformed provider,
legacy disagreement) is a VALIDATOR error — ``validate_raw``, at load,
same as every other spec-shape error. Everything that depends on the
HOST (is ``$QWEN_API_KEY`` exported? does the endpoint answer?) fires on
the START path only, never in the loader: ``sac agents list`` loads
every spec on the host, so a loader-side host probe would probe N
endpoints for a command nobody asked a reachability question of, and a
loader-side warning would print one line per spec and contaminate
``--json``. That is the same ruling that placed
``warn_if_legacy_harness_key`` and ``warn_if_legacy_apptainer_runtime``
on the start path.
THE MIGRATION HAS AN END, and this is it: when every deployed spec
declares ``engines:`` (the roll-over is a separate dry-run-first sweep —
this module ships the mechanism only), the legacy single-backend
reading in :func:`legacy_backend` is deleted, ``spec.claude.model`` /
``spec.claude.provider`` / ``spec.harness`` stop being read as a
backend declaration, and :func:`legacy_conflict_messages` goes with
them. Until that sweep completes the legacy block is not deprecated —
it is the majority spelling.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Mapping
from ._provider_parse import parse_provider_value
from ._provider_types import ProviderSpec
__all__ = [
"ENGINES_KEY",
"ENGINE_PIN_KEY",
"ENGINE_ENTRY_KEYS",
"MIGRATION_END_CONDITION",
"EngineDefaultError",
"EngineError",
"EngineSpec",
"UnknownEngineError",
"apply_default_engine",
"apply_engine",
"default_engine",
"resolve_default",
"legacy_backend",
"legacy_conflict_messages",
"parse_engines",
"select_engine",
]
#: The top-level spec key holding the named engines.
ENGINES_KEY = "engines"
#: THE WHOLE GRAMMAR CHANGE, one scalar: the top-level spec key that PINS
#: which engine this agent runs (``spec.engine: qwen38-27b``). Spelled
#: identically at the top of the fleet engine library, where it names the
#: FLEET default, because it means the same thing in both places: "the
#: engine to run when nothing more specific is stated". Flipping one
#: agent onto another backend is this ONE line; flipping the whole fleet
#: is this ONE line in the library.
ENGINE_PIN_KEY = "engine"
#: The keys ONE engine entry may carry. ``harness`` / ``model`` /
#: ``provider`` are the single-backend surface's own words; the rest are
#: the per-engine parameters (Q4).
#:
#: ``default`` is DEPRECATED — superseded by the top-level
#: :data:`ENGINE_PIN_KEY` (``spec.engine: <key>``), which says the same
#: thing without making the CHOICE a property of the CHOSEN. It stays
#: accepted, silently, for the specs already written with it: a hard
#: removal would boot-red them, and this axis exists to make switching
#: backends cheap, not to make it a flag day. It is deleted in the same
#: PR that deletes the legacy single-backend reading — see
#: :data:`MIGRATION_END_CONDITION`.
#:
#: ``harness`` inside an entry is deprecated for the same window and for
#: a sharper reason: after the split, an entry that states a harness is
#: an ENGINE claiming ownership of the HARNESS axis, which is precisely
#: the coupling this design removes. It survives only so a spec that
#: already writes it keeps loading.
ENGINE_ENTRY_KEYS = frozenset(
{
"harness",
"model",
"provider",
"subscription",
"default",
"reasoning_effort",
"service_tier",
"max_context_tokens",
"timeouts",
"env",
}
)
#: What CLOSES this migration — stated so the compatibility window is a
#: condition, not an open-ended "someday". See the module docstring.
MIGRATION_END_CONDITION = (
"every deployed spec declares spec.engines; then the legacy "
"single-backend reading (spec.harness + spec.claude.model + "
"spec.claude.provider as a BACKEND declaration) is deleted along "
"with legacy_conflict_messages()"
)
[docs]
class EngineError(ValueError):
"""Base class for ``spec.engines`` faults."""
[docs]
class EngineDefaultError(EngineError):
"""``spec.engines`` does not name exactly one default entry."""
[docs]
class UnknownEngineError(EngineError):
"""``--engine <key>`` named an engine the spec does not declare."""
[docs]
@dataclass(frozen=True)
class EngineSpec:
"""ONE named backend an agent may start on.
Every field is the single-backend surface's own field, so an engine
entry reads as "the ``harness`` + ``claude`` block I would have
written, given a name". ``provider_declared`` keeps the RAW YAML
value beside the folded :class:`ProviderSpec` because the two carry
different information: the fold collapses both an unregistered name
and the ``anthropic`` sentinel to ``None``, and the refusal path has
to tell those apart to say WHAT could not be honoured.
THE HARNESS FIELD IS NULLABLE, AND THAT NULL IS THE WHOLE
HARNESS/ENGINE SPLIT. It used to default to
``DEFAULT_AGENT_HARNESS``, which meant an entry that said nothing
about the harness SAID ANTHROPIC — and :func:`apply_engine` then
wrote that manufactured value over a spec that had declared
``harness: codex``. One engine definition could therefore not serve
a Claude-Code agent and a Codex agent, which is exactly what a fleet
engine library has to do. ``None`` now means NO OPINION: the engine
names the model endpoint, the spec names the program that runs the
loop, and neither overwrites the other.
"""
key: str
harness: str | None = None
model: str = ""
provider: ProviderSpec | None = None
provider_declared: Any = None
subscription_provider: str = ""
subscription_account: str = ""
reasoning_effort: str = ""
service_tier: str = ""
max_context_tokens: int | None = None
upstream_deadline_seconds: int | None = None
client_abandonment_seconds: int | None = None
env: dict[str, str] = field(default_factory=dict)
is_default: bool = False
def _stated(value: Any) -> str | None:
"""The STATED text of a YAML scalar, or ``None`` for "no opinion".
Same three-cases-are-one rule as ``_harness_types._stated``: absent,
null, and empty/whitespace all mean the author declared nothing.
"""
if value is None:
return None
text = str(value).strip()
return text or None
def _entry_mapping(raw: Any) -> Mapping[str, Any]:
return raw if isinstance(raw, Mapping) else {}
def _parse_int(value: Any) -> int | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, int):
return value
text = str(value).strip()
if not text:
return None
try:
return int(text)
except ValueError:
# The validator owns the "must be a positive integer"
# diagnostic; degrading to None here keeps an unvalidated
# fixture path from crashing the loader.
return None
def parse_engine_entry(key: str, raw: Any) -> EngineSpec:
"""Fold ONE ``spec.engines.<key>`` mapping into an :class:`EngineSpec`.
Shape faults (non-mapping entry, unknown sub-key, bad types) are the
VALIDATOR's diagnostic — see :mod:`_engine_validation`. This parser
is deliberately total so a spec that is about to be rejected still
parses far enough for the error message to quote it.
"""
entry = _entry_mapping(raw)
harness = _stated(entry.get("harness"))
provider_declared = entry.get("provider")
raw_subscription = entry.get("subscription")
subscription = raw_subscription if isinstance(raw_subscription, Mapping) else {}
raw_env = entry.get("env")
raw_timeouts = entry.get("timeouts")
timeouts = raw_timeouts if isinstance(raw_timeouts, Mapping) else {}
env: dict[str, str] = {}
if isinstance(raw_env, Mapping):
env = {str(k): str(v) for k, v in raw_env.items() if v is not None}
return EngineSpec(
key=key,
harness=harness.lower() if harness else None,
model=_stated(entry.get("model")) or "",
provider=parse_provider_value(provider_declared),
provider_declared=provider_declared,
subscription_provider=_stated(subscription.get("provider")) or "",
subscription_account=_stated(subscription.get("account")) or "",
reasoning_effort=(_stated(entry.get("reasoning_effort")) or "").lower(),
service_tier=_stated(entry.get("service_tier")) or "",
max_context_tokens=_parse_int(entry.get("max_context_tokens")),
upstream_deadline_seconds=_parse_int(timeouts.get("upstream_deadline_seconds")),
client_abandonment_seconds=_parse_int(
timeouts.get("client_abandonment_seconds")
),
env=env,
is_default=entry.get("default") is True,
)
def parse_engines(spec: Mapping) -> dict[str, EngineSpec]:
"""Parse ``spec.engines`` into ``{key: EngineSpec}``, insertion-ordered.
Returns ``{}`` when the block is absent, null, or not a mapping — a
legacy-only spec parses to no engines and takes the unchanged path.
"""
if not isinstance(spec, Mapping):
return {}
block = spec.get(ENGINES_KEY)
if not isinstance(block, Mapping):
return {}
return {
str(key): parse_engine_entry(str(key), value) for key, value in block.items()
}
def default_engine(engines: Mapping[str, EngineSpec]) -> EngineSpec | None:
"""See :func:`_engine_precedence.default_engine`."""
from ._engine_precedence import default_engine as _impl
return _impl(engines)
def resolve_default(engines: Mapping[str, EngineSpec], **kwargs: Any):
"""See :func:`_engine_precedence.resolve_default`."""
from ._engine_precedence import resolve_default as _impl
return _impl(engines, **kwargs)
[docs]
def select_engine(
engines: Mapping[str, EngineSpec], requested: str | None
) -> EngineSpec | None:
"""The engine THIS start runs on.
``requested`` is the ``--engine`` argument (``None`` = not given, so
the declared default wins). An unknown key raises
:class:`UnknownEngineError` LISTING the declared keys — it never
degrades to the default, because starting on a different backend
than the one the operator named is the silent fallback Q3 forbids.
"""
key = (requested or "").strip()
if not key:
return default_engine(engines)
if not engines:
raise UnknownEngineError(
f"--engine {key!r} was given but the spec declares no "
f"`{ENGINES_KEY}:` block, so there is no engine by that name "
"to select. Either declare the engine in the spec or drop "
"--engine to start on the spec's single declared backend."
)
if key not in engines:
declared = ", ".join(repr(name) for name in engines)
raise UnknownEngineError(
f"--engine {key!r} resolves to no engine. Resolvable engines "
f"(this spec's own `{ENGINES_KEY}:` block UNION the fleet "
f"engine library): {declared}. Declare it in whichever of the "
"two the key belongs to — `sac agents explain <agent>` prints "
"the library path and which entries came from where. sac will "
"NOT fall back to the default engine when an explicit --engine "
"was given."
)
return engines[key]
[docs]
def apply_engine(config: Any, engine: EngineSpec) -> None:
"""Fold ``engine`` onto a resolved ``AgentConfig``, in place.
This is the ONLY place an engine becomes behaviour, and it writes
the SAME fields the single-backend surface writes — which is what
makes every downstream reader (auth argv, birth certificate,
explain) engine-aware for free.
AN ENGINE ENTRY FULLY DETERMINES THE BACKEND TRIPLE, and every one of
the three is written UNCONDITIONALLY — including when the entry
states nothing (``model: ""`` = "use the runtime default";
``provider: None`` = "no endpoint override"). Writing them
conditionally is the obvious-looking version and it is wrong twice
over: applying engine B after the loader applied default engine A
would leave A's provider in place, silently pointing the agent at an
endpoint B never declared; and a legacy ``spec.claude.provider``
would leak into an engine that declares none. Both are exactly the
"ran a backend nobody asked for" failure this axis exists to remove.
A legacy block that states a value the default engine does not is
therefore a CONFLICT, not an inheritance — see
:func:`legacy_conflict_messages`.
``env`` merges ON TOP of ``spec.apptainer.env`` — a per-engine env
entry is a deliberate override for THIS backend, so it wins over the
agent-wide value it was written to displace.
THE TOP-LEVEL MODEL SURFACE FOLLOWS TOO, and it is not decoration.
``_loaders`` derives ``AgentConfig.model`` and the injected
``SCITEX_AGENT_CONTAINER_MODEL`` from the RAW ``spec.claude.model``,
which a spec declaring ``engines:`` deliberately leaves empty — so
without this the whole fleet reported the ``sonnet`` default while
running something else. Measured over the 119-spec corpus: 117 specs
flipped to ``'sonnet'`` / ``'Claude Sonnet'`` on migration while
``claude.model`` stayed correct. ``sac agents list`` reads
``config.model`` (``_lifecycle._status``), the tmux runner builds
``--model`` from it, and ``sac whoami`` prints the env — three
surfaces lying about one backend.
"""
from ._engine_service_tier import validate_service_tier
from ._parsers import MODEL_ENV_KEY, resolve_model_surface
validate_service_tier(config, engine)
config.engine_key = engine.key
# THE SPLIT. An engine that states NO harness states no opinion, and
# an opinion nobody stated must not be written over one that was:
# this line used to assign a manufactured "anthropic" onto a spec
# that had declared ``harness: codex``. Only a harness the entry
# ACTUALLY names is applied — which is what lets ONE fleet engine
# entry serve a Claude-Code agent and a Codex agent unchanged.
if engine.harness is not None:
config.harness = engine.harness
config.reasoning_effort = engine.reasoning_effort
config.service_tier = engine.service_tier
config.max_context_tokens = engine.max_context_tokens
config.upstream_deadline_seconds = engine.upstream_deadline_seconds
config.client_abandonment_seconds = engine.client_abandonment_seconds
config.subscription_provider = engine.subscription_provider
config.subscription_account = engine.subscription_account
resolved_model, display_model = resolve_model_surface(engine.model)
config.model = resolved_model
claude = getattr(config, "claude", None)
if claude is not None:
claude.model = engine.model
claude.provider = engine.provider
# A provider-backed engine authenticates with the provider's API key;
# the OAuth account the DEFAULT engine pins is not this start's, and
# the runtime refuses the two together (_apptainer_provider:
# "provider and account are mutually exclusive"). Measured
# 2026-09-05: `business --engine qwen38-27b` was refused on the peer
# with exactly that message while its spec was correct -- the fold
# had left the account in place. Clearing it here keeps the rule
# true for what actually runs; an engine without a provider keeps
# the account, because OAuth is then the only auth it has.
if engine.provider is not None:
claude.account = ""
# ...and the ROTATION POOL with it. The exclusion the runtime
# enforces is provider-vs-OAuth, not provider-vs-``account``:
# ``_claude_validation`` asserts it over ``credentials_files``
# too, but only on the RAW spec, never on the folded config.
# A spec declaring four credentials_files beside a
# provider-backed engine therefore passed validation and then
# composed the two at launch. Clearing both here keeps the
# rule true for WHAT ACTUALLY RUNS.
if hasattr(claude, "credentials_files"):
claude.credentials_files = []
if hasattr(claude, "credentials_file"):
claude.credentials_file = ""
merged = dict(getattr(config, "env", {}) or {})
# An EXPLICIT ``spec.apptainer.env`` entry still wins, exactly as it does
# over the loader's auto-derived value: this refreshes the DERIVED half,
# it does not overrule an author who wrote the key themselves.
declared_env = getattr(getattr(config, "apptainer", None), "env", None) or {}
if MODEL_ENV_KEY not in declared_env:
merged[MODEL_ENV_KEY] = display_model
if engine.env:
merged.update(engine.env)
config.env = merged
def apply_default_engine(
config: Any,
engines: Mapping[str, EngineSpec],
spec: Mapping | None = None,
) -> EngineSpec | None:
"""Fold the DEFAULT engine onto ``config`` at LOAD time; return it.
PURE by design — no warning, no host probe, no network. ``sac agents
list`` loads every spec on the machine, so a loader that warned would
print one line per spec and contaminate ``--json``, and a loader that
probed would dial N endpoints for a command that asked no
reachability question. Both belong on the START path
(:mod:`_lifecycle._engine_select`), the same ruling that placed
``warn_if_legacy_harness_key`` there.
A malformed engines block (two defaults, none, an unknown harness, a
legacy disagreement) is already a LOAD error from
``_engine_validation.validate_engines``, which runs first; the
``EngineDefaultError`` swallowed here is only reachable for a
hand-built dict that skipped the validator, and leaving the config on
its legacy fields is the honest outcome for a spec whose default
could not be determined.
"""
if not engines:
return None
from ._engine_precedence import default_engine as _spec_local_default
from ._engine_precedence import resolve_default_for_spec
try:
if spec is None:
# No spec handed over: answer the narrower SPEC-LOCAL
# question. Reading the fleet default here would be worse
# than useless — it would apply a fleet-wide backend to a
# hand-built config whose legacy pin this function cannot
# see, which is precisely the silent repointing the
# precedence exists to prevent.
selected = _spec_local_default(engines)
else:
selected = resolve_default_for_spec(spec, engines)
except (EngineDefaultError, UnknownEngineError):
return None
if selected is not None:
apply_engine(config, selected)
return selected
# Legacy/backend conflict checks live in ``_engine_migration`` until
# MIGRATION_END_CONDITION; these exports preserve the axis's import surface.
def legacy_backend(spec: Mapping) -> dict[str, Any]:
"""See :func:`_engine_migration.legacy_backend`."""
from ._engine_migration import legacy_backend as _impl
return _impl(spec)
def legacy_conflict_messages(spec: Mapping) -> list[str]:
"""See :func:`_engine_migration.legacy_conflict_messages`."""
from ._engine_migration import legacy_conflict_messages as _impl
return _impl(spec)