mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
feat(delegation): backends declare their own route keys; route saves keep them
A delegation backend's routes can carry keys of its own (e.g. a per-route `daybreak` opt-in), but PUT /api/delegation/backends/model_map copied only core's route fields, so any Desktop save silently dropped them and the route kept running without the setting. - DelegationProvider.route_keys (default empty): keys of the backend's own route entries that settings editors may write and must keep. - BackendInfo / GET /api/delegation/routing list each backend's declared route_keys (blank, non-string and repeated entries dropped; a broken plugin property declares nothing instead of hiding the backend). - The model_map save keeps declared keys as sent (the backend validates them) and any other key the stored route already carries when the save sends it back unchanged; a key nobody stored is still dropped.
This commit is contained in:
1 parent
ab84eb7aa0
commit
494b618eb9
4 files changed
+92
-9
No files matched your search
@@ -35,13 +35,26 @@ class BackendInfo:
|
||||
description: str
|
||||
available: bool
|
||||
reason: str = ""
|
||||
#: The backend's own route-entry keys settings editors may write (``DelegationProvider.route_keys``).
|
||||
route_keys: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _route_keys(provider: Any) -> tuple[str, ...]:
|
||||
# A plugin's answer (possibly a list over the plugin host's wire); anything malformed declares nothing.
|
||||
try:
|
||||
raw = provider.route_keys
|
||||
except Exception: # health: allow BLE001 -- a broken plugin property must not hide its backend
|
||||
return ()
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return ()
|
||||
return tuple(dict.fromkeys(k.strip() for k in raw if isinstance(k, str) and k.strip()))
|
||||
|
||||
|
||||
def _info(name: str) -> BackendInfo:
|
||||
provider, reason = backend_status(name)
|
||||
if provider is None:
|
||||
return BackendInfo(name, name, "", False, reason)
|
||||
return BackendInfo(name, provider.display_name, provider.summary, not reason, reason)
|
||||
return BackendInfo(name, provider.display_name, provider.summary, not reason, reason, _route_keys(provider))
|
||||
|
||||
|
||||
def list_backends() -> list[BackendInfo]:
|
||||
|
||||
@@ -78,6 +78,13 @@ class DelegationProvider(ProviderBase):
|
||||
"""One user-facing line for backend pickers (``/delegation``); never shown to the model."""
|
||||
return ""
|
||||
|
||||
@property
|
||||
def route_keys(self) -> tuple[str, ...]:
|
||||
"""Keys of this backend's own route entries, beyond core's route fields, that settings editors may write and
|
||||
must keep (e.g. a per-route ``daybreak`` opt-in). Editors offer a control only when its key is listed here,
|
||||
so an older plugin never gets a setting it would ignore. The backend validates their values itself."""
|
||||
return ()
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Whether this provider can run here. MUST NOT make network calls. False fails delegation closed: a
|
||||
``delegate_task`` call under this backend is a tool error naming it, never a run on another route."""
|
||||
|
||||
@@ -150,12 +150,13 @@ def _check_default(default: Optional[str], names: list[str], what: str) -> Optio
|
||||
return value or None
|
||||
|
||||
|
||||
def _clean_model_option(index: int, entry: dict, stored: dict[str, dict]) -> dict[str, Any]:
|
||||
def _clean_model_option(index: int, entry: dict, stored: dict[str, dict],
|
||||
declared: tuple[str, ...] = ()) -> dict[str, Any]:
|
||||
from agent.delegation_provider import ROUTE_KEYS
|
||||
from agent.fast_mode import STATIC_TIERS, parse_exact_service_tier
|
||||
from hermes_constants import VALID_REASONING_EFFORTS, parse_reasoning_effort
|
||||
|
||||
row = {key: _text(entry, key) for key in _OPTION_TEXT_KEYS if _text(entry, key)}
|
||||
row: dict[str, Any] = {key: _text(entry, key) for key in _OPTION_TEXT_KEYS if _text(entry, key)}
|
||||
label = f"Route {index + 1}"
|
||||
if not row.get("name"):
|
||||
raise _bad_request(f"{label} needs a name.")
|
||||
@@ -188,11 +189,22 @@ def _clean_model_option(index: int, entry: dict, stored: dict[str, dict]) -> dic
|
||||
for field in sorted(ROUTE_KEYS - validated):
|
||||
if entry.get(field) not in (None, "", [], {}):
|
||||
row[field] = entry[field]
|
||||
# The backend's own keys (DelegationProvider.route_keys, e.g. a ``daybreak`` opt-in) are kept as sent; the
|
||||
# backend validates them. Absent = the editor turned it off.
|
||||
for field in declared:
|
||||
if field not in row and field not in ROUTE_KEYS and entry.get(field) is not None:
|
||||
row[field] = entry[field]
|
||||
# A route the renderer came from (a rename names it in original_name; once that save landed, it is stored
|
||||
# under its new name).
|
||||
source = stored.get(_text(entry, "original_name")) or stored.get(row["name"]) or {}
|
||||
# Any other key the stored route carries (hand-written, or declared by a backend that is not loaded now)
|
||||
# survives an untouched round-trip, so a save never silently drops a setting; a key nobody stored is dropped.
|
||||
for field, value in source.items():
|
||||
if field not in row and field not in ROUTE_KEYS and field != "api_key" and entry.get(field) == value:
|
||||
row[field] = value
|
||||
key = entry.get("api_key")
|
||||
if is_redacted_credential_preview(key):
|
||||
# The renderer only ever saw a preview: keep the stored key of the route it came from (a rename names
|
||||
# it in original_name; once that save landed, the route is stored under its new name).
|
||||
source = stored.get(_text(entry, "original_name")) or stored.get(row["name"]) or {}
|
||||
# The renderer only ever saw a preview: keep the stored key of the route it came from.
|
||||
key = source.get("api_key")
|
||||
if isinstance(key, str) and key.strip():
|
||||
row["api_key"] = key.strip()
|
||||
@@ -217,7 +229,8 @@ async def save_model_routes(body: DelegationModelMapUpdate, profile: Optional[st
|
||||
"""Replace ``delegation.backends.model_map`` (``options`` + ``default``) after validating every route."""
|
||||
def _build(current: dict) -> dict:
|
||||
stored = {_text(o, "name"): o for o in current.get("options") or [] if isinstance(o, dict)}
|
||||
options = [_clean_model_option(i, dict(o), stored) for i, o in enumerate(body.options)]
|
||||
declared = next((tuple(b.get("route_keys") or ()) for b in _registered_backends() if b["name"] == MODEL_MAP), ())
|
||||
options = [_clean_model_option(i, dict(o), stored, declared) for i, o in enumerate(body.options)]
|
||||
names = [o["name"] for o in options]
|
||||
duplicate = next((n for n in names if names.count(n) > 1), None)
|
||||
if duplicate:
|
||||
|
||||
@@ -16,19 +16,24 @@ from agent.delegation_provider import DelegationProvider
|
||||
|
||||
|
||||
class _Routes(DelegationProvider):
|
||||
def __init__(self, name):
|
||||
def __init__(self, name, route_keys=()):
|
||||
self._name = name
|
||||
self._route_keys = route_keys
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def route_keys(self):
|
||||
return self._route_keys
|
||||
|
||||
def route_task(self, task, config):
|
||||
return dict(config)
|
||||
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_delegation_provider(_Routes("model_map"))
|
||||
ctx.register_delegation_provider(_Routes("model_map", ("daybreak", "", 3, "daybreak")))
|
||||
ctx.register_delegation_provider(_Routes("profile_routes"))
|
||||
'''
|
||||
|
||||
@@ -165,6 +170,51 @@ def test_saving_model_routes_replaces_the_block_and_keeps_the_stored_key(client,
|
||||
assert "backends" not in _delegation(root)
|
||||
|
||||
|
||||
def test_routing_lists_each_backends_declared_route_keys(client):
|
||||
backends = {b["name"]: b["route_keys"] for b in client.get("/api/delegation/routing?profile=work").json()["backends"]}
|
||||
# Blank, non-string and repeated declarations are dropped; a backend that declares nothing lists nothing.
|
||||
assert backends == {"default": [], "model_map": ["daybreak"], "profile_routes": []}
|
||||
|
||||
|
||||
def test_a_declared_route_key_round_trips_and_removing_it_turns_it_off(client, homes):
|
||||
_root, work = homes
|
||||
route = {"name": "daybreak", "description": "Sol, reduced guardrails", "provider": "openai-codex",
|
||||
"model": "gpt-6-sol", "service_tier": "fast"}
|
||||
resp = client.put("/api/delegation/backends/model_map?profile=work", json={"options": [{**route, "daybreak": True}]})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _delegation(work)["backends"]["model_map"]["options"][0]["daybreak"] is True
|
||||
# Re-saving what the renderer was shown keeps it (an unrelated edit must never silently drop it).
|
||||
shown = client.get("/api/delegation/routing?profile=work").json()["model_map"]["options"]
|
||||
resp = client.put("/api/delegation/backends/model_map?profile=work",
|
||||
json={"options": [{**shown[0], "original_name": "daybreak", "reasoning_effort": "high"}]})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _delegation(work)["backends"]["model_map"]["options"][0]["daybreak"] is True
|
||||
# The editor turns it off by leaving the key out.
|
||||
assert client.put("/api/delegation/backends/model_map?profile=work", json={"options": [route]}).status_code == 200
|
||||
assert "daybreak" not in _delegation(work)["backends"]["model_map"]["options"][0]
|
||||
|
||||
|
||||
def test_an_undeclared_key_survives_only_an_untouched_round_trip(client, homes):
|
||||
_root, work = homes
|
||||
stored = {"name": "deep", "description": "Careful", "provider": "anthropic", "model": "m-deep", "legacy": {"a": 1}}
|
||||
assert client.put("/api/delegation/backends/model_map?profile=work", json={"options": [stored]}).status_code == 200
|
||||
# A key nobody stored (no backend declares it) is dropped, however the renderer sends it.
|
||||
assert "legacy" not in _delegation(work)["backends"]["model_map"]["options"][0]
|
||||
|
||||
block = _delegation(work)
|
||||
block["backends"]["model_map"]["options"][0]["legacy"] = {"a": 1} # hand-written in config.yaml
|
||||
(work / "config.yaml").write_text(yaml.safe_dump({"plugins": {"enabled": ["routes"]}, "delegation": block}),
|
||||
encoding="utf-8")
|
||||
shown = client.get("/api/delegation/routing?profile=work").json()["model_map"]["options"][0]
|
||||
resp = client.put("/api/delegation/backends/model_map?profile=work",
|
||||
json={"options": [{**shown, "original_name": "deep", "model": "m-deeper"}]})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _delegation(work)["backends"]["model_map"]["options"][0]["legacy"] == {"a": 1}
|
||||
changed = {**shown, "legacy": {"a": 2}}
|
||||
assert client.put("/api/delegation/backends/model_map?profile=work", json={"options": [changed]}).status_code == 200
|
||||
assert "legacy" not in _delegation(work)["backends"]["model_map"]["options"][0]
|
||||
|
||||
|
||||
def test_profile_routes_only_name_existing_profiles(client, homes):
|
||||
_root, work = homes
|
||||
before = (work / "config.yaml").read_text(encoding="utf-8")
|
||||
|
||||
Reference in new issue
Block a user