Batch release exp-v0.52.237 — profile goal isolation (#6899) + hermes-webui entry point (#6742) (#7108)

* fix(goals): delegate profile evaluation to native manager

Use Hermes' context-local home override to run profile-scoped goal operations through the native GoalManager, preserving current judge, wait, and failure semantics while retaining the explicit-DB legacy fallback.

* docs(goals): describe profile ownership boundary

* fix(goals): gate native profile persistence capability

* feat(cli): add packaged hermes-webui CLI entry point (#6739)

* fix(cli): route hermes-webui entry through bootstrap:main for wheel install (#6742)

* docs(changelog): note #6899 profile goal isolation + #6742 hermes-webui entry point

* test(goals): guard hermes_cli import with importorskip for CI (agent not installed)

#6899's native-contract tests imported hermes_cli unconditionally, failing
CI with ModuleNotFoundError. Match the repo's established importorskip pattern
so they skip cleanly when the agent isn't installed and run when it is.
Co-authored-by: ticketclosed-wontfix

---------

Co-authored-by: Nick <202622897+ticketclosed-wontfix@users.noreply.github.com>
Co-authored-by: webtecnica <webtecnica@gmail.com>
Co-authored-by: n <a@n>
This commit is contained in:
authored and GitHub committed 2026-08-17 10:45:59 +00:00
1 parent 747b5a6913
commit 69d5647b60
6 files changed
+393 -3

No files matched your search

+24
View File
@@ -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
+4
View File
@@ -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)
+105 -3
View File
@@ -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
+8
View File
@@ -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"]
+64
View File
@@ -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())
+188
View File
@@ -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