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:
6 files changed
+392
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+104
-2
@@ -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,10 +337,18 @@ 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:
|
||||
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(),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Profile-scoped GoalManager unavailable: %s", exc)
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user