mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix: Daybreak follows the launch policy and reaches isolated turns
Platform.LOCAL is the canonical transport, not the owner: hosted rooms (bot_room) and A2A peers stage LOCAL sessions too, so the platform gate still sent Daybreak on their turns. The gateway now reads the route's frozen launch policy and allows only the owner's own creation labels (cli, tui, gui, oneshot; gateway.session_policy.DAYBREAK_LABELS). Third- party tool integrations, editor (ACP) clients and an unreadable policy get standard requests. With turn isolation the live agent is in the compute-host child, which read the switch once at build; the parent's live flip only reaches its own process. The child now re-reads it from its profile's config.yaml on every reused turn, with the same local-surface gate.
This commit is contained in:
1 parent
7dbe1bff8f
commit
b51e4272ea
6 files changed
+99
-18
No files matched your search
@@ -280,15 +280,22 @@ class GatewayConfigLoadersMixin:
|
||||
|
||||
return _parse_service_tier_config(cls._cfg_str("agent", "service_tier"))
|
||||
|
||||
@staticmethod
|
||||
def _load_daybreak(source) -> bool:
|
||||
def _load_daybreak(self, source) -> bool:
|
||||
"""``agent.daybreak`` (off unless explicitly true) for a turn from ``source``. Read per turn so the
|
||||
Desktop switch reaches the next turn of a running chat without rebuilding its cached agent. Only the
|
||||
owner's local surfaces carry it: Daybreak is the approved account's own access (OpenAI: no third-party
|
||||
or downstream use), and a messaging platform's chats can be other people."""
|
||||
owner's own chats carry it: Daybreak is the approved account's own access (OpenAI: no third-party
|
||||
or downstream use), and a messaging platform's chats, a hosted room or an A2A peer can be other people.
|
||||
LOCAL is the canonical transport, not the owner, so the route's frozen launch policy decides."""
|
||||
from gateway.run import _load_gateway_config
|
||||
from gateway.session_policy import DAYBREAK_LABELS, policy_for_source
|
||||
if getattr(source, "platform", None) != Platform.LOCAL:
|
||||
return False
|
||||
try:
|
||||
policy = policy_for_source(self, source)
|
||||
except Exception: # health: allow BLE001 -- an unreadable policy fails closed to standard requests
|
||||
return False
|
||||
if getattr(policy, "source", None) not in DAYBREAK_LABELS:
|
||||
return False
|
||||
return cfg_get(_load_gateway_config(), "agent", "daybreak") is True
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -84,6 +84,10 @@ _TURN_PROFILE: ContextVar = ContextVar('follow_profile_read', default=None) # (
|
||||
# pickers hide them (INTERNAL_LISTING_SOURCES).
|
||||
SURFACES = {'cli': 'cli', 'tui': 'tui', 'gui': 'desktop', 'acp': 'acp', 'tool': 'cli', 'oneshot': 'cli'}
|
||||
# ``tool`` and ``oneshot`` stay stored under their own labels so pickers can hide them.
|
||||
# Creation labels of the owner's own chats: the only LOCAL routes that carry OpenAI Daybreak (agent.daybreak),
|
||||
# the approved account's own access (no third-party or downstream use). Not ``tool`` (third-party
|
||||
# integrations), ``acp`` (editor clients), or the ``bot_room`` / ``a2a`` policies hosted rooms and peers stage.
|
||||
DAYBREAK_LABELS = frozenset({'cli', 'tui', 'gui', 'oneshot'})
|
||||
# Every other creation label is rewritten to ``source='local'`` on peer refresh.
|
||||
_KEEP_STORED_LABEL = frozenset({'tool', 'oneshot'})
|
||||
STORED_AS_LOCAL = frozenset(SURFACES) - _KEEP_STORED_LABEL
|
||||
|
||||
@@ -107,36 +107,63 @@ def test_offered_only_where_the_account_lists_it(catalog):
|
||||
access_token=token("A"))
|
||||
|
||||
|
||||
def _owner_policies(home, monkeypatch):
|
||||
"""Real launch policies, built the way each producer builds them (build_policy, then the hosted-room and
|
||||
A2A rewrites in gateway/session_hosted_rpc.py and gateway/session_a2a.py), keyed by a LOCAL chat id."""
|
||||
from dataclasses import replace
|
||||
from gateway import run as gateway_run
|
||||
from gateway import session_policy
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", home) # frozen at import by an earlier test
|
||||
policies = {label: session_policy.build_policy({"source": label}, {}, private_secrets={})
|
||||
for label in ("gui", "cli", "tui", "oneshot", "tool", "acp")}
|
||||
policies["bot_room"] = replace(policies["gui"], source="bot_room", platform="bot_room")
|
||||
policies["a2a"] = replace(policies["cli"], source="a2a", platform="a2a")
|
||||
|
||||
def policy_for_source(runner, source):
|
||||
if source.chat_id == "unreadable":
|
||||
raise RuntimeError("storage_unavailable")
|
||||
return policies[source.chat_id]
|
||||
monkeypatch.setattr(session_policy, "policy_for_source", policy_for_source)
|
||||
return policies
|
||||
|
||||
|
||||
def _load(platform, chat_id="gui"):
|
||||
from gateway.run_config_loaders import GatewayConfigLoadersMixin
|
||||
return GatewayConfigLoadersMixin._load_daybreak(SimpleNamespace(), SimpleNamespace(platform=platform,
|
||||
chat_id=chat_id))
|
||||
|
||||
|
||||
def test_gateway_reads_the_profile_switch_each_turn(tmp_path, monkeypatch):
|
||||
"""The owner's chat turns (Desktop, TUI, ``hermes chat``: ``Platform.LOCAL``) run in the gateway, which copies
|
||||
the switch onto the turn's agent from the profile's config.yaml, so a flip reaches the next turn of a running chat."""
|
||||
from gateway.config import Platform
|
||||
from gateway.run_config_loaders import GatewayConfigLoadersMixin
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
_owner_policies(home, monkeypatch)
|
||||
seen = {}
|
||||
for value in ("true", "false", '"yes"', None):
|
||||
(home / "config.yaml").write_text("model: {}\n" if value is None else f"agent:\n daybreak: {value}\n",
|
||||
encoding="utf-8")
|
||||
seen[value] = GatewayConfigLoadersMixin._load_daybreak(SimpleNamespace(platform=Platform.LOCAL))
|
||||
seen[value] = _load(Platform.LOCAL)
|
||||
assert seen == {"true": True, "false": False, '"yes"': False, None: False}
|
||||
|
||||
|
||||
def test_messaging_platform_turns_never_carry_daybreak(tmp_path, monkeypatch):
|
||||
"""Daybreak is the approved account's own access (OpenAI: no third-party or downstream use). A messaging
|
||||
platform's chats can be other people, so with the switch on only the owner's local surfaces carry it."""
|
||||
from gateway import run as gateway_run
|
||||
def test_only_the_owners_own_chats_carry_daybreak(tmp_path, monkeypatch):
|
||||
"""Daybreak is the approved account's own access (OpenAI: no third-party or downstream use). Messaging
|
||||
chats, hosted rooms, A2A peers, third-party ``tool`` integrations and editor clients can be other people,
|
||||
so with the switch on only the owner's Desktop, TUI and CLI chats carry it."""
|
||||
from gateway.config import Platform
|
||||
from gateway.run_config_loaders import GatewayConfigLoadersMixin
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", home) # frozen at import by an earlier test
|
||||
policies = _owner_policies(home, monkeypatch)
|
||||
(home / "config.yaml").write_text("agent:\n daybreak: true\n", encoding="utf-8")
|
||||
loaded = {platform: GatewayConfigLoadersMixin._load_daybreak(SimpleNamespace(platform=platform))
|
||||
for platform in (Platform.LOCAL, Platform.TELEGRAM, Platform.DISCORD, Platform.API_SERVER)}
|
||||
assert loaded == {Platform.LOCAL: True, Platform.TELEGRAM: False, Platform.DISCORD: False,
|
||||
Platform.API_SERVER: False}
|
||||
|
||||
platforms = {platform: _load(platform) for platform in (Platform.TELEGRAM, Platform.DISCORD, Platform.API_SERVER)}
|
||||
assert platforms == {Platform.TELEGRAM: False, Platform.DISCORD: False, Platform.API_SERVER: False}
|
||||
local = {label: _load(Platform.LOCAL, label) for label in [*policies, "unreadable"]}
|
||||
assert local == {"gui": True, "cli": True, "tui": True, "oneshot": True, "tool": False, "acp": False,
|
||||
"bot_room": False, "a2a": False, "unreadable": False}
|
||||
|
||||
|
||||
def test_model_menu_capability_marks_only_listed_subscription_models(catalog, monkeypatch):
|
||||
|
||||
@@ -95,3 +95,27 @@ def test_new_agents_read_the_switch_only_for_local_surfaces(monkeypatch, source,
|
||||
|
||||
agent = server._make_agent("sid", "session-key", platform_override=source)
|
||||
assert agent.daybreak is expected
|
||||
|
||||
|
||||
def test_an_isolated_turns_reused_child_agent_follows_the_switch(homes, monkeypatch):
|
||||
"""With turn isolation the live agent is in the compute-host child, out of reach of the parent's live
|
||||
flip, so each turn the child re-reads the switch from its profile's config.yaml, in both directions."""
|
||||
from tui_gateway.compute_host import ComputeHost
|
||||
_launch, worker = homes
|
||||
child = object.__new__(ComputeHost)
|
||||
child._transport = object()
|
||||
desktop = SimpleNamespace(daybreak=True, platform="desktop")
|
||||
bot = SimpleNamespace(daybreak=False, platform="telegram")
|
||||
monkeypatch.setitem(server._sessions, "desktop-sid", {"agent": desktop, "profile_home": str(worker)})
|
||||
monkeypatch.setitem(server._sessions, "bot-sid", {"agent": bot, "profile_home": str(worker)})
|
||||
config = worker / "config.yaml"
|
||||
|
||||
config.write_text("agent:\n daybreak: false\n", encoding="utf-8")
|
||||
child._ensure_server_session(server, {"sid": "desktop-sid", "profile_home": str(worker)})
|
||||
assert desktop.daybreak is False
|
||||
|
||||
config.write_text("agent:\n daybreak: true\n", encoding="utf-8")
|
||||
for sid in ("desktop-sid", "bot-sid"):
|
||||
child._ensure_server_session(server, {"sid": sid, "profile_home": str(worker)})
|
||||
assert desktop.daybreak is True
|
||||
assert bot.daybreak is False
|
||||
@@ -331,6 +331,7 @@ class ComputeHost:
|
||||
session[key] = str(frame[key])
|
||||
if "explicit_cwd" in frame:
|
||||
session["explicit_cwd"] = bool(frame["explicit_cwd"])
|
||||
self._refresh_daybreak(server, session)
|
||||
else:
|
||||
session = self._build_server_session(server, frame, sid)
|
||||
if isinstance(frame.get("attached_images"), list):
|
||||
@@ -342,6 +343,24 @@ class ComputeHost:
|
||||
session["pending_model_switch"] = dict(frame["pending_model_switch"])
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def _refresh_daybreak(server: Any, session: dict) -> None:
|
||||
"""``agent.daybreak`` is read when the agent is built, and the parent's live flip only reaches agents in
|
||||
its own process, so a reused child agent re-reads it each turn from its profile's config.yaml."""
|
||||
agent = session.get("agent")
|
||||
if agent is None:
|
||||
return
|
||||
from agent.session_source import DAYBREAK_SOURCES
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
profile_home = str(session.get("profile_home") or "")
|
||||
token = set_hermes_home_override(profile_home) if profile_home else None
|
||||
try:
|
||||
on = (server._load_cfg().get("agent") or {}).get("daybreak") is True
|
||||
finally:
|
||||
if token is not None:
|
||||
reset_hermes_home_override(token)
|
||||
agent.daybreak = on and getattr(agent, "platform", None) in DAYBREAK_SOURCES
|
||||
|
||||
def _build_server_session(self, server: Any, frame: dict[str, Any], sid: str) -> dict:
|
||||
"""Build the agent under the frame's profile scope and register the session."""
|
||||
key = str(frame.get("session_key") or sid)
|
||||
|
||||
@@ -1961,7 +1961,7 @@ agent:
|
||||
|
||||
In Desktop, open a model's options in the model menu: the **Daybreak** switch appears next to **Fast**, only on models your signed-in account's Codex catalog lists for Daybreak. Accounts without Daybreak never see it. The switch is profile-wide, like Codex's own toggle, and applies from the next turn.
|
||||
|
||||
Every request checks the catalog again, so `access_programs.cyber: daybreak_blue` is sent only when the switch is on **and** that request's account and model are listed. Switching to an unlisted model, rotating to an account without access, or an unreadable catalog sends standard requests. It is not sent on API-key routes or the opt-in `codex_app_server` runtime, and only your own chat turns use it (Desktop, the TUI and the CLI). Messaging-platform chats, cron jobs and subagents use standard safeguards: OpenAI allows Daybreak no third-party or downstream access, and a bot's chats can be other people. The setting grants no access: approval comes from OpenAI.
|
||||
Every request checks the catalog again, so `access_programs.cyber: daybreak_blue` is sent only when the switch is on **and** that request's account and model are listed. Switching to an unlisted model, rotating to an account without access, or an unreadable catalog sends standard requests. It is not sent on API-key routes or the opt-in `codex_app_server` runtime, and only your own chat turns use it (Desktop, the TUI and the CLI). Messaging-platform chats, hosted rooms, A2A peers, third-party `--source tool` integrations, editor (ACP) clients, cron jobs and subagents use standard safeguards: OpenAI allows Daybreak no third-party or downstream access, and those chats can be other people. The setting grants no access: approval comes from OpenAI.
|
||||
|
||||
:::note Adaptive-thinking models (Claude 4.6+, Fable/Mythos-class) over OpenRouter
|
||||
These models use *adaptive* thinking and don't accept the usual `reasoning.effort`
|
||||
|
||||
Reference in new issue
Block a user