diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a8d98f92..d465c724 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -59,6 +59,7 @@ actions. The topbar remains focused on conversation context and the workspace/fi auth.py Optional password authentication, signed cookies, passkeys/WebAuthn config.py Discovery, globals, model detection, reloadable config helpers.py HTTP helpers: j(), bad(), require(), safe_resolve(), security headers + goals.py Persistent-goal commands and profile-scoped native GoalManager bridge models.py Session model + CRUD, per-session profile tracking, CLI/state.db bridge profiles.py Profile state management, hermes_cli wrapper onboarding.py First-run onboarding status, real provider config writes, OAuth linking, readiness detection @@ -394,6 +395,29 @@ read_file_content(workspace, rel): - Reads as UTF-8 with errors='replace' (binary files show replacement chars) - Returns {path, content, size, lines} +### 4.8 Persistent Goal Profile Boundary + +`api/goals.py` exposes the WebUI `/goal` command payloads and post-turn evaluation hook. +Hermes Agent's native `GoalManager` is the authoritative owner of goal evaluation, +continuation decisions, wait barriers, failure counters, contracts, subgoals, and +`state.db` persistence. + +For a profile-scoped WebUI session, the bridge delegates only when the Agent exposes +both the context-local `set_hermes_home_override()` API and call-time default +`SessionDB` path resolution. The bridge probes the resolved default path under the +selected context before constructing the native manager, then binds that profile's +Hermes home before every native call. The override is reset in a `finally` block after +every operation, so concurrent sessions using the same session ID under different +profiles cannot cross-read or cross-write goal state. Goal snapshot rollback uses the +same scoped native persistence path. + +Older Hermes Agent versions that lack either capability continue through +`_LegacyProfileGoalManager`, which pins persistence to the selected profile's explicit +`state.db` path. This includes intermediate versions whose context API is present but +whose default `SessionDB()` path remains frozen at module import. Keep this fallback +compatibility-only: new goal semantics belong in Hermes Agent's native manager rather +than a second WebUI implementation. + --- ## 5. Frontend Architecture: Current State diff --git a/CHANGELOG.md b/CHANGELOG.md index af0c2a3d..77d814a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Fixed +- **Profile goal contract no longer drifts across profiles on older agent builds.** On Hermes Agent builds where the context-override API was exposed but `SessionDB()` still used the import-time default DB path (v2026.5.28–v2026.7.20), a goal mutation in one profile could overwrite another profile's goal. Native profile-goal persistence is now gated on the agent supporting *both* context-home resolution *and* call-time DB-path selection; otherwise it falls back to the legacy per-profile manager. Profiles stay isolated. Thanks @ticketclosed-won. (#6899) + +- **Packaged `hermes-webui` console entry point.** `pip install`-style wheel installs now expose a `hermes-webui` command that boots the WebUI through `bootstrap:main` — the same launcher path as `python server.py`, so agent-interpreter discovery and dependency validation run before the server starts (a direct `server:main` entry started the UI but broke every chat with a missing-dependency error). Thanks @webtecnica. (#6742) + - **Dotted Bedrock/Vertex model IDs get clean labels in the model picker.** Model IDs with dotted vendor prefixes (e.g. `anthropic.claude-*`) rendered awkwardly in the picker. They now get normalized display labels while the exact model IDs and provider grouping stay authoritative — distinct regional/vendor models remain separate entries, and live discovery still precedes the static fallback. Thanks @samfoy. (#6628) - **MCP dev dependency pinned to a compatible range.** The MCP SDK dev dependency was unpinned, so an incompatible 2.x install broke `mcp_server.py` with an `AttributeError`. It is now pinned to a compatible 1.x range with a bootstrap guard that exits with an actionable message on an incompatible version; the SDK remains optional (imports degrade gracefully when it is absent). Thanks @webtecnica. (#6616) diff --git a/api/goals.py b/api/goals.py index 3e4e23ea..2eb55c27 100644 --- a/api/goals.py +++ b/api/goals.py @@ -73,8 +73,99 @@ def _profile_db(profile_home: str | Path): return db +def _profile_home_context_api(): + """Return the native context-local Hermes home setters when available.""" + try: + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + except Exception: # pragma: no cover - depends on installed hermes-agent + return None + return set_hermes_home_override, reset_hermes_home_override + + +def _native_profile_context_api(profile_home: str | Path): + """Return context setters only when SessionDB resolves that context at call time.""" + context_api = _profile_home_context_api() + if context_api is None: + return None + try: + from hermes_state import _default_db_path # type: ignore + except Exception: # pragma: no cover - depends on installed hermes-agent + return None + if not callable(_default_db_path): + return None + + home = Path(profile_home).expanduser().resolve() + set_home, reset_home = context_api + try: + token = set_home(home) + except Exception: # pragma: no cover - depends on installed hermes-agent + return None + try: + resolved_db_path = Path(_default_db_path()).expanduser().resolve() + except Exception: # pragma: no cover - depends on installed hermes-agent + return None + finally: + reset_home(token) + if resolved_db_path != home / "state.db": + return None + return context_api + + class _ProfileGoalManager: - """Small WebUI-local GoalManager adapter with explicit profile persistence.""" + """Run the native GoalManager under a context-local profile home.""" + + def __init__( + self, + session_id: str, + *, + profile_home: str | Path, + default_max_turns: int = 20, + context_api=None, + ): + if _NativeGoalManager is None: + raise RuntimeError("Hermes goal manager unavailable") + context_api = context_api or _profile_home_context_api() + if context_api is None: + raise RuntimeError("Hermes profile context unavailable") + self.session_id = session_id + self.profile_home = Path(profile_home).expanduser().resolve() + self._set_home, self._reset_home = context_api + self._manager = self._scoped( + _NativeGoalManager, + session_id=session_id, + default_max_turns=default_max_turns, + ) + + def _scoped(self, func, *args, **kwargs): + token = self._set_home(self.profile_home) + try: + return func(*args, **kwargs) + finally: + self._reset_home(token) + + @property + def state(self): + return self._manager.state + + def __getattr__(self, name): + value = getattr(self._manager, name) + if not callable(value): + return value + + def scoped_call(*args, **kwargs): + return self._scoped(value, *args, **kwargs) + + return scoped_call + + def _restore_state(self, snapshot) -> None: + from hermes_cli.goals import save_goal # type: ignore + + self._manager._state = snapshot + self._scoped(save_goal, self.session_id, snapshot) + + +class _LegacyProfileGoalManager: + """Explicit-DB fallback for Hermes versions without profile context.""" def __init__(self, session_id: str, *, profile_home: str | Path, default_max_turns: int = 20): if GoalState is None: @@ -193,7 +284,7 @@ class _ProfileGoalManager: if judge_goal is None: verdict, reason = "continue", "goal judge unavailable" else: - verdict, reason = judge_goal(state.goal, str(last_response or "")) + verdict, reason, *_ = judge_goal(state.goal, str(last_response or "")) state.last_verdict = verdict state.last_reason = reason @@ -246,7 +337,15 @@ def _manager(session_id: str, *, profile_home: str | Path | None = None): return None if profile_home and GoalManager is _NativeGoalManager and GoalState is not None: try: - return _ProfileGoalManager( + context_api = _native_profile_context_api(profile_home) + if context_api is not None: + return _ProfileGoalManager( + session_id=session_id, + profile_home=profile_home, + default_max_turns=_default_max_turns(), + context_api=context_api, + ) + return _LegacyProfileGoalManager( session_id=session_id, profile_home=profile_home, default_max_turns=_default_max_turns(), @@ -407,6 +506,9 @@ def restore_goal_state(session_id: str, snapshot: Any, *, profile_home: str | Pa pass return if isinstance(mgr, _ProfileGoalManager): + mgr._restore_state(snapshot) + return + if isinstance(mgr, _LegacyProfileGoalManager): mgr._state = snapshot mgr._save(snapshot) return diff --git a/pyproject.toml b/pyproject.toml index 1ddba525..bdd96238 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,14 @@ dynamic = ["version", "dependencies"] Repository = "https://github.com/nesquena/hermes-webui" Issues = "https://github.com/nesquena/hermes-webui/issues" +[project.scripts] +# Packaged CLI entry point (#6739): `hermes-webui` starts the WebUI server, the +# pip-installed equivalent of `python server.py` from a source checkout. The +# generated console script calls bootstrap.main(), which runs launcher-python +# discovery and dep-ensure before launching the server — matching the effective +# behavior of the `python server.py` / `python -m server` launch surface. +hermes-webui = "bootstrap:main" + [tool.setuptools] include-package-data = true packages = ["api", "static"] diff --git a/tests/test_cli_entry_point.py b/tests/test_cli_entry_point.py new file mode 100644 index 00000000..8d7a1755 --- /dev/null +++ b/tests/test_cli_entry_point.py @@ -0,0 +1,64 @@ +"""Smoke tests for the packaged ``hermes-webui`` CLI entry point (#6739). + +The console script is declared in ``pyproject.toml`` under +``[project.scripts]`` and must keep resolving to ``bootstrap.main`` — the +bootstrap entry point runs launcher-python discovery and dep-ensure before +launching the server, matching the effective behavior of +``python server.py`` / ``python -m server``. These tests +guard the wiring without booting a real server: + +1. The console script is declared in the packaging metadata. +2. The declared target (``bootstrap:main``) actually resolves to a callable. +3. When the package is installed in the test environment (e.g. CI editable + install), the real ``importlib.metadata`` entry point resolves end-to-end. +""" +from __future__ import annotations + +import importlib +import importlib.metadata +import os +import tomllib + +import pytest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +EXPECTED_SCRIPT = "hermes-webui" +EXPECTED_TARGET = "bootstrap:main" + + +def _pyproject_scripts() -> dict: + """Parse the [project.scripts] table from pyproject.toml.""" + with open(os.path.join(REPO_ROOT, "pyproject.toml"), "rb") as fh: + pyproject = tomllib.load(fh) + return dict(pyproject.get("project", {}).get("scripts", {})) + + +def test_console_script_declared_in_pyproject(): + """The packaged CLI entry point is declared and maps to bootstrap.main.""" + scripts = _pyproject_scripts() + assert scripts.get(EXPECTED_SCRIPT) == EXPECTED_TARGET + + +def test_console_script_target_resolves_to_callable(): + """The declared module:attr target imports and is callable.""" + module_name, _, attr = EXPECTED_TARGET.partition(":") + module = importlib.import_module(module_name) + target = getattr(module, attr) + assert callable(target) + + +def _installed_entry_point(): + """Return the installed hermes-webui console-script EntryPoint, or None.""" + selected = importlib.metadata.entry_points().select( + group="console_scripts", name=EXPECTED_SCRIPT + ) + return selected[0] if selected else None + + +def test_installed_entry_point_wiring(): + """When installed, the console script must resolve to bootstrap.main.""" + ep = _installed_entry_point() + if ep is None: + pytest.skip("hermes-webui not installed in this test environment") + assert ep.value == EXPECTED_TARGET + assert callable(ep.load()) diff --git a/tests/test_goal_command_webui.py b/tests/test_goal_command_webui.py index b16f3892..32283c88 100644 --- a/tests/test_goal_command_webui.py +++ b/tests/test_goal_command_webui.py @@ -128,6 +128,194 @@ def test_has_active_goal_reports_only_active_state(monkeypatch): assert webui_goals.has_active_goal("") is False +def test_profile_goal_evaluation_supports_native_judge_contract(monkeypatch, tmp_path): + """Profile goals accept the current five-field native judge result.""" + from api import goals as webui_goals + native_goals = pytest.importorskip("hermes_cli.goals", reason="hermes-agent not installed") + + judge = lambda *args, **kwargs: ("continue", "work remains", False, None, False) + monkeypatch.setattr(webui_goals, "judge_goal", judge) + monkeypatch.setattr(native_goals, "judge_goal", judge) + monkeypatch.syspath_prepend(str(Path(native_goals.__file__).resolve().parents[1])) + from hermes_constants import get_hermes_home + + original_home = get_hermes_home() + webui_goals._DB_CACHE.clear() + native_goals._DB_CACHE.clear() + __import__("hermes_state") + + home = tmp_path / "profile" + webui_goals.goal_command_payload("sid-native-contract", "finish it", profile_home=home) + decision = webui_goals.evaluate_goal_after_turn( + "sid-native-contract", + "not finished", + profile_home=home, + ) + state = webui_goals.goal_state_snapshot("sid-native-contract", profile_home=home) + + assert decision["verdict"] == "continue" + assert decision["should_continue"] is True + assert state.turns_used == 1 + assert state.last_verdict == "continue" + + snapshot = webui_goals.goal_state_snapshot("sid-native-contract", profile_home=home) + webui_goals.goal_command_payload("sid-native-contract", "replacement", profile_home=home) + webui_goals.restore_goal_state("sid-native-contract", snapshot, profile_home=home) + restored = webui_goals.goal_state_snapshot("sid-native-contract", profile_home=home) + assert restored.goal == "finish it" + assert restored.turns_used == 1 + assert get_hermes_home() == original_home + + +def test_profile_goal_evaluation_preserves_native_wait_semantics(monkeypatch, tmp_path): + """Profile goals park when the native judge returns a wait directive.""" + from api import goals as webui_goals + native_goals = pytest.importorskip("hermes_cli.goals", reason="hermes-agent not installed") + + judge = lambda *args, **kwargs: ( + "wait", + "rate limited", + False, + {"seconds": 30}, + False, + ) + monkeypatch.setattr(webui_goals, "judge_goal", judge) + monkeypatch.setattr(native_goals, "judge_goal", judge) + monkeypatch.syspath_prepend(str(Path(native_goals.__file__).resolve().parents[1])) + webui_goals._DB_CACHE.clear() + native_goals._DB_CACHE.clear() + __import__("hermes_state") + + home = tmp_path / "profile" + webui_goals.goal_command_payload("sid-native-wait", "finish it", profile_home=home) + decision = webui_goals.evaluate_goal_after_turn( + "sid-native-wait", + "rate limited", + profile_home=home, + ) + state = webui_goals.goal_state_snapshot("sid-native-wait", profile_home=home) + + assert decision["verdict"] == "wait" + assert decision["should_continue"] is False + assert state.waiting_until > 0 + assert state.waiting_reason == "rate limited" + + +def test_profile_goal_context_isolated_across_threads(monkeypatch, tmp_path): + """Concurrent profiles with the same session id cannot cross-write goals.""" + from api import goals as webui_goals + native_goals = pytest.importorskip("hermes_cli.goals", reason="hermes-agent not installed") + + monkeypatch.syspath_prepend(str(Path(native_goals.__file__).resolve().parents[1])) + from hermes_constants import get_hermes_home + + original_home = get_hermes_home() + webui_goals._DB_CACHE.clear() + native_goals._DB_CACHE.clear() + __import__("hermes_state") + barrier = threading.Barrier(2) + failures = [] + + def set_goal(profile): + try: + barrier.wait() + result = webui_goals.goal_command_payload( + "shared-session", + f"goal-{profile}", + profile_home=tmp_path / profile, + ) + assert result["ok"] is True + except Exception as exc: + failures.append(exc) + + threads = [threading.Thread(target=set_goal, args=(profile,)) for profile in ("a", "b")] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert failures == [] + assert webui_goals.goal_state_snapshot( + "shared-session", profile_home=tmp_path / "a" + ).goal == "goal-a" + assert webui_goals.goal_state_snapshot( + "shared-session", profile_home=tmp_path / "b" + ).goal == "goal-b" + assert get_hermes_home() == original_home + + +def test_profile_goal_falls_back_when_session_db_path_is_frozen(monkeypatch, tmp_path): + """A context API alone cannot make an import-time SessionDB path profile-safe.""" + from api import goals as webui_goals + native_goals = pytest.importorskip("hermes_cli.goals", reason="hermes-agent not installed") + import hermes_state + + frozen_db_path = tmp_path / "frozen-home" / "state.db" + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", frozen_db_path) + webui_goals._DB_CACHE.clear() + native_goals._DB_CACHE.clear() + + profile_a = tmp_path / "profile-a" + profile_b = tmp_path / "profile-b" + try: + assert webui_goals.goal_command_payload( + "shared-session", "goal-a", profile_home=profile_a + )["ok"] is True + assert webui_goals.goal_command_payload( + "shared-session", "goal-b", profile_home=profile_b + )["ok"] is True + + assert webui_goals.goal_state_snapshot( + "shared-session", profile_home=profile_a + ).goal == "goal-a" + assert webui_goals.goal_state_snapshot( + "shared-session", profile_home=profile_b + ).goal == "goal-b" + finally: + for cache in (webui_goals._DB_CACHE, native_goals._DB_CACHE): + for db in cache.values(): + close = getattr(db, "close", None) + if close is not None: + close() + cache.clear() + + +def test_profile_goal_context_resets_when_native_call_raises(monkeypatch, tmp_path): + """A failed delegated call cannot leak its profile into the next task.""" + from api import goals as webui_goals + native_goals = pytest.importorskip("hermes_cli.goals", reason="hermes-agent not installed") + + monkeypatch.syspath_prepend(str(Path(native_goals.__file__).resolve().parents[1])) + from hermes_constants import ( + get_hermes_home, + reset_hermes_home_override, + set_hermes_home_override, + ) + + class RaisingGoalManager: + state = None + + def __init__(self, session_id, default_max_turns=20): + self.session_id = session_id + self.default_max_turns = default_max_turns + + def explode(self): + raise RuntimeError("boom") + + original_home = get_hermes_home() + monkeypatch.setattr(webui_goals, "_NativeGoalManager", RaisingGoalManager) + manager = webui_goals._ProfileGoalManager( + "sid-context-reset", + profile_home=tmp_path / "profile", + context_api=(set_hermes_home_override, reset_hermes_home_override), + ) + + with pytest.raises(RuntimeError, match="boom"): + manager.explode() + + assert get_hermes_home() == original_home + + def test_goal_continuation_decision_emits_status_and_normal_user_prompt(monkeypatch): """Post-turn hook returns the visible status event plus a normal continuation prompt.""" from api import goals as webui_goals