mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix: read Daybreak at each turn and bind its writes to the clicked connection
Standalone hermes serve read agent.daybreak when an agent was built and then relied on a live-agent scan and a compute-host refresh. A build that raced a flip could still publish the old value, and the compute-host read bound only the home, not the profile's secrets and terminal policy. _prepare_turn_input now reads it once the turn's final agent is selected, inside the turn's own profile scope (prompt_turn._sync_agent_daybreak). That one read covers in-process and compute-host turns, so the build-time read, the live-agent scan and the compute-host refresh are gone. Desktop: a queued write kept the primary menu's focus-following requester, so after a focus switch it could reach another connection's same-named profile. The model menu now binds Daybreak reads and writes to the clicked surface's own gateway.
This commit is contained in:
1 parent
b51e4272ea
commit
40b566502a
7 files changed
+144
-104
No files matched your search
@@ -3,6 +3,8 @@ import { renderHook, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { $daybreakByProfile, daybreakKey } from '@/store/daybreak'
|
||||
import { $notifications } from '@/store/notifications'
|
||||
import { $activeSessionId, $currentModel, $currentProvider } from '@/store/session'
|
||||
|
||||
@@ -15,7 +17,9 @@ it('a level picked while a reply streams applies from the next message instead o
|
||||
$notifications.set([])
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) =>
|
||||
method === 'config.set' ? { ok: true, operation: 'reasoning', status: 'deferred', value: 'high' } : { providers: [] }
|
||||
method === 'config.set'
|
||||
? { ok: true, operation: 'reasoning', status: 'deferred', value: 'high' }
|
||||
: { providers: [] }
|
||||
)
|
||||
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
@@ -29,7 +33,10 @@ it('a level picked while a reply streams applies from the next message instead o
|
||||
{ wrapper }
|
||||
)
|
||||
|
||||
result.current.controller.setOptions({ effort: 'high' }, { isActive: true, model: 'gemini-3.1-pro', provider: 'google' })
|
||||
result.current.controller.setOptions(
|
||||
{ effort: 'high' },
|
||||
{ isActive: true, model: 'gemini-3.1-pro', provider: 'google' }
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect($notifications.get()).toContainEqual(
|
||||
@@ -38,3 +45,55 @@ it('a level picked while a reply streams applies from the next message instead o
|
||||
)
|
||||
expect($notifications.get().some(n => n.kind === 'error')).toBe(false)
|
||||
})
|
||||
|
||||
it('a queued Daybreak flip goes to the connection that was clicked, not the one focused later', async () => {
|
||||
$daybreakByProfile.set({})
|
||||
|
||||
let releaseFirst!: (value: unknown) => void
|
||||
|
||||
const firstReply = new Promise(resolve => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
|
||||
const local = {
|
||||
request: vi.fn((method: string, params?: Record<string, unknown>) =>
|
||||
method === 'config.set' && params?.value === true ? firstReply : Promise.resolve({ value: params?.value })
|
||||
)
|
||||
}
|
||||
|
||||
const remote = {
|
||||
request: vi.fn(async (_method: string, params?: Record<string, unknown>) => ({ value: params?.value }))
|
||||
}
|
||||
|
||||
// The primary menu's shared requester follows focus: it reaches whichever connection is focused when called.
|
||||
const focusedRequest = vi.fn(async () => ({ providers: [] }))
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
)
|
||||
|
||||
const { rerender, result } = renderHook(
|
||||
({ gateway, connection }: { connection: string; gateway: typeof local }) =>
|
||||
useModelMenuController({
|
||||
gateway: gateway as unknown as HermesGateway,
|
||||
onSelectModel: vi.fn(),
|
||||
ownerConnectionId: connection,
|
||||
profile: 'default',
|
||||
requestGateway: focusedRequest as never
|
||||
}),
|
||||
{ initialProps: { connection: 'local', gateway: local }, wrapper }
|
||||
)
|
||||
|
||||
result.current.controller.daybreak?.set(true)
|
||||
result.current.controller.daybreak?.set(false) // queued behind the first write
|
||||
rerender({ connection: 'remote', gateway: remote }) // focus moves to another connection's `default`
|
||||
|
||||
releaseFirst({ value: true })
|
||||
await waitFor(() => expect(local.request).toHaveBeenCalledTimes(2))
|
||||
|
||||
expect(local.request).toHaveBeenLastCalledWith('config.set', { key: 'daybreak', profile: 'default', value: false })
|
||||
expect(remote.request).not.toHaveBeenCalledWith('config.set', expect.anything())
|
||||
expect(focusedRequest).not.toHaveBeenCalledWith('config.set', expect.anything())
|
||||
await waitFor(() => expect($daybreakByProfile.get()[daybreakKey('default', 'local')]).toBe(false))
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { DEFAULT_REASONING_EFFORT, type ModelOptionsResult } from '@hermes/share
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { atom } from 'nanostores'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
@@ -107,8 +107,20 @@ export function useModelMenuController({
|
||||
)
|
||||
|
||||
const daybreakOn = useStore($daybreakByProfile)[daybreakKey(profile, ownerConnectionId)] ?? false
|
||||
const daybreakRequest = useRef(requestGateway)
|
||||
daybreakRequest.current = requestGateway
|
||||
|
||||
// The primary menu's shared requester follows focus when called, and a queued write (store/daybreak.ts)
|
||||
// can go out after focus moved: bind reads and writes to THIS connection's gateway when the host has one.
|
||||
// A tile's requester is already bound to its owner route.
|
||||
const daybreakRequester = useCallback(
|
||||
(requester: typeof requestGateway) =>
|
||||
gateway
|
||||
? (method: string, params?: Record<string, unknown>) => gateway.request(method, params)
|
||||
: (method: string, params?: Record<string, unknown>) => requester(method, params),
|
||||
[gateway]
|
||||
)
|
||||
|
||||
const daybreakRequest = useRef(daybreakRequester(requestGateway))
|
||||
daybreakRequest.current = daybreakRequester(requestGateway)
|
||||
|
||||
useEffect(() => {
|
||||
if (daybreakListed) {
|
||||
@@ -222,7 +234,7 @@ export function useModelMenuController({
|
||||
daybreak: {
|
||||
on: daybreakOn,
|
||||
set: on => {
|
||||
void setDaybreak(requestGateway, profile, on, ownerConnectionId).then(ok => {
|
||||
void setDaybreak(daybreakRequester(requestGateway), profile, on, ownerConnectionId).then(ok => {
|
||||
if (!ok) {
|
||||
notify({ id: 'daybreak-update-failed', kind: 'error', message: t.shell.modelOptions.daybreakFailed })
|
||||
}
|
||||
|
||||
@@ -50,72 +50,61 @@ def test_a_non_boolean_is_refused_rather_than_written(homes):
|
||||
assert not (launch / "config.yaml").exists()
|
||||
|
||||
|
||||
def test_a_flip_reaches_only_this_profiles_local_agents(homes, monkeypatch):
|
||||
class _StopAfterDaybreak(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _admit_turn(monkeypatch, session):
|
||||
"""Run real turn admission (``_prepare_turn_input``, under the session's real profile scope) and stop just
|
||||
after the turn's agent is selected and its Daybreak setting synced."""
|
||||
from tools import approval_context
|
||||
monkeypatch.setattr(server, "_set_session_context", lambda *a, **k: [])
|
||||
monkeypatch.setattr(server, "_wire_callbacks", lambda sid: None)
|
||||
for name in ("_apply_pending_model_switch", "_sync_agent_model_with_config", "_sync_agent_compression_with_config",
|
||||
"_sync_agent_fallback_with_config", "_sync_bot_capabilities"):
|
||||
monkeypatch.setattr(server, name, lambda sid, session: None)
|
||||
monkeypatch.setattr(server, "_adopt_out_of_band_turns", lambda session: None)
|
||||
|
||||
def stop(*args):
|
||||
raise _StopAfterDaybreak()
|
||||
monkeypatch.setattr(server, "_stage_first_contact_onboarding_note", stop)
|
||||
st = server._TurnRun(agent=None, one_turn_restore=None, terminal_callback=None, receipt_committed=False)
|
||||
try:
|
||||
with pytest.raises(_StopAfterDaybreak):
|
||||
server._prepare_turn_input("sid", session, st, "hello", [])
|
||||
finally:
|
||||
server._release_turn_scopes("sid", session, st)
|
||||
assert approval_context.get_current_session_key(default="") == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("platform", "expected"), [
|
||||
("desktop", True), ("tui", True), ("cli", True), ("telegram", False), ("acp", False), (None, False)])
|
||||
def test_every_turn_reads_the_switch_for_the_owners_own_surfaces(homes, monkeypatch, platform, expected):
|
||||
"""Each turn reads ``agent.daybreak`` from the session's profile once the final agent is selected, so a
|
||||
flip, a build that raced one, or a compute-host child (same turn path) all send the saved value. A plugin
|
||||
session tagged ``telegram`` (or an editor client) built by this backend gets standard requests."""
|
||||
import threading
|
||||
_launch, worker = homes
|
||||
launch_agent = SimpleNamespace(daybreak=False, platform="desktop")
|
||||
worker_agent = SimpleNamespace(daybreak=False, platform="tui")
|
||||
bot_agent = SimpleNamespace(daybreak=False, platform="telegram") # a plugin session in the same profile
|
||||
monkeypatch.setitem(server._sessions, "launch-sid", {"agent": launch_agent, "profile_home": None})
|
||||
monkeypatch.setitem(server._sessions, "worker-sid", {"agent": worker_agent, "profile_home": str(worker)})
|
||||
monkeypatch.setitem(server._sessions, "bot-sid", {"agent": bot_agent, "profile_home": str(worker)})
|
||||
monkeypatch.setitem(server._sessions, "unbuilt-sid", {"agent": None, "profile_home": str(worker)})
|
||||
|
||||
assert _call("config.set", key="daybreak", value=True, profile="code")["result"]["value"] is True
|
||||
assert worker_agent.daybreak is True
|
||||
assert launch_agent.daybreak is False # another profile's setting
|
||||
assert bot_agent.daybreak is False # someone else's chat: OpenAI allows no third-party access
|
||||
assert yaml.safe_load((worker / "config.yaml").read_text())["agent"]["daybreak"] is True
|
||||
|
||||
assert _call("config.set", key="daybreak", value=True)["result"]["value"] is True # launch profile
|
||||
assert launch_agent.daybreak is True
|
||||
|
||||
|
||||
def test_only_the_owners_local_surfaces_carry_it():
|
||||
from agent.session_source import DAYBREAK_SOURCES
|
||||
assert DAYBREAK_SOURCES == {"tui", "desktop", "cli"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("source", "expected"), [(None, True), ("desktop", True), ("telegram", False)])
|
||||
def test_new_agents_read_the_switch_only_for_local_surfaces(monkeypatch, source, expected):
|
||||
"""``_make_agent`` end to end with a stand-in agent class: the switch is on in config.yaml, and a plugin
|
||||
session tagged ``telegram`` built by this backend still gets standard requests."""
|
||||
monkeypatch.delenv("HERMES_DESKTOP", raising=False)
|
||||
monkeypatch.delenv("HERMES_MODEL", raising=False)
|
||||
monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
|
||||
monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
|
||||
monkeypatch.setattr(server, "_load_cfg", lambda: {"agent": {"daybreak": True},
|
||||
"model": {"default": "gpt-6-sol", "provider": "openai-codex"}})
|
||||
monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
lambda requested=None, target_model=None: {
|
||||
"provider": "openai-codex", "base_url": "https://chatgpt.com/backend-api/codex",
|
||||
"api_key": "token", "api_mode": "codex_responses", "credential_pool": None})
|
||||
monkeypatch.setattr("run_agent.AIAgent", lambda **kwargs: SimpleNamespace(model=kwargs.get("model")))
|
||||
monkeypatch.setattr(server, "_load_enabled_toolsets", lambda platform=None: ["file"])
|
||||
monkeypatch.setattr(server, "_get_db", lambda: None)
|
||||
|
||||
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)})
|
||||
agent = SimpleNamespace(daybreak=not expected, platform=platform)
|
||||
session = {"agent": agent, "session_key": "daybreak-key", "profile_home": str(worker),
|
||||
"history": [], "history_lock": threading.Lock()}
|
||||
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
|
||||
_admit_turn(monkeypatch, session)
|
||||
assert agent.daybreak is expected
|
||||
|
||||
config.write_text("agent:\n daybreak: false\n", encoding="utf-8") # flipped off between turns
|
||||
_admit_turn(monkeypatch, session)
|
||||
assert agent.daybreak is False
|
||||
|
||||
|
||||
def test_the_turn_reads_its_own_profile_not_the_launch_one(homes, monkeypatch):
|
||||
import threading
|
||||
launch, worker = homes
|
||||
(launch / "config.yaml").write_text("agent:\n daybreak: true\n", encoding="utf-8")
|
||||
(worker / "config.yaml").write_text("agent:\n daybreak: false\n", encoding="utf-8")
|
||||
agent = SimpleNamespace(daybreak=True, platform="desktop")
|
||||
_admit_turn(monkeypatch, {"agent": agent, "session_key": "worker-key", "profile_home": str(worker),
|
||||
"history": [], "history_lock": threading.Lock()})
|
||||
assert agent.daybreak is False
|
||||
@@ -686,8 +686,6 @@ def _make_agent(
|
||||
if context_cwd_is_launch_artifact is None:
|
||||
context_cwd_is_launch_artifact = _context_cwd_is_launch_artifact(session)
|
||||
agent._context_cwd_is_launch_artifact = bool(context_cwd_is_launch_artifact)
|
||||
from agent.session_source import DAYBREAK_SOURCES # live flips: methods_config_set._set_daybreak
|
||||
agent.daybreak = platform in DAYBREAK_SOURCES and (cfg.get("agent") or {}).get("daybreak") is True
|
||||
if fallback_notice:
|
||||
# Emitted once on the first successful reply via _emit_pending_fallback_notice -> status_callback.
|
||||
agent._pending_fallback_notice = fallback_notice
|
||||
|
||||
@@ -331,7 +331,6 @@ 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):
|
||||
@@ -343,24 +342,6 @@ 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)
|
||||
|
||||
@@ -510,22 +510,12 @@ def _set_display_toggle(rid, params, key, value, session):
|
||||
|
||||
|
||||
def _set_daybreak(rid, params, key, value, session):
|
||||
"""``agent.daybreak``: profile-wide like the native owner's (gateway/session_tui_config.py). This
|
||||
profile's live local-surface agents take it at once (agent.session_source.DAYBREAK_SOURCES); requests
|
||||
check eligibility (agent/codex_service_tiers.py)."""
|
||||
"""``agent.daybreak``: profile-wide like the native owner's (gateway/session_tui_config.py). Every turn
|
||||
reads it (prompt_turn._sync_agent_daybreak); requests check eligibility (agent/codex_service_tiers.py)."""
|
||||
on = _BOOL_WORDS.get(str(value).strip().lower())
|
||||
if on is None:
|
||||
return _err(rid, 4002, f"{key} takes true or false")
|
||||
_write_config_key("agent.daybreak", on)
|
||||
from agent.session_source import DAYBREAK_SOURCES
|
||||
from hermes_constants import get_hermes_home
|
||||
home = get_hermes_home().resolve()
|
||||
with _sessions_lock:
|
||||
live = list(_sessions.values())
|
||||
for live_session in live:
|
||||
agent = live_session.get("agent")
|
||||
if agent is not None and Path(live_session.get("profile_home") or _hermes_home).resolve() == home:
|
||||
agent.daybreak = on and getattr(agent, "platform", None) in DAYBREAK_SOURCES
|
||||
return _kv(rid, key, on)
|
||||
|
||||
|
||||
|
||||
@@ -659,6 +659,16 @@ def _stage_first_contact_onboarding_note(session: dict, agent, history_empty: bo
|
||||
logger.debug("first-contact onboarding note failed", exc_info=True)
|
||||
|
||||
|
||||
def _sync_agent_daybreak(agent) -> None:
|
||||
"""``agent.daybreak`` for this turn, read inside the turn's profile scope once the final agent is selected,
|
||||
so a build that raced a flip, a model switch and a compute-host child all take the saved value. Only the
|
||||
owner's own surfaces carry it (agent.session_source.DAYBREAK_SOURCES): OpenAI allows no third-party use."""
|
||||
from agent.session_source import DAYBREAK_SOURCES
|
||||
if agent is not None:
|
||||
agent.daybreak = (getattr(agent, "platform", None) in DAYBREAK_SOURCES
|
||||
and (_load_cfg().get("agent") or {}).get("daybreak") is True)
|
||||
|
||||
|
||||
def _prepare_turn_input(sid: str, session: dict, st: _TurnRun, text: Any, images: list[str]):
|
||||
"""Bind scopes, sync the agent, snapshot history, build the run message; returns
|
||||
``(prompt, run_message, cols, streamer)`` or None when @-expansion was refused.
|
||||
@@ -695,6 +705,7 @@ def _prepare_turn_input(sid: str, session: dict, st: _TurnRun, text: Any, images
|
||||
_sync_bot_capabilities(sid, session) # Bot Chat: adopt Settings->Capabilities edits
|
||||
_adopt_out_of_band_turns(session)
|
||||
st.agent = agent = session["agent"]
|
||||
_sync_agent_daybreak(agent)
|
||||
# Snapshot after the model sync: a deferred switch's history mutation belongs to this turn.
|
||||
with session["history_lock"]:
|
||||
st.history = list(session["history"])
|
||||
|
||||
Reference in new issue
Block a user