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(`
${esc(p.base_url)}${esc(p.default_workspace)}