diff --git a/CHANGELOG.md b/CHANGELOG.md index fa5d9f90..eb4aba35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +## [v0.51.316] — 2026-06-07 — Release KF (Phase 2 — agent-source dependency audit contract) + +### Changed +- **Documented the WebUI → hermes-agent source-dependency contract.** Adds a deterministic, repo-relative audit script (`scripts/audit_agent_source_dependencies.py`) that classifies how the WebUI depends on the agent source tree, an architecture/contract doc, and a regression test pinning the dependency classes. Read-only tooling and docs — no runtime behavior change; groundwork for cleaner agent/WebUI packaging boundaries. (#3723, @rodboev) + ## [v0.51.315] — 2026-06-07 — Release KE (test infra — cross-platform workspace-fallback tests) ### Changed diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 3bdc6ed0..83f4ed9b 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -42,6 +42,10 @@ does not change runtime behavior, maintainer policy, bot behavior, or CI gates. execution behind an adapter boundary. Use this for adapter-seam, control-plane, runner, sidecar, or execution-ownership work; do not treat it as authorization to implement those slices. +- [`docs/architecture/agent-api-contract.md`](architecture/agent-api-contract.md): + current audit of WebUI dependencies on the hermes-agent source checkout and + the replacement API/client surfaces needed before source mounts can be removed. + Start here for issue #2491 and Docker/source-boundary migration slices. - [`docs/rfcs/turn-journal.md`](rfcs/turn-journal.md): proposed crash-safe write-ahead journal for browser-originated chat turns. - [`docs/rfcs/README.md`](rfcs/README.md): RFC conventions and current RFC index. diff --git a/docs/architecture/agent-api-contract.md b/docs/architecture/agent-api-contract.md new file mode 100644 index 00000000..a6f52a95 --- /dev/null +++ b/docs/architecture/agent-api-contract.md @@ -0,0 +1,111 @@ +# WebUI to hermes-agent source dependency contract + +This document records the current WebUI dependency on the hermes-agent source +tree for issue #2491. It is an audit and replacement contract only; it does not +change runtime behavior or Docker wiring. + +Run the deterministic audit with: + +```powershell +python scripts/audit_agent_source_dependencies.py +python scripts/audit_agent_source_dependencies.py --format markdown +``` + +The JSON output is repo-relative and grouped by stable dependency class IDs so +follow-up PRs can remove one class at a time without relying on exact line +fixtures. + +## Current boundary + +The multi-container setup still shares the agent source tree with the WebUI: + +- `docker-compose.two-container.yml` mounts `hermes-agent-src` at `/opt/hermes` + in the agent service and read-only at + `/home/hermeswebui/.hermes/hermes-agent` in the WebUI service. +- `docker-compose.three-container.yml` uses the same source volume pattern. +- `docker_init.bash` documents that the WebUI-side mount is read-only and uses + a staged copy when installing from the source checkout. +- `docs/docker.md` explicitly documents that this is not a filesystem trust + boundary: the WebUI cannot write the mount, but it still installs and imports + code from it. + +The durable target is that multi-container WebUI should not need a direct +`hermes-agent-src` mount. The WebUI should communicate with hermes-agent through +HTTP endpoints and a small versioned client/shared-schema package where pure +helpers are genuinely shared code. + +## Dependency classes + +| Audit class | Current surface | Replacement classification | +| --- | --- | --- | +| `docker_agent_source_volume` | Compose files and Docker docs expose `hermes-agent-src` and `/opt/hermes` to make the agent checkout visible to WebUI. | Remove the WebUI source mount only after startup install and runtime imports have migrated. This needs Docker/compose follow-up work, not a runtime behavior change in this audit PR. | +| `startup_dependency_install` | `api/startup.py` discovers `HERMES_WEBUI_AGENT_DIR` or `$HERMES_HOME/hermes-agent`; `server.py` calls `auto_install_agent_deps()` after import verification fails; `docker_init.bash` installs from the staged agent source. | Replace source-tree pip installs with a packaged hermes-agent WebUI client plus an agent health/version capability contract. Keep `HERMES_WEBUI_AGENT_DIR` during migration as an override/debug path, but it should stop being required in normal multi-container startup. | +| `runtime_auxiliary_model_metadata` | `api/streaming.py`, `api/routes.py`, `api/config.py`, and `api/providers.py` import `agent.auxiliary_client`, `agent.model_metadata`, `agent.models_dev`, `hermes_cli.models`, and `agent.account_usage`. | Existing provider/model WebUI endpoints can keep serving UI data where they already wrap agent helpers. Missing surfaces need hermes-agent endpoints or a client package for auxiliary task config, text auxiliary calls, context length, token estimate, provider catalog, and account usage. | +| `runtime_session_state` | `api/streaming.py`, `api/goals.py`, and `api/state_sync.py` import `hermes_state.SessionDB` directly. | Move cross-container state reads and writes behind hermes-agent session/state endpoints. WebUI-only presentation state can remain local, but agent session storage should not be opened from the WebUI container. | +| `runtime_gateway_provider` | `api/streaming.py` and `api/routes.py` import `hermes_cli.runtime_provider`; adapter helpers such as `agent.anthropic_adapter` are also imported for gateway normalization. | Provider resolution, runtime routing, and gateway invocation should be hermes-agent API calls. WebUI can keep request validation and display formatting, but it should not import runtime provider internals from the agent checkout. | +| `webui_local_or_client_package` | WebUI imports `hermes_cli.auth`, `hermes_cli.config`, `hermes_cli.plugins`, `hermes_cli.profiles`, `hermes_cli.goals`, `agent.skill_utils`, `agent.credential_pool`, and `hermes_constants`. | Pure schemas, constants, and parsing helpers can move into a small versioned client/shared package. Privileged data such as credential pools, auth status, profile mutation, plugin discovery, and goal persistence need hermes-agent endpoints. UI-only formatting can remain in WebUI. | + +## Replacement contract + +### Existing endpoint candidates + +The WebUI already exposes provider, model, profile, route, and streaming +handlers that callers use today. Those handlers can remain as WebUI HTTP routes +when they only format UI responses, but their implementations should stop +loading agent modules directly. Good candidates for reusing the current WebUI +route shape while changing its backend dependency are: + +- Provider/model catalog routes currently backed by `hermes_cli.models`. +- Auxiliary title/compression paths currently backed by `agent.auxiliary_client`. +- Context-window and token-estimate paths currently backed by + `agent.model_metadata`. +- Runtime-provider choices currently backed by `hermes_cli.runtime_provider`. + +### New hermes-agent endpoints needed + +These surfaces require an agent-owned endpoint because they read agent state, +perform provider/runtime decisions, or expose privileged data: + +- SessionDB/session state read and write operations now using + `hermes_state.SessionDB`. +- Runtime provider resolution and gateway normalization now using + `hermes_cli.runtime_provider` and `agent.anthropic_adapter`. +- Auxiliary task execution and configuration now using `agent.auxiliary_client`. +- Credential/auth/account usage access now using `agent.credential_pool`, + `hermes_cli.auth`, and `agent.account_usage`. +- Profile, plugin, goal, and skill operations that mutate or discover + agent-owned resources. + +### Client/shared package candidates + +These items can be kept out of the live agent API if they are pure, versioned, +and safe to import without the agent source tree: + +- Shared constants currently imported from `hermes_constants`. +- Provider/model schema names and non-privileged catalog shape definitions. +- Pure skill/profile parsing helpers that do not touch agent-owned state. +- Typed response/request models for the new hermes-agent endpoints. + +### WebUI-local items + +The WebUI can keep code that is only presentation, validation, or routing glue: + +- User-facing diagnostics that display whether `HERMES_WEBUI_AGENT_DIR` is set. +- Route-level request validation and response formatting. +- WebUI-only caches and client-facing state that do not open agent SessionDB. +- Docker documentation describing the transition while both paths are supported. + +## Audit expectations + +`tests/test_agent_source_dependency_audit.py` pins the contract shape: + +- Docker/compose source sharing is reported. +- Startup dependency installation and `HERMES_WEBUI_AGENT_DIR` are reported. +- Runtime auxiliary/model metadata imports are reported. +- Runtime SessionDB/state imports are reported. +- Runtime provider/gateway imports are reported. +- The catch-all class for local/client-package candidates remains populated. + +The tests intentionally check stable class IDs and representative anchors, not +exact full fixtures. Follow-up migration PRs should update this document and the +audit expectations when a dependency class is intentionally reduced or removed. diff --git a/scripts/audit_agent_source_dependencies.py b/scripts/audit_agent_source_dependencies.py new file mode 100644 index 00000000..a2802acf --- /dev/null +++ b/scripts/audit_agent_source_dependencies.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +"""Audit WebUI dependencies on the hermes-agent source tree. + +This report is deterministic and repo-relative so migration PRs can compare the +same dependency classes without relying on brittle exact line fixtures. +""" +from __future__ import annotations + +import argparse +import ast +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +AGENT_MODULE_ROOTS = ( + "agent", + "cron", + "hermes_cli", + "hermes_constants", + "hermes_state", + "run_agent", + "tools", +) + + +@dataclass(frozen=True) +class Finding: + path: str + line: int + kind: str + anchor: str + text: str + + +@dataclass(frozen=True) +class DependencyClass: + class_id: str + title: str + current_dependency: str + replacement_surface: str + findings: tuple[Finding, ...] + + +def _repo_root_from_script() -> Path: + return Path(__file__).resolve().parents[1] + + +def _line_text(path: Path, line_number: int) -> str: + try: + return path.read_text(encoding="utf-8").splitlines()[line_number - 1].strip() + except (IndexError, OSError, UnicodeDecodeError): + return "" + + +def _iter_text_matches( + root: Path, + paths: Iterable[str], + patterns: Iterable[tuple[str, str]], +) -> list[Finding]: + findings: list[Finding] = [] + compiled = [(kind, re.compile(pattern)) for kind, pattern in patterns] + for rel in sorted(paths): + path = root / rel + if not path.is_file(): + continue + try: + lines = path.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + continue + for line_number, line in enumerate(lines, start=1): + for kind, pattern in compiled: + match = pattern.search(line) + if match: + findings.append( + Finding( + path=rel.replace("\\", "/"), + line=line_number, + kind=kind, + anchor=match.group(0), + text=line.strip(), + ) + ) + break + return findings + + +def _iter_python_files(root: Path) -> list[Path]: + paths: set[Path] = set() + api_dir = root / "api" + if api_dir.is_dir(): + paths.update(api_dir.rglob("*.py")) + for filename in ("server.py", "bootstrap.py"): + path = root / filename + if path.is_file(): + paths.add(path) + return sorted(paths) + + +def _module_root(module_name: str) -> str: + return module_name.split(".", 1)[0] + + +def _import_kind(module_name: str) -> str: + if module_name == "run_agent" or module_name.startswith("run_agent."): + return "runtime_agent_execution_import" + if module_name == "tools" or module_name.startswith("tools."): + return "runtime_tools_import" + if module_name == "cron" or module_name.startswith("cron."): + return "runtime_cron_import" + if module_name == "hermes_state" or module_name.startswith("hermes_state."): + return "state_import" + if module_name == "hermes_constants" or module_name.startswith("hermes_constants."): + return "constants_import" + if module_name.startswith("hermes_cli.runtime_provider"): + return "runtime_provider_import" + if module_name.startswith(("agent.auxiliary_client", "agent.model_metadata", "agent.models_dev")): + return "auxiliary_model_metadata_import" + if module_name.startswith(("hermes_cli.models", "agent.account_usage")): + return "provider_model_catalog_import" + if module_name.startswith(("hermes_cli.auth", "hermes_cli.config", "agent.credential_pool")): + return "auth_config_credential_import" + if module_name.startswith(("agent.skill_utils", "hermes_cli.plugins", "hermes_cli.profiles", "hermes_cli.goals")): + return "profiles_skills_plugins_import" + if module_name.startswith("agent.anthropic_adapter"): + return "gateway_adapter_import" + return "agent_source_import" + + +def _runtime_import_findings(root: Path) -> list[Finding]: + findings: list[Finding] = [] + for path in _iter_python_files(root): + rel = path.relative_to(root).as_posix() + try: + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=rel) + except (SyntaxError, UnicodeDecodeError): + continue + for node in ast.walk(tree): + module_name = "" + if isinstance(node, ast.ImportFrom) and node.module: + module_name = node.module + elif isinstance(node, ast.Import): + for alias in node.names: + if _module_root(alias.name) in AGENT_MODULE_ROOTS: + findings.append( + Finding( + path=rel, + line=node.lineno, + kind=_import_kind(alias.name), + anchor=alias.name, + text=_line_text(path, node.lineno), + ) + ) + continue + + if module_name and _module_root(module_name) in AGENT_MODULE_ROOTS: + findings.append( + Finding( + path=rel, + line=node.lineno, + kind=_import_kind(module_name), + anchor=module_name, + text=_line_text(path, node.lineno), + ) + ) + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): + findings.extend(_embedded_python_import_findings(rel, node.lineno, node.value.value)) + return sorted(findings, key=lambda item: (item.path, item.line, item.anchor)) + + +def _embedded_python_import_findings(rel: str, base_line: int, source: str) -> list[Finding]: + if "import " not in source: + return [] + try: + tree = ast.parse(source, filename=f"{rel}:embedded") + except SyntaxError: + return [] + lines = source.splitlines() + findings: list[Finding] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if _module_root(alias.name) in AGENT_MODULE_ROOTS: + line = base_line + node.lineno - 1 + findings.append( + Finding( + path=rel, + line=line, + kind=_import_kind(alias.name), + anchor=alias.name, + text=lines[node.lineno - 1].strip() if node.lineno - 1 < len(lines) else "", + ) + ) + elif isinstance(node, ast.ImportFrom) and node.module and _module_root(node.module) in AGENT_MODULE_ROOTS: + line = base_line + node.lineno - 1 + findings.append( + Finding( + path=rel, + line=line, + kind=_import_kind(node.module), + anchor=node.module, + text=lines[node.lineno - 1].strip() if node.lineno - 1 < len(lines) else "", + ) + ) + return findings + + +def _findings_by_kind(findings: Iterable[Finding], kinds: set[str]) -> tuple[Finding, ...]: + selected = [finding for finding in findings if finding.kind in kinds] + return tuple(sorted(selected, key=lambda item: (item.path, item.line, item.anchor))) + + +def build_report(root: Path) -> dict[str, object]: + root = root.resolve() + + docker_findings = _iter_text_matches( + root, + ( + "docker-compose.two-container.yml", + "docker-compose.three-container.yml", + "docker_init.bash", + "docs/docker.md", + "docs/rfcs/agent-source-boundary.md", + ), + ( + ("agent_source_volume", r"hermes-agent-src"), + ("agent_source_path", r"/opt/hermes(?:\b|[-/])?"), + ), + ) + startup_findings = _iter_text_matches( + root, + ( + "server.py", + "bootstrap.py", + "start.ps1", + "api/startup.py", + "api/config.py", + "api/streaming.py", + "docker_init.bash", + ), + ( + ("startup_install_function", r"auto_install_agent_deps"), + ("agent_dir_env", r"HERMES_WEBUI_AGENT_DIR"), + ("agent_source_install", r"uv pip install.*\[all\]"), + ("agent_source_staging", r"_agent_src|_stage_src"), + ), + ) + runtime_findings = _runtime_import_findings(root) + + classes = ( + DependencyClass( + class_id="docker_agent_source_volume", + title="Docker/compose source-tree sharing", + current_dependency=( + "Multi-container compose files expose the agent image source via " + "the hermes-agent-src volume and /opt/hermes." + ), + replacement_surface=( + "Remove the WebUI source mount after startup install and runtime " + "imports move to hermes-agent endpoints or a versioned client package." + ), + findings=_findings_by_kind(docker_findings, {"agent_source_volume", "agent_source_path"}), + ), + DependencyClass( + class_id="startup_dependency_install", + title="Startup dependency installation from agent checkout", + current_dependency=( + "WebUI startup discovers HERMES_WEBUI_AGENT_DIR or ~/.hermes/hermes-agent " + "and installs the agent checkout extras." + ), + replacement_surface=( + "Replace source-tree pip installs with a packaged hermes-agent client " + "or an agent health/version endpoint that declares required WebUI client capabilities." + ), + findings=_findings_by_kind( + startup_findings, + {"startup_install_function", "agent_dir_env", "agent_source_install", "agent_source_staging"}, + ), + ), + DependencyClass( + class_id="runtime_agent_execution", + title="Runtime agent execution, tools, and cron imports", + current_dependency=( + "Browser chat, approvals, tools, and scheduled work import Hermes Agent " + "execution modules directly from the source checkout." + ), + replacement_surface=( + "Move run orchestration, tool approval/control, and cron execution behind " + "Hermes Agent APIs or a versioned client package before removing source mounts." + ), + findings=_findings_by_kind( + runtime_findings, + {"runtime_agent_execution_import", "runtime_tools_import", "runtime_cron_import"}, + ), + ), + DependencyClass( + class_id="runtime_auxiliary_model_metadata", + title="Runtime auxiliary and model metadata imports", + current_dependency=( + "WebUI imports agent auxiliary client and model metadata helpers directly " + "for title/compression tasks, context windows, provider catalogs, and account usage." + ), + replacement_surface=( + "Use existing provider/model endpoints where they already exist; add agent " + "endpoints or a client package for auxiliary task config, text auxiliary " + "calls, context lengths, token estimates, provider catalog, and account usage." + ), + findings=_findings_by_kind( + runtime_findings, + {"auxiliary_model_metadata_import", "provider_model_catalog_import"}, + ), + ), + DependencyClass( + class_id="runtime_session_state", + title="Runtime SessionDB/state access", + current_dependency=( + "WebUI imports hermes_state.SessionDB and related state helpers directly " + "to read or write agent session state." + ), + replacement_surface=( + "Move cross-container state reads/writes behind hermes-agent session/state " + "endpoints; keep WebUI-only presentation caches local." + ), + findings=_findings_by_kind(runtime_findings, {"state_import"}), + ), + DependencyClass( + class_id="runtime_gateway_provider", + title="Gateway/runtime provider calls", + current_dependency=( + "WebUI imports hermes_cli.runtime_provider and agent adapter helpers " + "to resolve providers and normalize gateway calls." + ), + replacement_surface=( + "Route provider resolution and gateway invocation through hermes-agent " + "runtime/provider endpoints; keep only display formatting in WebUI." + ), + findings=_findings_by_kind( + runtime_findings, + {"runtime_provider_import", "gateway_adapter_import"}, + ), + ), + DependencyClass( + class_id="webui_local_or_client_package", + title="Config, auth, skills, profiles, plugins, and constants imports", + current_dependency=( + "WebUI imports hermes_cli and agent helpers for config/auth status, " + "credential pools, skills, profiles, plugin discovery, goals, and constants." + ), + replacement_surface=( + "Keep WebUI-owned display/config code local, but move shared schemas and " + "pure helpers into a versioned client package; privileged agent data needs endpoints." + ), + findings=_findings_by_kind( + runtime_findings, + { + "auth_config_credential_import", + "profiles_skills_plugins_import", + "constants_import", + "agent_source_import", + }, + ), + ), + ) + + class_dicts: list[dict[str, object]] = [] + for item in classes: + findings = [ + { + "path": finding.path, + "line": finding.line, + "kind": finding.kind, + "anchor": finding.anchor, + "text": finding.text, + } + for finding in item.findings + ] + class_dicts.append( + { + "id": item.class_id, + "title": item.title, + "current_dependency": item.current_dependency, + "replacement_surface": item.replacement_surface, + "finding_count": len(findings), + "findings": findings, + } + ) + + return { + "schema_version": 1, + "repo_root": ".", + "summary": { + "dependency_class_count": len(class_dicts), + "finding_count": sum(int(item["finding_count"]) for item in class_dicts), + "dependency_class_ids": [item["id"] for item in class_dicts], + }, + "dependency_classes": class_dicts, + } + + +def _print_markdown(report: dict[str, object]) -> None: + print("# Agent Source Dependency Audit") + print() + summary = report["summary"] + assert isinstance(summary, dict) + print(f"- Schema version: {report['schema_version']}") + print(f"- Dependency classes: {summary['dependency_class_count']}") + print(f"- Findings: {summary['finding_count']}") + print() + classes = report["dependency_classes"] + assert isinstance(classes, list) + for item in classes: + assert isinstance(item, dict) + print(f"## {item['id']}: {item['title']}") + print() + print(f"- Current dependency: {item['current_dependency']}") + print(f"- Replacement surface: {item['replacement_surface']}") + print(f"- Findings: {item['finding_count']}") + print() + findings = item["findings"] + assert isinstance(findings, list) + for finding in findings: + print( + f"- `{finding['path']}:{finding['line']}` " + f"({finding['kind']}, `{finding['anchor']}`): {finding['text']}" + ) + print() + + +def main(argv: list[str] | None = None) -> int: + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8") + except Exception: + pass + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "repo_root", + nargs="?", + type=Path, + default=_repo_root_from_script(), + help="Path to the hermes-webui checkout; defaults to this script's repo.", + ) + parser.add_argument( + "--format", + choices=("json", "markdown"), + default="json", + help="Output format. JSON is stable for tests and automation.", + ) + args = parser.parse_args(argv) + + report = build_report(args.repo_root) + if args.format == "markdown": + _print_markdown(report) + else: + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/test_agent_source_dependency_audit.py b/tests/test_agent_source_dependency_audit.py new file mode 100644 index 00000000..a9f74e55 --- /dev/null +++ b/tests/test_agent_source_dependency_audit.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import json +import importlib.util +import subprocess +import sys +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] +AUDIT_SCRIPT = REPO / "scripts" / "audit_agent_source_dependencies.py" + + +def _audit_module(): + spec = importlib.util.spec_from_file_location("audit_agent_source_dependencies", AUDIT_SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _run_audit() -> dict[str, object]: + proc = subprocess.run( + [sys.executable, str(AUDIT_SCRIPT), str(REPO)], + cwd=REPO, + capture_output=True, + text=True, + check=True, + ) + return json.loads(proc.stdout) + + +def _run_markdown_audit() -> str: + proc = subprocess.run( + [sys.executable, str(AUDIT_SCRIPT), str(REPO), "--format", "markdown"], + cwd=REPO, + capture_output=True, + text=True, + check=True, + ) + return proc.stdout + + +def _class_by_id(report: dict[str, object]) -> dict[str, dict[str, object]]: + classes = report["dependency_classes"] + assert isinstance(classes, list) + return {item["id"]: item for item in classes} + + +def _anchors(dep_class: dict[str, object]) -> set[tuple[str, str]]: + findings = dep_class["findings"] + assert isinstance(findings, list) + return { + (str(finding["path"]), str(finding["anchor"])) + for finding in findings + } + + +def _texts(dep_class: dict[str, object]) -> list[str]: + findings = dep_class["findings"] + assert isinstance(findings, list) + return [str(finding["text"]) for finding in findings] + + +def _findings(dep_class: dict[str, object]) -> list[dict[str, object]]: + findings = dep_class["findings"] + assert isinstance(findings, list) + return findings + + +def test_audit_reports_expected_dependency_classes(): + report = _run_audit() + + assert report["schema_version"] == 1 + classes = _class_by_id(report) + assert set(classes) == { + "docker_agent_source_volume", + "startup_dependency_install", + "runtime_agent_execution", + "runtime_auxiliary_model_metadata", + "runtime_session_state", + "runtime_gateway_provider", + "webui_local_or_client_package", + } + for dep_class in classes.values(): + assert dep_class["finding_count"] > 0 + assert dep_class["findings"] + assert dep_class["replacement_surface"] + + +def test_audit_reports_compose_source_volume_anchors(): + classes = _class_by_id(_run_audit()) + anchors = _anchors(classes["docker_agent_source_volume"]) + texts = _texts(classes["docker_agent_source_volume"]) + + assert ("docker-compose.two-container.yml", "hermes-agent-src") in anchors + assert ("docker-compose.three-container.yml", "hermes-agent-src") in anchors + assert any("hermes-agent-src:/opt/hermes" in text for text in texts) + assert any( + "hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro" in text + for text in texts + ) + + +def test_audit_reports_startup_install_dependencies(): + classes = _class_by_id(_run_audit()) + anchors = _anchors(classes["startup_dependency_install"]) + texts = _texts(classes["startup_dependency_install"]) + + assert ("api/startup.py", "HERMES_WEBUI_AGENT_DIR") in anchors + assert ("bootstrap.py", "HERMES_WEBUI_AGENT_DIR") in anchors + assert ("start.ps1", "HERMES_WEBUI_AGENT_DIR") in anchors + assert ("api/startup.py", "auto_install_agent_deps") in anchors + assert ("server.py", "auto_install_agent_deps") in anchors + assert any("uv pip install" in text and "[all]" in text for text in texts) + + +def test_audit_reports_runtime_agent_execution_imports(): + classes = _class_by_id(_run_audit()) + anchors = _anchors(classes["runtime_agent_execution"]) + + assert ("api/streaming.py", "run_agent") in anchors + assert ("api/routes.py", "tools.skills_tool") in anchors + assert ("api/streaming.py", "tools.approval") in anchors + assert ("api/routes.py", "cron.jobs") in anchors + + +def test_audit_reports_runtime_auxiliary_and_model_metadata_imports(): + classes = _class_by_id(_run_audit()) + anchors = _anchors(classes["runtime_auxiliary_model_metadata"]) + + assert ("api/streaming.py", "agent.auxiliary_client") in anchors + assert ("api/streaming.py", "agent.model_metadata") in anchors + assert ("api/config.py", "hermes_cli.models") in anchors + assert ("api/providers.py", "agent.account_usage") in anchors + + +def test_audit_embedded_worker_import_line_anchors_are_source_lines(): + classes = _class_by_id(_run_audit()) + provider_findings = _findings(classes["runtime_auxiliary_model_metadata"]) + account_usage = next( + finding + for finding in provider_findings + if finding["path"] == "api/providers.py" and finding["anchor"] == "agent.account_usage" + ) + + assert account_usage["line"] == 168 + assert account_usage["text"] == "from agent.account_usage import fetch_account_usage" + + +def test_audit_reports_runtime_state_and_provider_imports(): + classes = _class_by_id(_run_audit()) + state_anchors = _anchors(classes["runtime_session_state"]) + provider_anchors = _anchors(classes["runtime_gateway_provider"]) + + assert ("api/streaming.py", "hermes_state") in state_anchors + assert ("api/state_sync.py", "hermes_state") in state_anchors + assert ("api/streaming.py", "hermes_cli.runtime_provider") in provider_anchors + assert ("api/routes.py", "hermes_cli.runtime_provider") in provider_anchors + + +def test_runtime_import_scan_includes_root_python_entrypoints(): + root = AUDIT_SCRIPT.parents[1] + audit_module = _audit_module() + paths = { + path.relative_to(root).as_posix() + for path in audit_module._iter_python_files(root) + } + + assert "api/routes.py" in paths + assert "server.py" in paths + assert "bootstrap.py" in paths + + +def test_audit_keeps_client_package_candidates_visible(): + classes = _class_by_id(_run_audit()) + anchors = _anchors(classes["webui_local_or_client_package"]) + + assert ("api/streaming.py", "hermes_constants") in anchors + assert ("api/routes.py", "agent.skill_utils") in anchors + assert ("api/routes.py", "hermes_cli.plugins") in anchors + assert ("api/providers.py", "agent.credential_pool") in anchors + + +def test_markdown_output_is_utf8_safe_on_windows_stdout(): + markdown = _run_markdown_audit() + + assert "# Agent Source Dependency Audit" in markdown + assert "runtime_agent_execution" in markdown + + +def test_contract_index_links_agent_api_contract(): + contracts = (REPO / "docs" / "CONTRACTS.md").read_text(encoding="utf-8") + + assert "docs/architecture/agent-api-contract.md" in contracts + assert "issue #2491" in contracts + assert "source mounts can be removed" in contracts