diff --git a/api/config.py b/api/config.py index c0ca41fe..e9d7e71d 100644 --- a/api/config.py +++ b/api/config.py @@ -2094,6 +2094,61 @@ def _strip_provider_hint_for_reasoning(model_id: str) -> str: return model +def _reasoning_name_candidates(model_id: str) -> list[str]: + """Return normalized model-name candidates for heuristic capability checks.""" + bare = str(model_id or "").strip().lower().rsplit("/", 1)[-1] + if not bare: + return [] + + candidates: list[str] = [] + + def _add(value: str) -> None: + candidate = str(value or "").strip().lower() + if candidate and candidate not in candidates: + candidates.append(candidate) + + _add(bare) + + dot_parts = [part for part in bare.split(".") if part] + if len(dot_parts) > 1: + # Try progressively stripping dot-separated vendor namespaces so inputs like + # "moonshotai.kimi-k2.5" and "vendor.deepseek.v3.2" both surface the real + # model family rather than treating every dot as part of the provider slug. + for index in range(1, len(dot_parts)): + suffix = ".".join(dot_parts[index:]) + if any(ch.isalpha() for ch in suffix): + _add(suffix) + + for candidate in list(candidates): + normalized = re.sub(r"[^a-z0-9]+", "-", candidate).strip("-") + _add(normalized) + + return candidates + + +def _candidate_supports_reasoning(candidate: str) -> bool: + normalized = re.sub(r"[^a-z0-9]+", "-", str(candidate or "").strip().lower()).strip("-") + if not normalized: + return False + + tokens = [token for token in normalized.split("-") if token] + token_set = set(tokens) + + if "thinking" in token_set or "reasoning" in token_set: + return True + if normalized in {"o1", "o3", "o4"} or normalized.startswith(("o1-", "o3-", "o4-")): + return True + if normalized.startswith(("kimi-k2", "kimi-thinking", "claude-3", "claude-4")): + return True + if normalized.startswith("qwen3") or "qwen3" in token_set: + return True + if normalized.startswith(("deepseek-v3", "deepseek-v4", "deepseek-r1", "deepseek-r2")): + return True + if len(tokens) >= 2 and tokens[0] == "deepseek" and tokens[1] in {"v3", "v4", "r1", "r2"}: + return True + return False + + def _heuristic_reasoning_efforts(model_id: str, provider_id: str) -> list[str]: """Fallback when hermes_cli is unavailable.""" model = _strip_provider_hint_for_reasoning(model_id).lower() @@ -2123,31 +2178,11 @@ def _heuristic_reasoning_efforts(model_id: str, provider_id: str) -> list[str]: ) if any(model.startswith(prefix) for prefix in prefixes): return list(VALID_REASONING_EFFORTS) - # Custom API aggregators (e.g. New API, One API) use non-standard model naming: - # bare names like "deepseek-v4-flash" or dot-separated "moonshotai.kimi-k2.5" - # rather than the OpenRouter-style "vendor/model" that the prefix list targets. - # Strip a dot-vendor prefix (e.g. "moonshotai.kimi-k2.5" → "kimi-k2.5") and - # check both the original bare name and the stripped suffix. - bare_after_dot = bare.split(".", 1)[-1] if "." in bare else bare - thinking_bare_prefixes = ( - "deepseek-v4", - "deepseek-r1", - "deepseek-r2", - "kimi-k2", - "kimi-thinking", - "qwen3", - "claude-3", - "claude-4", - "o1-", - "o3-", - "o4-", - ) - if any( - bare.startswith(p) or bare_after_dot.startswith(p) - for p in thinking_bare_prefixes - ): - return list(VALID_REASONING_EFFORTS) - if "thinking" in bare or "reasoning" in bare: + # Named custom providers often rewrite model ids with dots, underscores, or + # extra vendor namespaces. Normalize those shapes before applying family-level + # reasoning heuristics so "deepseek.v3.2", "deepseek_v4_flash", and + # "vendor.deepseek.v3.2" are treated consistently. + if any(_candidate_supports_reasoning(candidate) for candidate in _reasoning_name_candidates(bare)): return list(VALID_REASONING_EFFORTS) return [] diff --git a/api/profiles.py b/api/profiles.py index ff35f808..a9ff9d58 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -891,6 +891,7 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: raise ValueError(f"Profile '{name}' does not exist.") with _profile_lock: + _SKILLS_STATS_CACHE.clear() if process_wide: global _active_profile _active_profile = name @@ -989,6 +990,77 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: } +_SKILLS_STATS_CACHE: dict[Path, tuple[int, int, float]] = {} +_SKILLS_STATS_CACHE_TTL = 8.0 # seconds + + +def _get_profile_skills_stats(profile_dir: Path) -> tuple[int, int]: + """Calculate (enabled_count, compatible_count) for a profile directory.""" + import time + profile_dir = Path(profile_dir).resolve() + now = time.time() + if profile_dir in _SKILLS_STATS_CACHE: + enabled, compat, expiry = _SKILLS_STATS_CACHE[profile_dir] + if now < expiry: + return enabled, compat + + skills_dir = profile_dir / "skills" + if not skills_dir.is_dir(): + res = (0, 0) + _SKILLS_STATS_CACHE[profile_dir] = (res[0], res[1], now + _SKILLS_STATS_CACHE_TTL) + return res + + disabled = set() + config_path = profile_dir / "config.yaml" + if config_path.exists(): + try: + import yaml as _yaml + cfg = _yaml.safe_load(config_path.read_text(encoding="utf-8")) + if isinstance(cfg, dict): + skills_cfg = cfg.get("skills") + if isinstance(skills_cfg, dict): + # Align with get_disabled_skill_names(platform="webui") behavior: + platform_disabled = (skills_cfg.get("platform_disabled") or {}).get("webui") + if platform_disabled is not None: + disabled_val = platform_disabled + else: + disabled_val = skills_cfg.get("disabled") + + if disabled_val is not None: + if isinstance(disabled_val, str): + disabled_val = [disabled_val] + disabled = {str(v).strip() for v in disabled_val if str(v).strip()} + except Exception: + pass + + from agent.skill_utils import iter_skill_index_files, parse_frontmatter, skill_matches_platform + + seen_names = set() + enabled_count = 0 + compatible_count = 0 + + for skill_md in iter_skill_index_files(skills_dir, "SKILL.md"): + try: + content = skill_md.read_text(encoding="utf-8")[:4000] + frontmatter, _ = parse_frontmatter(content) + if not skill_matches_platform(frontmatter): + continue + name = frontmatter.get("name", skill_md.parent.name)[:64] + if name in seen_names: + continue + seen_names.add(name) + + compatible_count += 1 + if name not in disabled: + enabled_count += 1 + except Exception: + pass + + res = (enabled_count, compatible_count) + _SKILLS_STATS_CACHE[profile_dir] = (res[0], res[1], now + _SKILLS_STATS_CACHE_TTL) + return res + + def list_profiles_api() -> list: """List all profiles with metadata, serialized for JSON response.""" try: @@ -1001,6 +1073,7 @@ def list_profiles_api() -> list: active = get_active_profile_name() result = [] for p in infos: + enabled_count, total_count = _get_profile_skills_stats(p.path) result.append({ 'name': p.name, 'path': str(p.path), @@ -1010,13 +1083,16 @@ def list_profiles_api() -> list: 'model': p.model, 'provider': p.provider, 'has_env': p.has_env, - 'skill_count': p.skill_count, + 'skill_count': enabled_count, + 'enabled_skills': enabled_count, + 'total_skills': total_count, }) return result def _default_profile_dict() -> dict: """Fallback profile dict when hermes_cli is not importable.""" + enabled_count, compatible_count = _get_profile_skills_stats(_DEFAULT_HERMES_HOME) return { 'name': 'default', 'path': str(_DEFAULT_HERMES_HOME), @@ -1026,7 +1102,9 @@ def _default_profile_dict() -> dict: 'model': None, 'provider': None, 'has_env': (_DEFAULT_HERMES_HOME / '.env').exists(), - 'skill_count': 0, + 'skill_count': enabled_count, + 'enabled_skills': enabled_count, + 'total_skills': compatible_count, } @@ -1437,6 +1515,7 @@ def create_profile_api(name: str, clone_from: str = None, # Invalidate cached root-profile-name lookup; create_profile may have added # a new profile that flips is_default semantics on the agent side (#1612). + _SKILLS_STATS_CACHE.clear() _invalidate_root_profile_cache() # Find and return the newly created profile info. @@ -1456,6 +1535,8 @@ def create_profile_api(name: str, clone_from: str = None, 'provider': None, 'has_env': (profile_path / '.env').exists(), 'skill_count': 0, + 'enabled_skills': 0, + 'total_skills': 0, } @@ -1488,5 +1569,6 @@ def delete_profile_api(name: str) -> dict: raise ValueError(f"Profile '{name}' does not exist.") # Drop cached root-profile-name lookup — list_profiles_api() shape changed. + _SKILLS_STATS_CACHE.clear() _invalidate_root_profile_cache() return {'ok': True, 'name': name} diff --git a/docs/rfcs/hermes-run-adapter-contract.md b/docs/rfcs/hermes-run-adapter-contract.md index 781768b7..d879ce76 100644 --- a/docs/rfcs/hermes-run-adapter-contract.md +++ b/docs/rfcs/hermes-run-adapter-contract.md @@ -956,28 +956,34 @@ Non-goals for Slice 4e: #### Slice 4f: Supervised local runner client backend gate -Status as of 2026-05-28: client transport proposed in #3073 behind -`HERMES_WEBUI_RUNNER_BASE_URL`; it should be described as under review until a -release PR actually ships it. -`runner-local` still remains default-off and returns the bounded not-configured -path unless that endpoint is explicitly configured. When configured, WebUI uses a -JSON HTTP client boundary for start / observe / status / controls and bridges -observed runner events through the existing SSE stream route rather than adding -main-process runner-owned maps. +Status as of 2026-05-31: shipped in v0.51.188 via #3073 / #3274. The client +transport is now implemented behind `HERMES_WEBUI_RUNNER_BASE_URL` and remains +default-off. With no endpoint configured, `runner-local` still returns the +bounded not-configured path and the live in-process `_run_agent_streaming` path +is unchanged. When configured, WebUI uses a JSON HTTP client boundary for start / +observe / status / controls and bridges observed runner events through the +existing SSE stream route rather than adding main-process runner-owned maps. + +The release added two security hardening checks while absorbing #3073: +`HttpRunnerClient` rejects non-`http(s)` base URL schemes and uses an opener that +does not follow redirects, so a misconfigured or compromised runner cannot leak a +Bearer token to a redirected host. The release gate reported full pytest passing +and independent default-off/inert-path review. + This bridge is intentionally a WebUI consumer transport seam: the configured runner must emit events that are already compatible with the browser SSE event names/payloads, or a later runner-owned normalization layer must translate Hermes runtime families such as `token.delta`, `tool.started`, and `done` before they reach this route. -After the route-selection harness ships, the next reviewable step is not to make -`runner-local` the default. It is to define the first concrete supervised/local -runner client backend that can replace the bounded 501 path under the existing -feature flag and prove execution ownership has moved out of the main WebUI -request process. +After the configured runner-client boundary ships, the next reviewable step is +not to make `runner-local` the default. It is to define the first supervised +runner process harness that can actually own `AIAgent` execution behind that +client boundary and prove restart/reattach with a real local runner, not just a +configured external endpoint or fake-runner fixture. -This slice is a contract gate before backend code lands. The goal is to pin the -minimum runner client behavior so the implementation cannot become a renamed +This slice was the client-boundary implementation gate. The goal was to pin the +minimum runner client behavior so the implementation could not become a renamed `STREAMS` / `CANCEL_FLAGS` / cached `AIAgent` surrogate inside `api/routes.py`. Scope: @@ -1027,6 +1033,59 @@ Non-goals for Slice 4f: - no permanent WebUI-owned active-run discovery cache that duplicates runner or future Hermes Runtime API responsibility. +#### Slice 4g: Supervised local runner process harness gate + +After #3073 / #3274, WebUI has an explicit configured-runner HTTP client and SSE +consumer bridge, but it still does not ship the supervised runner process itself. +The next gate should define the smallest local runner harness that can own +`AIAgent` execution outside the main WebUI request process while being consumed +through the already-shipped `runner-local` client boundary. + +Scope: + +- define the local runner process lifecycle: spawn/start, health check, run + ownership, graceful shutdown, crash classification, and cleanup; +- keep WebUI as a client of `HERMES_WEBUI_RUNNER_BASE_URL`, not the owner of + process-local runner execution state; +- persist run/session lookup, ordered events, terminal state, and active controls + in runner-owned or journal-backed state that a restarted WebUI can discover; +- carry explicit profile, workspace, attachments, provider/model, toolset, + source, and metadata payloads into the runner without WebUI process-global + environment mutation; +- prove cancel as the first live control for active runner-owned runs, with + approval, clarify, goal, and queue either mapped to explicit runner + capabilities or returned as bounded unsupported/conflict `ControlResult` + values. + +Acceptance tests for Slice 4g: + +1. **Process ownership moved.** A local runner process, not `hermes-webui`, owns + `AIAgent` construction/reuse and active run execution for `runner-local` runs. +2. **Restart/reattach with a real runner.** Start a non-trivial `runner-local` + run, restart only `hermes-webui`, reload the session, rediscover the active or + terminal runner-owned run, replay/catch up from cursor without duplicate + transcript/tool/reasoning state, and preserve cancel if still active. +3. **No runtime-surrogate globals in WebUI.** The main WebUI server still does not + gain new module-level maps for runner-owned streams, cancel flags, + approval/clarify callbacks, cached agents, child process run registries, goal + state, or queue schedulers. +4. **Default-off and reversible.** Unset `HERMES_WEBUI_RUNNER_BASE_URL` or switch + the adapter mode back to legacy and the existing in-process path remains + available without session or journal migration. +5. **Runner health and failure are observable.** A missing, unhealthy, or crashed + runner returns bounded diagnostics and terminal/interrupted state rather than + silently falling back to WebUI-owned execution for a runner-selected run. + +Non-goals for Slice 4g: + +- no default-on runner mode; +- no removal of `legacy-direct` or `legacy-journal`; +- no server-side queue scheduler just for adapter symmetry; +- no broad WebUI product-surface migration; +- no claim that this is the canonical Hermes Agent Runtime API; if Hermes Agent + later ships `/v1/runs`, this local runner remains a replaceable backend behind + the same adapter/client boundary. + ## First Meaningful Success Criteria The first meaningful milestones are deliberately split. diff --git a/static/panels.js b/static/panels.js index a31d183b..25ec1452 100644 --- a/static/panels.js +++ b/static/panels.js @@ -5035,7 +5035,7 @@ async function loadProfilesPanel() { const meta = []; if (p.model) meta.push(p.model.split('/').pop()); if (p.provider) meta.push(p.provider); - if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count)); + if (p.total_skills && p.total_skills > 0) meta.push(t('profile_skill_count', p.total_skills).replace(String(p.total_skills), `${p.enabled_skills} / ${p.total_skills}`)); const gwDot = p.gateway_running ? `` : ``; @@ -5109,7 +5109,7 @@ function _renderProfileDetail(p, activeName){ if (p.provider) rows.push(`
Provider
${esc(p.provider)}
`); if (p.base_url) rows.push(`
Base URL
${esc(p.base_url)}
`); rows.push(`
API key
${p.has_env ? esc(t('profile_api_keys_configured')) : 'Not configured'}
`); - if (typeof p.skill_count === 'number') rows.push(`
Skills
${esc(t('profile_skill_count', p.skill_count))}
`); + if (p.total_skills && p.total_skills > 0) rows.push(`
Skills
${esc(t('profile_skill_count', p.total_skills).replace(String(p.total_skills), `${p.enabled_skills} / ${p.total_skills}`))}
`); if (p.default_workspace) rows.push(`
Default space
${esc(p.default_workspace)}
`); body.innerHTML = `
@@ -5199,7 +5199,7 @@ function renderProfileDropdown(data) { opt.className = 'profile-opt' + (p.name === active ? ' active' : ''); const meta = []; if (p.model) meta.push(p.model.split('/').pop()); - if (p.skill_count) meta.push(t('profile_skill_count', p.skill_count)); + if (p.total_skills && p.total_skills > 0) meta.push(t('profile_skill_count', p.total_skills).replace(String(p.total_skills), `${p.enabled_skills} / ${p.total_skills}`)); const gwDot = ``; const checkmark = p.name === active ? ' ' : ''; const defaultBadge = p.is_default ? ` ${esc(t('profile_default_label'))}` : ''; diff --git a/tests/test_custom_provider_bare_model_reasoning.py b/tests/test_custom_provider_bare_model_reasoning.py index 951854fa..46fbf3f1 100644 --- a/tests/test_custom_provider_bare_model_reasoning.py +++ b/tests/test_custom_provider_bare_model_reasoning.py @@ -10,6 +10,8 @@ these combinations, hiding the reasoning effort selector in the UI even though the underlying models fully support thinking/reasoning. """ +import pytest + import api.config as cfg @@ -33,6 +35,26 @@ def test_deepseek_r1_bare_name_custom_provider(): assert set(efforts) >= {"low", "medium", "high"} +@pytest.mark.parametrize( + "model_id", + [ + "deepseek.v3.2", + "deepseek_v3_2", + "vendor.deepseek.v3.2", + "deepseek.v4-flash", + "deepseek_v4_flash", + ], +) +def test_deepseek_separator_variants_custom_provider(model_id): + efforts = cfg.resolve_model_reasoning_efforts( + model_id, + provider_id="custom:newapi", + ) + assert set(efforts) >= {"low", "medium", "high"}, ( + f"{model_id} via custom provider should expose reasoning efforts" + ) + + # ── dot-separated model names (vendor.model) ───────────────────────────────── def test_kimi_dot_separated_custom_provider(): @@ -91,6 +113,20 @@ def test_plain_llm_dot_separated_custom_provider_no_reasoning(): ) == [] +@pytest.mark.parametrize( + "model_id", + [ + "thinkinghub.llama-3.1-70b", + "reasoninghub.llama-3.1-70b", + ], +) +def test_vendor_prefix_keyword_does_not_trigger_reasoning(model_id): + assert cfg.resolve_model_reasoning_efforts( + model_id, + provider_id="custom:newapi", + ) == [] + + # ── slash-prefixed names must still work (no regression) ───────────────────── def test_deepseek_slash_prefix_still_works(): diff --git a/tests/test_profile_skills_stats.py b/tests/test_profile_skills_stats.py new file mode 100644 index 00000000..f237b3a9 --- /dev/null +++ b/tests/test_profile_skills_stats.py @@ -0,0 +1,142 @@ +from pathlib import Path +import yaml +from api import profiles +from tests.conftest import requires_agent_modules + + +def _write_skill(root: Path, name: str, platforms=None): + skill_dir = root / "skills" / name + skill_dir.mkdir(parents=True, exist_ok=True) + platforms_line = f"platforms: {platforms}\n" if platforms else "" + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {name} skill\n{platforms_line}---\n\n# {name}\n", + encoding="utf-8", + ) + +def _write_config(home: Path, disabled): + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text( + yaml.safe_dump({"skills": {"disabled": list(disabled)}}, sort_keys=False), + encoding="utf-8", + ) + +@requires_agent_modules +def test_get_profile_skills_stats(tmp_path): + # Setup skills directory with: + # 1 compatible & enabled skill ("alpha") + # 1 compatible & disabled skill ("beta") + # 1 incompatible skill ("gamma" - macos only on linux test run) + profile_home = tmp_path / "auditor" + _write_skill(profile_home, "alpha") + _write_skill(profile_home, "beta") + _write_skill(profile_home, "gamma", platforms=["macos"]) + _write_config(profile_home, ["beta"]) + + # Explicitly clear the stats cache to ensure we compute fresh + profiles._SKILLS_STATS_CACHE.clear() + + enabled, compatible = profiles._get_profile_skills_stats(profile_home) + assert enabled == 1 + assert compatible == 2 + +@requires_agent_modules +def test_list_profiles_api_contains_formatted_skills(monkeypatch, tmp_path): + class FakeProfile: + def __init__(self, name, path): + self.name = name + self.path = Path(path) + self.is_default = name == "default" + self.gateway_running = False + self.model = "gpt-4" + self.provider = "openai" + self.has_env = False + self.skill_count = 3 # Raw on-disk count from hermes_cli + + p_default = tmp_path / "default" + p_fintech = tmp_path / "profiles" / "fintech" + + _write_skill(p_default, "a1") + _write_skill(p_default, "a2") + _write_config(p_default, ["a2"]) + + _write_skill(p_fintech, "f1") + _write_skill(p_fintech, "f2") + _write_skill(p_fintech, "f3") + _write_config(p_fintech, ["f2", "f3"]) + + fake_infos = [ + FakeProfile("default", p_default), + FakeProfile("fintech", p_fintech) + ] + + monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default") + + # We patch the upstream list_profiles call to return our FakeProfile list + try: + import hermes_cli.profiles as cli_p + monkeypatch.setattr(cli_p, "list_profiles", lambda: fake_infos) + except ImportError: + pass + + profiles._SKILLS_STATS_CACHE.clear() + + results = profiles.list_profiles_api() + by_name = {p["name"]: p for p in results} + + assert "default" in by_name + assert "fintech" in by_name + + # backward-compatible skill_count as an integer: + assert by_name["default"]["skill_count"] == 1 + assert by_name["fintech"]["skill_count"] == 1 + + # new enabled_skills and total_skills integer fields: + assert by_name["default"]["enabled_skills"] == 1 + assert by_name["default"]["total_skills"] == 2 + assert by_name["fintech"]["enabled_skills"] == 1 + assert by_name["fintech"]["total_skills"] == 3 + +@requires_agent_modules +def test_no_skills_dir(tmp_path): + """Profile with no skills/ directory should return (0, 0).""" + profiles._SKILLS_STATS_CACHE.clear() + enabled, compat = profiles._get_profile_skills_stats(tmp_path) + assert enabled == 0 and compat == 0 + +@requires_agent_modules +def test_corrupt_config(tmp_path): + """Corrupt config.yaml should not crash — disabled set stays empty.""" + profiles._SKILLS_STATS_CACHE.clear() + _write_skill(tmp_path, "a") + (tmp_path / "config.yaml").write_text("not: [valid: yaml: {{", encoding="utf-8") + enabled, compat = profiles._get_profile_skills_stats(tmp_path) + assert compat == 1 and enabled == 1 # no disabled parsing, all enabled + +@requires_agent_modules +def test_platform_disabled_webui(tmp_path): + """platform_disabled.webui list should be used when present.""" + profiles._SKILLS_STATS_CACHE.clear() + _write_skill(tmp_path, "web-only") + cfg = {"skills": {"platform_disabled": {"webui": ["web-only"]}, "disabled": []}} + (tmp_path / "config.yaml").write_text(yaml.safe_dump(cfg), encoding="utf-8") + enabled, compat = profiles._get_profile_skills_stats(tmp_path) + assert compat == 1 and enabled == 0 + +@requires_agent_modules +def test_skills_stats_cache(tmp_path): + """Verify that caching works and has short TTL behavior.""" + profiles._SKILLS_STATS_CACHE.clear() + + _write_skill(tmp_path, "alpha") + enabled, compat = profiles._get_profile_skills_stats(tmp_path) + assert enabled == 1 and compat == 1 + + # Add a skill but since cache is active (TTL 8s), we should still get old values + _write_skill(tmp_path, "beta") + enabled, compat = profiles._get_profile_skills_stats(tmp_path) + assert enabled == 1 and compat == 1 + + # Force clear or override the mock TTL, or clear cache manually to see changes + profiles._SKILLS_STATS_CACHE.clear() + enabled, compat = profiles._get_profile_skills_stats(tmp_path) + assert enabled == 2 and compat == 2 diff --git a/tests/test_runtime_adapter_seam.py b/tests/test_runtime_adapter_seam.py index 4c64091a..c86d1c40 100644 --- a/tests/test_runtime_adapter_seam.py +++ b/tests/test_runtime_adapter_seam.py @@ -574,12 +574,14 @@ def test_rfc_defines_slice4f_supervised_local_runner_client_gate(): rfc = (routes.Path(__file__).parent.parent / "docs" / "rfcs" / "hermes-run-adapter-contract.md").read_text(encoding="utf-8") assert "#### Slice 4f: Supervised local runner client backend gate" in rfc - assert "Status as of 2026-05-28: client transport proposed in #3073 behind" in rfc - assert "it should be described as under review until a\nrelease PR actually ships it" in rfc - assert "replace the bounded 501 path under the existing\nfeature flag" in rfc - assert "durable runner-owned run id plus session-to-run lookup" in rfc + assert "Status as of 2026-05-31: shipped in v0.51.188 via #3073 / #3274" in rfc + assert "The client\ntransport is now implemented behind `HERMES_WEBUI_RUNNER_BASE_URL`" in rfc + assert "`HttpRunnerClient` rejects non-`http(s)` base URL schemes" in rfc + assert "uses an opener that\ndoes not follow redirects" in rfc assert "the configured\nrunner must emit events that are already compatible with the browser SSE event\nnames/payloads" in rfc assert "a later runner-owned normalization layer must translate\nHermes runtime families such as `token.delta`, `tool.started`, and `done`" in rfc + assert "After the configured runner-client boundary ships" in rfc + assert "configured external endpoint or fake-runner fixture" in rfc assert "cancel as the first required live control" in rfc assert "501 path replaced only when configured" in rfc assert "Restart/reattach proves ownership moved" in rfc @@ -588,6 +590,24 @@ def test_rfc_defines_slice4f_supervised_local_runner_client_gate(): assert "Unsupported runner controls return safe\n `unsupported`, `not-active`, or `conflict` results" in rfc assert "no permanent WebUI-owned active-run discovery cache" in rfc + +def test_rfc_defines_slice4g_supervised_local_runner_process_gate(): + routes = importlib.import_module("api.routes") + rfc = (routes.Path(__file__).parent.parent / "docs" / "rfcs" / "hermes-run-adapter-contract.md").read_text(encoding="utf-8") + + assert "#### Slice 4g: Supervised local runner process harness gate" in rfc + assert "After #3073 / #3274, WebUI has an explicit configured-runner HTTP client" in rfc + assert "still does not ship the supervised runner process itself" in rfc + assert "own\n`AIAgent` execution outside the main WebUI request process" in rfc + assert "keep WebUI as a client of `HERMES_WEBUI_RUNNER_BASE_URL`" in rfc + assert "without WebUI process-global\n environment mutation" in rfc + assert "Process ownership moved" in rfc + assert "Restart/reattach with a real runner" in rfc + assert "No runtime-surrogate globals in WebUI" in rfc + assert "Default-off and reversible" in rfc + assert "Runner health and failure are observable" in rfc + assert "no claim that this is the canonical Hermes Agent Runtime API" in rfc + def test_runtime_runner_client_factory_stays_bounded_until_endpoint_configured(monkeypatch): routes = importlib.import_module("api.routes")