mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-07 16:21:23 +00:00
07235674ad
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>
465 lines
17 KiB
Python
465 lines
17 KiB
Python
#!/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:]))
|