Release v0.51.316 — Release KF (Phase 2: agent-source dependency audit, #3723) (#3785)
Some checks failed
Release & Docker / release (push) Has been cancelled
Some checks failed
Release & Docker / release (push) Has been cancelled
Phase-2 release. #3723 (@rodboev): read-only agent-source dependency audit script + contract docs + test, no runtime change. (#3781 Polish locale dropped from batch — incomplete locale, kicked back to contributor.) Full suite 8199 passed, Opus SHIP, Codex clean. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
111
docs/architecture/agent-api-contract.md
Normal file
111
docs/architecture/agent-api-contract.md
Normal file
@@ -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.
|
||||
464
scripts/audit_agent_source_dependencies.py
Normal file
464
scripts/audit_agent_source_dependencies.py
Normal file
@@ -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:]))
|
||||
198
tests/test_agent_source_dependency_audit.py
Normal file
198
tests/test_agent_source_dependency_audit.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user