Phase 005: vendor ArkScope, land the Arco style baseline, and fix bridge interpreter resolution
- Vendor ArkScope decorative layers (ScrambleTitle / IsometricWave) with provenance notes; decorative-only per AGENTS.md red lines - Migrate theme tokens to Arco, register the Arco theme, and add hook adapter composables (useMessage / useDialog / useNotification) with matching signatures - Sample pages: LoginView and AppSidebar on the new baseline; docs/arco-mapping.md and docs/ui-baseline.md land - agent-bridge: prefer the PM-selected venv from facts.json over legacy in-tree venvs (legacy lacks the mcp extra, so every MCP server showed 0 tools / not connected) - agent-bridge python (vendored, deliberate deviation): MCP discovery import fallback and pyyaml -> hermes_yaml so plugins and MCP work under the PM env; install the mcp extra via `hermes pm install --extra mcp` - Chat-chain fragment: docs/chat-chain-changes/2026-10-17-phase005-arkscope-baseline.md Verified: harness:check, test (2020 passed), test:e2e (30 passed), build all green; manual: plugins 100/0 warnings, MCP 6/6 servers connected with 104 tools.
This commit is contained in:
@@ -75,9 +75,12 @@ python packages/server/src/services/hermes/agent-bridge/python/hermes_bridge.py
|
||||
|
||||
If no source checkout containing `run_agent.py` is found, the bridge falls back
|
||||
to importing `run_agent` from the Python environment. This supports package
|
||||
installs such as `pip install hermes-agent`. The Node manager prefers the source
|
||||
checkout's virtualenv when a checkout exists, then the Python interpreter from
|
||||
the installed `hermes` command, then the system Python.
|
||||
installs such as `pip install hermes-agent`. The Node manager resolves the
|
||||
interpreter in this order: an explicit `HERMES_AGENT_BRIDGE_PYTHON`, the
|
||||
PM-selected venv recorded in `<hermesHome>/installs/<key>/facts.json` (the
|
||||
environment hermes itself runs in, including extras such as `mcp`), the
|
||||
checkout's in-tree `.venv`/`venv` trees (legacy fallbacks), the Python
|
||||
interpreter from the installed `hermes` command, `uv run`, then system Python.
|
||||
|
||||
The socket transport uses Python and Node standard libraries. No ZMQ dependency
|
||||
is required.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync, spawn, type ChildProcess } from 'child_process'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { createHash } from 'crypto'
|
||||
import { existsSync, readFileSync, realpathSync } from 'fs'
|
||||
import { createConnection, createServer } from 'net'
|
||||
import { dirname, isAbsolute, join, resolve } from 'path'
|
||||
import { logger } from '../../logger'
|
||||
@@ -103,18 +104,22 @@ export function buildAgentBridgeProcessEnv(endpoint: string, hermesHome: string
|
||||
|
||||
function pathCandidates(agentRoot?: string): string[] {
|
||||
if (!agentRoot) return []
|
||||
// Legacy in-tree virtualenvs. PM-managed installs live under
|
||||
// `<hermesHome>/installs/<key>/environments/.../venv` (see
|
||||
// pmSelectedVenvPython below); these trees predate PM and may be a bare
|
||||
// interpreter, so they are only a fallback.
|
||||
return process.platform === 'win32'
|
||||
? [
|
||||
join(agentRoot, 'venv', 'Scripts', 'python.exe'),
|
||||
join(agentRoot, 'venv', 'Scripts', 'python3.exe'),
|
||||
join(agentRoot, '.venv', 'Scripts', 'python.exe'),
|
||||
join(agentRoot, '.venv', 'Scripts', 'python3.exe'),
|
||||
join(agentRoot, 'venv', 'Scripts', 'python.exe'),
|
||||
join(agentRoot, 'venv', 'Scripts', 'python3.exe'),
|
||||
]
|
||||
: [
|
||||
join(agentRoot, 'venv', 'bin', 'python3'),
|
||||
join(agentRoot, 'venv', 'bin', 'python'),
|
||||
join(agentRoot, '.venv', 'bin', 'python3'),
|
||||
join(agentRoot, '.venv', 'bin', 'python'),
|
||||
join(agentRoot, 'venv', 'bin', 'python3'),
|
||||
join(agentRoot, 'venv', 'bin', 'python'),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -218,6 +223,41 @@ function firstExistingExecutable(candidates: string[]): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// PM (hermes's package manager) records the selected venv in
|
||||
// `<hermesHome>/installs/<installKey>/facts.json`, where installKey is
|
||||
// sha256(resolved agentRoot)[:16] (see pm/environments.py). That venv carries
|
||||
// hermes's extras (mcp, ruamel, ...); the in-tree `.venv`/`venv` trees predate
|
||||
// PM and often lack them.
|
||||
function pmSelectedVenvPython(agentRoot: string | undefined, hermesHome: string): string | undefined {
|
||||
if (!agentRoot) return undefined
|
||||
let resolvedRoot = agentRoot
|
||||
try {
|
||||
resolvedRoot = realpathSync(agentRoot)
|
||||
} catch {}
|
||||
const installKey = createHash('sha256').update(resolve(resolvedRoot)).digest('hex').slice(0, 16)
|
||||
const factsCandidates = [
|
||||
join(hermesHome, 'installs', installKey, 'facts.json'),
|
||||
join(process.env.HERMES_HOME?.trim() || join(process.env.HOME || '', '.hermes'), 'installs', installKey, 'facts.json'),
|
||||
]
|
||||
for (const factsPath of factsCandidates) {
|
||||
try {
|
||||
if (!existsSync(factsPath)) continue
|
||||
const facts = JSON.parse(readFileSync(factsPath, 'utf8')) as {
|
||||
packages?: { venv?: { environment?: string } }
|
||||
}
|
||||
const environment = facts?.packages?.venv?.environment
|
||||
if (!environment) continue
|
||||
const python = firstExistingExecutable(
|
||||
process.platform === 'win32'
|
||||
? [join(environment, 'Scripts', 'python.exe'), join(environment, 'Scripts', 'python3.exe')]
|
||||
: [join(environment, 'bin', 'python3'), join(environment, 'bin', 'python')],
|
||||
)
|
||||
if (python) return python
|
||||
} catch {}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function resolveAgentRoot(explicit?: string, hermesHome = detectHermesHome()): string | undefined {
|
||||
const candidates = [
|
||||
explicit,
|
||||
@@ -242,6 +282,11 @@ export function resolveAgentBridgeCommand(options: AgentBridgeManagerOptions = {
|
||||
return { command: explicitPython, argsPrefix: [], agentRoot, hermesHome }
|
||||
}
|
||||
|
||||
const pmPython = pmSelectedVenvPython(agentRoot, hermesHome)
|
||||
if (pmPython) {
|
||||
return { command: pmPython, argsPrefix: [], agentRoot, hermesHome }
|
||||
}
|
||||
|
||||
const venvPython = firstExistingExecutable(pathCandidates(agentRoot))
|
||||
if (venvPython) {
|
||||
return { command: venvPython, argsPrefix: [], agentRoot, hermesHome }
|
||||
|
||||
@@ -825,7 +825,11 @@ def _load_enabled_toolsets() -> list[str] | None:
|
||||
def _discover_bridge_mcp_tools() -> list[str]:
|
||||
_ensure_agent_imports()
|
||||
try:
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
try:
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
except ImportError:
|
||||
# hermes-agent split tools.mcp_tool; discovery lives in its own module now.
|
||||
from tools.mcp_tool_discovery import discover_mcp_tools
|
||||
|
||||
tools = discover_mcp_tools()
|
||||
return list(tools) if isinstance(tools, list) else []
|
||||
|
||||
@@ -237,7 +237,9 @@ class BridgeServer:
|
||||
|
||||
def _read_mcp_config(self, profile=None):
|
||||
"""Read config.yaml for the given profile."""
|
||||
import yaml
|
||||
# hermes ships `hermes_yaml` (ruamel, YAML 1.1 policy) and has no pyyaml
|
||||
# dependency, so read config with the same parser hermes itself uses.
|
||||
import hermes_yaml as yaml
|
||||
config_path = _profile_home(profile) / "config.yaml"
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
@@ -247,7 +249,6 @@ class BridgeServer:
|
||||
|
||||
def _save_mcp_config(self, cfg, profile=None):
|
||||
"""Save config.yaml for the given profile using atomic write."""
|
||||
import yaml
|
||||
from utils import atomic_yaml_write
|
||||
config_path = _profile_home(profile) / "config.yaml"
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -269,9 +270,27 @@ class BridgeServer:
|
||||
_restore_profile_env(original)
|
||||
threading.Thread(target=_bg, daemon=True).start()
|
||||
|
||||
@staticmethod
|
||||
def _mcp_registry():
|
||||
"""Import MCP registry symbols from whichever module owns them.
|
||||
|
||||
hermes-agent split ``tools.mcp_tool`` into a module family: discovery and
|
||||
loop helpers moved to ``tools.mcp_tool_discovery`` / ``tools.mcp_tool_loop``
|
||||
while the ``_servers`` ledger and ``_lock`` stay at the origin module.
|
||||
Raises ImportError when no MCP tool module is importable at all.
|
||||
"""
|
||||
from tools.mcp_tool import _lock, _servers
|
||||
|
||||
try:
|
||||
from tools.mcp_tool import _run_on_mcp_loop, discover_mcp_tools, register_mcp_servers
|
||||
except ImportError:
|
||||
from tools.mcp_tool_discovery import discover_mcp_tools, register_mcp_servers
|
||||
from tools.mcp_tool_loop import _run_on_mcp_loop
|
||||
return discover_mcp_tools, register_mcp_servers, _run_on_mcp_loop, _servers, _lock
|
||||
|
||||
def _shutdown_all_mcp_servers(self) -> int:
|
||||
try:
|
||||
from tools.mcp_tool import _run_on_mcp_loop, _servers, _lock
|
||||
_, _, _run_on_mcp_loop, _servers, _lock = self._mcp_registry()
|
||||
except ImportError:
|
||||
return 0
|
||||
with _lock:
|
||||
@@ -281,7 +300,7 @@ class BridgeServer:
|
||||
def _handle_mcp_action(self, action: str, req: dict[str, Any], profile: str | None = None) -> dict[str, Any]:
|
||||
"""Handle MCP management actions in worker process."""
|
||||
try:
|
||||
from tools.mcp_tool import discover_mcp_tools, register_mcp_servers, _run_on_mcp_loop, _servers, _lock
|
||||
discover_mcp_tools, register_mcp_servers, _run_on_mcp_loop, _servers, _lock = self._mcp_registry()
|
||||
except ImportError:
|
||||
return {"error": "MCP tool module not available", "ok": False}
|
||||
|
||||
|
||||
@@ -97,16 +97,25 @@ def coerce_list(value):
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def read_manifest_list(plugin_path, *keys):
|
||||
def load_yaml_text(text):
|
||||
# Hermes has no pyyaml dependency: hermes_yaml (ruamel, YAML 1.1) is the
|
||||
# parser hermes itself uses, so fall back to it when pyyaml is absent.
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
import hermes_yaml as yaml
|
||||
return yaml.safe_load(text)
|
||||
|
||||
|
||||
def read_manifest_list(plugin_path, *keys):
|
||||
try:
|
||||
plugin_dir = Path(plugin_path)
|
||||
manifest_file = plugin_dir / "plugin.yaml"
|
||||
if not manifest_file.exists():
|
||||
manifest_file = plugin_dir / "plugin.yml"
|
||||
if not manifest_file.exists():
|
||||
return []
|
||||
data = yaml.safe_load(manifest_file.read_text(encoding="utf-8")) or {}
|
||||
data = load_yaml_text(manifest_file.read_text(encoding="utf-8")) or {}
|
||||
for key in keys:
|
||||
value = data.get(key)
|
||||
if isinstance(value, list):
|
||||
@@ -128,11 +137,10 @@ def read_config_file(home):
|
||||
if not home:
|
||||
return {}
|
||||
try:
|
||||
import yaml
|
||||
path = Path(home) / "config.yaml"
|
||||
if not path.exists():
|
||||
return {}
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
return load_yaml_text(path.read_text(encoding="utf-8")) or {}
|
||||
except Exception as exc:
|
||||
warnings.append(f"plugin config at {home}: {exc}")
|
||||
return {}
|
||||
|
||||
Reference in New Issue
Block a user