From 7c5f122d94307d5ece44c9ae2bae9f0a7615eee4 Mon Sep 17 00:00:00 2001 From: sprooty Date: Tue, 4 Aug 2026 00:53:18 +0000 Subject: [PATCH] feat: a fallback chain can be configured through the API, not only the CLI Chains shipped in the CLI and in the stored role map, and `RoleRoute` -- the only way to configure a deployed pod -- could not express one. Found while repointing Node B: the models an operator had chosen could be set locally and not on the machine that runs the work, which makes the feature unshipped where it matters. `models` names the chain in preference order; `model` stays the preferred one and is filled in from the first when omitted, so every existing map, client and reader that knows only `model` is unaffected. Giving both is allowed and checked: a route whose two fields contradict each other behaves differently depending on which one a reader consults, so it is refused with the mismatch named rather than silently resolved. --- src/agent_harness/schemas.py | 45 +++++++++++++++++++++++++++++-- tests/test_model_fallback.py | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/agent_harness/schemas.py b/src/agent_harness/schemas.py index 2bc2fc7..a74a609 100644 --- a/src/agent_harness/schemas.py +++ b/src/agent_harness/schemas.py @@ -14,7 +14,14 @@ from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + computed_field, + field_validator, + model_validator, +) # --------------------------------------------------------------------- work @@ -170,7 +177,19 @@ class SetFleetControl(BaseModel): class RoleRoute(BaseModel): - model: str = Field(description="Model identifier as the provider names it.") + model: str = Field( + "", + description="Model identifier as the provider names it. The PREFERRED " + "one when `models` names several; filled in from the first of them " + "when omitted.", + ) + models: list[str] = Field( + default_factory=list, + description="Models to try for this role, in preference order. The " + "first that answers does the work; the rest are tried only when it " + "will not, and the whole list is tried before any backoff. Omit for a " + "single model — `model` alone still works and always has.", + ) endpoint: str = Field(description="Base URL of the provider API.") provider: str = Field( "claw-bay", @@ -179,6 +198,28 @@ class RoleRoute(BaseModel): "limit, because nothing in HTTP can.", ) + @model_validator(mode="after") + def one_source_of_truth(self) -> RoleRoute: + """Keep `model` and `models` from disagreeing. + + They are two views of one thing, and a route where they contradict + each other is a route whose behaviour depends on which field a reader + happens to consult -- exactly the ambiguity that made the old + single-field map unable to express a fallback at all. + """ + if self.models and not self.model: + self.model = self.models[0] + elif self.model and not self.models: + self.models = [self.model] + elif not self.model and not self.models: + raise ValueError("a role needs a model: set `model`, or `models` in preference order") + elif self.models[0] != self.model: + raise ValueError( + f"`model` is {self.model!r} but `models` prefers {self.models[0]!r}; " + "`model` is the preferred route, so either match it or omit it" + ) + return self + class RoutedRole(RoleRoute): """A route, and whether this deployment's executor ever calls it.""" diff --git a/tests/test_model_fallback.py b/tests/test_model_fallback.py index dab9ab2..327cab3 100644 --- a/tests/test_model_fallback.py +++ b/tests/test_model_fallback.py @@ -251,3 +251,55 @@ def test_a_map_written_before_fallbacks_existed_still_reads() -> None: def test_a_role_with_no_endpoint_is_dropped_rather_than_half_built() -> None: assert chains_from_map({"reviewer": {"models": ["gpt-5.6"]}}) == {} + + +# --------------------------------------------------- configuring it remotely + + +def test_the_api_schema_can_express_a_chain() -> None: + """The gap this closes: chains shipped in the CLI and the stored map, and + `RoleRoute` — the only way to configure a deployed pod — could not say + them. A feature unconfigurable where it is used is not shipped.""" + from agent_harness.schemas import RoleRoute + + route = RoleRoute(models=["deepseek-v4-flash", "glm-5.2", "gpt-5.4"], endpoint=ENDPOINT) + + assert route.model == "deepseek-v4-flash", "`model` is the preferred one" + assert [ + r.model for r in chains_from_map({"implementer": route.model_dump()})["implementer"] + ] == [ + "deepseek-v4-flash", + "glm-5.2", + "gpt-5.4", + ] + + +def test_a_single_model_still_works_unchanged() -> None: + from agent_harness.schemas import RoleRoute + + route = RoleRoute(model="gpt-5.6", endpoint=ENDPOINT) + + assert route.models == ["gpt-5.6"] + assert [r.model for r in chains_from_map({"reviewer": route.model_dump()})["reviewer"]] == [ + "gpt-5.6" + ] + + +def test_a_route_whose_two_fields_disagree_is_refused() -> None: + """They are two views of one thing. A route where they contradict behaves + differently depending on which field a reader consults.""" + from pydantic import ValidationError + + from agent_harness.schemas import RoleRoute + + with pytest.raises(ValidationError, match="preferred route"): + RoleRoute(model="gpt-5.6", models=["gpt-5.4", "gpt-5.6"], endpoint=ENDPOINT) + + +def test_a_route_naming_no_model_at_all_is_refused() -> None: + from pydantic import ValidationError + + from agent_harness.schemas import RoleRoute + + with pytest.raises(ValidationError, match="a role needs a model"): + RoleRoute(endpoint=ENDPOINT)