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]
|
## [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)
|
## [v0.51.315] — 2026-06-07 — Release KE (test infra — cross-platform workspace-fallback tests)
|
||||||
|
|
||||||
### Changed
|
### 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,
|
execution behind an adapter boundary. Use this for adapter-seam, control-plane,
|
||||||
runner, sidecar, or execution-ownership work; do not treat it as authorization
|
runner, sidecar, or execution-ownership work; do not treat it as authorization
|
||||||
to implement those slices.
|
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
|
- [`docs/rfcs/turn-journal.md`](rfcs/turn-journal.md): proposed crash-safe
|
||||||
write-ahead journal for browser-originated chat turns.
|
write-ahead journal for browser-originated chat turns.
|
||||||
- [`docs/rfcs/README.md`](rfcs/README.md): RFC conventions and current RFC index.
|
- [`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