stage-batch10: #3327 model-id normalize + #3334 RFC slice doc + #3341 profile skill counts

#3327 fix(reasoning): normalize custom-provider model ids for fallback heuristics
Co-authored-by: Carry00 <Carry00@users.noreply.github.com>

#3334 docs(rfc): mark run-adapter Slice 4f shipped, define Slice 4g gate
Co-authored-by: Michaelyklam <Michaelyklam@users.noreply.github.com>

#3341 fix(profiles): show enabled vs compatible skill counts
Co-authored-by: b3nw <b3nw@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-06-01 18:23:49 +00:00
parent c5aed7654c
commit de22c607bc
7 changed files with 423 additions and 49 deletions

View File

@@ -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 []

View File

@@ -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}

View File

@@ -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.

View File

@@ -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
? `<span class="profile-opt-badge running" title="${esc(t('profile_gateway_running'))}"></span>`
: `<span class="profile-opt-badge stopped" title="${esc(t('profile_gateway_stopped'))}"></span>`;
@@ -5109,7 +5109,7 @@ function _renderProfileDetail(p, activeName){
if (p.provider) rows.push(`<div class="detail-row"><div class="detail-row-label">Provider</div><div class="detail-row-value">${esc(p.provider)}</div></div>`);
if (p.base_url) rows.push(`<div class="detail-row"><div class="detail-row-label">Base URL</div><div class="detail-row-value"><code>${esc(p.base_url)}</code></div></div>`);
rows.push(`<div class="detail-row"><div class="detail-row-label">API key</div><div class="detail-row-value">${p.has_env ? esc(t('profile_api_keys_configured')) : '<span style="color:var(--muted)">Not configured</span>'}</div></div>`);
if (typeof p.skill_count === 'number') rows.push(`<div class="detail-row"><div class="detail-row-label">Skills</div><div class="detail-row-value">${esc(t('profile_skill_count', p.skill_count))}</div></div>`);
if (p.total_skills && p.total_skills > 0) rows.push(`<div class="detail-row"><div class="detail-row-label">Skills</div><div class="detail-row-value">${esc(t('profile_skill_count', p.total_skills).replace(String(p.total_skills), `${p.enabled_skills} / ${p.total_skills}`))}</div></div>`);
if (p.default_workspace) rows.push(`<div class="detail-row"><div class="detail-row-label">Default space</div><div class="detail-row-value"><code>${esc(p.default_workspace)}</code></div></div>`);
body.innerHTML = `
<div class="main-view-content">
@@ -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 = `<span class="profile-opt-badge ${p.gateway_running ? 'running' : 'stopped'}"></span>`;
const checkmark = p.name === active ? ' <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--link)" stroke-width="3" style="vertical-align:-1px"><polyline points="20 6 9 17 4 12"/></svg>' : '';
const defaultBadge = p.is_default ? ` <span style="opacity:.5;font-weight:400">${esc(t('profile_default_label'))}</span>` : '';

View File

@@ -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():

View File

@@ -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

View File

@@ -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")