refactor(hermes_cli): group F — drop section banners, one-line UI helpers, fold configure pre-selection, hug closers
This commit is contained in:
+34
-60
@@ -27,9 +27,6 @@ _MANIFEST_VERSION = 1
|
||||
_INSTALL_DIR_VAR = "${INSTALL_DIR}"
|
||||
|
||||
|
||||
# ─── Data classes ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnvVarSpec:
|
||||
name: str
|
||||
@@ -43,8 +40,7 @@ class EnvVarSpec:
|
||||
class AuthSpec:
|
||||
type: str # "api_key" | "oauth" | "none"
|
||||
env: List[EnvVarSpec] = field(default_factory=list)
|
||||
# OAuth-specific (third-party provider like Google)
|
||||
provider: Optional[str] = None
|
||||
provider: Optional[str] = None # OAuth-specific (third-party provider like Google)
|
||||
scopes: List[str] = field(default_factory=list)
|
||||
env_var: Optional[str] = None
|
||||
|
||||
@@ -56,8 +52,8 @@ class TransportSpec:
|
||||
args: List[str] = field(default_factory=list)
|
||||
url: Optional[str] = None
|
||||
version: Optional[str] = None # informational, pinned
|
||||
# Static env for the stdio subprocess (telemetry opt-outs, mode flags). NOT for secrets —
|
||||
# credentials go through auth.env so they are prompted for and land in ~/.hermes/.env.
|
||||
# Static env for the stdio subprocess (telemetry opt-outs, mode flags). NOT for secrets — those
|
||||
# go through auth.env so they are prompted for and land in ~/.hermes/.env.
|
||||
env: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -72,15 +68,15 @@ class InstallSpec:
|
||||
|
||||
@dataclass
|
||||
class ToolsSpec:
|
||||
"""Manifest-side tool-selection hints: pre-check state for the install checklist and the
|
||||
fallback selection when the probe fails (see install_entry())."""
|
||||
"""Manifest-side tool-selection hints (see _apply_tool_selection()).
|
||||
|
||||
``default_enabled``: pre-checked in the install checklist / applied directly on probe failure;
|
||||
None => all pre-checked (no filter written on failure). ``default_excluded``: exclude-mode
|
||||
counterpart written to ``tools.exclude`` — everything NOT matching stays enabled, including tools
|
||||
the server adds later (for huge OpenAPI-derived surfaces). Mutually exclusive.
|
||||
"""
|
||||
|
||||
# Pre-checked (or applied directly on probe failure). None => all probed tools pre-checked
|
||||
# (or no filter written on probe failure).
|
||||
default_enabled: Optional[List[str]] = None
|
||||
# Exclude-mode counterpart written to ``tools.exclude``: everything NOT matching stays enabled,
|
||||
# including tools the server adds later. For huge auto-generated surfaces (OpenAPI-derived
|
||||
# MCPs). Mutually exclusive with ``default_enabled``.
|
||||
default_excluded: Optional[List[str]] = None
|
||||
|
||||
|
||||
@@ -110,9 +106,6 @@ class CatalogEntry:
|
||||
manifest_path: Path = field(default_factory=Path)
|
||||
|
||||
|
||||
# ─── Manifest loader ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CatalogError(Exception):
|
||||
"""Manifest parse/validation failure or install error."""
|
||||
|
||||
@@ -130,8 +123,7 @@ def _parse_env_spec(raw: Any) -> EnvVarSpec:
|
||||
raise CatalogError(f"invalid env var name: {name!r}")
|
||||
return EnvVarSpec(
|
||||
name=name, prompt=raw.get("prompt") or name, required=bool(raw.get("required", True)),
|
||||
secret=bool(raw.get("secret", True)), default=str(raw.get("default") or ""),
|
||||
)
|
||||
secret=bool(raw.get("secret", True)), default=str(raw.get("default") or ""))
|
||||
|
||||
|
||||
def _require_mapping(path: Path, key: str, raw: Any) -> dict:
|
||||
@@ -160,12 +152,13 @@ def _parse_transport(path: Path, raw: Any) -> TransportSpec:
|
||||
raise CatalogError(f"{path}: transport.type must be 'stdio' or 'http'")
|
||||
args = _require_list(path, "transport.args", transport_raw.get("args") or [])
|
||||
env_raw = transport_raw.get("env") or {}
|
||||
if not isinstance(env_raw, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in env_raw.items()):
|
||||
if not isinstance(env_raw, dict) or not all(
|
||||
isinstance(k, str) and isinstance(v, str) for k, v in env_raw.items()
|
||||
):
|
||||
raise CatalogError(f"{path}: transport.env must be a mapping of string to string")
|
||||
transport = TransportSpec(
|
||||
type=t_type, command=transport_raw.get("command"), args=[str(a) for a in args],
|
||||
url=transport_raw.get("url"), version=transport_raw.get("version"), env=dict(env_raw),
|
||||
)
|
||||
url=transport_raw.get("url"), version=transport_raw.get("version"), env=dict(env_raw))
|
||||
if t_type == "stdio" and not transport.command:
|
||||
raise CatalogError(f"{path}: stdio transport requires 'command'")
|
||||
if t_type == "http" and not transport.url:
|
||||
@@ -189,12 +182,10 @@ def _parse_auth(path: Path, raw: Any, name: str, http: bool) -> AuthSpec:
|
||||
if all(spec.name != _required_key for spec in env_list):
|
||||
raise CatalogError(
|
||||
f"{path}: http + api_key auth requires auth.env to declare "
|
||||
f"'{_required_key}' (the key the Authorization header references)"
|
||||
)
|
||||
f"'{_required_key}' (the key the Authorization header references)")
|
||||
return AuthSpec(
|
||||
type=a_type, env=env_list, provider=auth_raw.get("provider"),
|
||||
scopes=list(auth_raw.get("scopes") or []), env_var=auth_raw.get("env_var"),
|
||||
)
|
||||
scopes=list(auth_raw.get("scopes") or []), env_var=auth_raw.get("env_var"))
|
||||
|
||||
|
||||
def _parse_tools(path: Path, raw: Any) -> ToolsSpec:
|
||||
@@ -222,8 +213,7 @@ def _parse_suggest(path: Path, suggest_raw: Any) -> Optional[SuggestSpec]:
|
||||
# Matching is case-insensitive whole-word / host-suffix: store lowercase so UIs needn't re-normalize.
|
||||
return SuggestSpec(
|
||||
keywords=[k.strip().lower() for k in kw_raw],
|
||||
hosts=[h.strip().lower().lstrip(".") for h in hosts_raw],
|
||||
)
|
||||
hosts=[h.strip().lower().lstrip(".") for h in hosts_raw])
|
||||
|
||||
|
||||
def _parse_install(path: Path, install_raw: Any) -> Optional[InstallSpec]:
|
||||
@@ -254,8 +244,7 @@ def _parse_manifest(path: Path) -> CatalogEntry:
|
||||
if mv != _MANIFEST_VERSION:
|
||||
raise CatalogError(
|
||||
f"{path}: manifest_version {mv!r} unsupported "
|
||||
f"(this Hermes understands version {_MANIFEST_VERSION})"
|
||||
)
|
||||
f"(this Hermes understands version {_MANIFEST_VERSION})")
|
||||
name = data.get("name") or ""
|
||||
if not name or not re.match(r"^[A-Za-z0-9_-]+$", name):
|
||||
raise CatalogError(f"{path}: invalid or missing 'name'")
|
||||
@@ -279,8 +268,7 @@ def _parse_manifest(path: Path) -> CatalogEntry:
|
||||
install=install,
|
||||
post_install=str(data.get("post_install") or ""),
|
||||
suggest=suggest,
|
||||
manifest_path=path,
|
||||
)
|
||||
manifest_path=path)
|
||||
|
||||
|
||||
# Populated by list_catalog(); inspected by the picker / catalog UIs so the user gets actionable
|
||||
@@ -325,9 +313,6 @@ def get_entry(name: str) -> Optional[CatalogEntry]:
|
||||
return next((e for e in list_catalog() if e.name == name), None)
|
||||
|
||||
|
||||
# ─── Status helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def installed_servers() -> Dict[str, dict]:
|
||||
"""Return current ``mcp_servers`` block from config.yaml."""
|
||||
servers = load_config().get("mcp_servers") or {}
|
||||
@@ -366,9 +351,6 @@ def remove_server(name: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# ─── Install ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _say(msg: str, colour: str = Colors.GREEN) -> None:
|
||||
print(color(msg, colour))
|
||||
|
||||
@@ -533,8 +515,7 @@ def _apply_tool_selection(
|
||||
entry: CatalogEntry,
|
||||
*,
|
||||
prior_selection: Optional[List[str]],
|
||||
prior_exclude: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
prior_exclude: Optional[List[str]] = None) -> None:
|
||||
"""Probe the server and let the user pick which tools to enable.
|
||||
|
||||
Probe-success: curses checklist; pre-check priority *prior_selection* (reinstall) > manifest
|
||||
@@ -557,8 +538,7 @@ def _apply_tool_selection(
|
||||
_write_tools_filter(name, "exclude", entry.tools.default_excluded)
|
||||
_say(
|
||||
f" Applied manifest exclude list ({len(entry.tools.default_excluded)} entries); "
|
||||
f"everything else stays enabled. {edit_hint}"
|
||||
)
|
||||
f"everything else stays enabled. {edit_hint}")
|
||||
return
|
||||
|
||||
_say(f" Probing '{name}' for available tools...", Colors.CYAN)
|
||||
@@ -573,8 +553,7 @@ def _apply_tool_selection(
|
||||
_write_tools_filter(name, "include", prior_selection)
|
||||
msg = (
|
||||
f" Couldn't probe server. Kept your previous tool selection "
|
||||
f"({len(prior_selection)} tools). {refine_hint}"
|
||||
)
|
||||
f"({len(prior_selection)} tools). {refine_hint}")
|
||||
elif prior_exclude is not None:
|
||||
_write_tools_filter(name, "exclude", prior_exclude)
|
||||
msg = f" Couldn't probe server. Kept your existing exclude list ({len(prior_exclude)} entries)."
|
||||
@@ -582,14 +561,12 @@ def _apply_tool_selection(
|
||||
_write_tools_filter(name, "include", manifest_default)
|
||||
msg = (
|
||||
f" Couldn't probe server. Applied manifest default "
|
||||
f"({len(manifest_default)} tools). {refine_hint}"
|
||||
)
|
||||
f"({len(manifest_default)} tools). {refine_hint}")
|
||||
else:
|
||||
_write_tools_filter(name, "include", None)
|
||||
msg = (
|
||||
" Couldn't probe server; installed with no tool filter (all tools enabled when "
|
||||
f"reachable). Run {configure_hint} after first connect to prune."
|
||||
)
|
||||
f"reachable). Run {configure_hint} after first connect to prune.")
|
||||
_say(msg, Colors.YELLOW)
|
||||
return
|
||||
|
||||
@@ -604,7 +581,9 @@ def _apply_tool_selection(
|
||||
import sys as _sys
|
||||
if not _sys.stdin.isatty():
|
||||
preferred = prior_selection if prior_selection is not None else (entry.tools.default_enabled or None)
|
||||
_write_tools_filter(name, "include", None if preferred is None else [n for n in preferred if n in tool_names])
|
||||
_write_tools_filter(
|
||||
name, "include", None if preferred is None else [n for n in preferred if n in tool_names]
|
||||
)
|
||||
return
|
||||
|
||||
pre_set = {n for n in (prior_selection or entry.tools.default_enabled or tool_names) if n in tool_names}
|
||||
@@ -615,8 +594,7 @@ def _apply_tool_selection(
|
||||
|
||||
labels = [f"{n} — {(d[:60] + '...') if len(d) > 60 else d}" for n, d in probed]
|
||||
chosen_indices = curses_checklist(
|
||||
f"Select tools for '{name}' (SPACE toggle, ENTER confirm)", labels, pre_indices,
|
||||
)
|
||||
f"Select tools for '{name}' (SPACE toggle, ENTER confirm)", labels, pre_indices)
|
||||
if not chosen_indices:
|
||||
# Everything unchecked: write an empty include so the server is installed but contributes
|
||||
# nothing until reconfigured.
|
||||
@@ -629,8 +607,7 @@ def _apply_tool_selection(
|
||||
_write_tools_filter(name, "include", None)
|
||||
_say(
|
||||
f" ✓ All {len(probed)} tools enabled (no filter — new tools "
|
||||
"the server adds later will be auto-enabled)."
|
||||
)
|
||||
"the server adds later will be auto-enabled).")
|
||||
return
|
||||
chosen_names = [tool_names[i] for i in sorted(chosen_indices)]
|
||||
_write_tools_filter(name, "include", chosen_names)
|
||||
@@ -665,14 +642,12 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None:
|
||||
f" This MCP uses {entry.auth.provider} OAuth. Run "
|
||||
f"`hermes auth {entry.auth.provider}` if you have not "
|
||||
"already authenticated.",
|
||||
Colors.YELLOW,
|
||||
)
|
||||
Colors.YELLOW)
|
||||
elif entry.auth.type == "oauth":
|
||||
_say(
|
||||
" This MCP uses native OAuth 2.1; tokens will be acquired "
|
||||
"on first connection (browser flow).",
|
||||
Colors.DIM,
|
||||
)
|
||||
Colors.DIM)
|
||||
|
||||
# Read prior user selection BEFORE overwriting the entry so a reinstall preserves it.
|
||||
prior_selection = _read_prior_tool_list(entry.name, "include")
|
||||
@@ -692,8 +667,7 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None:
|
||||
_say(
|
||||
f" ✓ Installed '{entry.name}' "
|
||||
f"({'enabled' if enable else 'disabled'}). "
|
||||
f"Start a new Hermes session to load its tools."
|
||||
)
|
||||
f"Start a new Hermes session to load its tools.")
|
||||
if entry.post_install:
|
||||
print()
|
||||
for line in entry.post_install.strip().splitlines():
|
||||
@@ -702,7 +676,7 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None:
|
||||
|
||||
|
||||
def uninstall_entry(name: str, *, purge_install_dir: bool = True) -> bool:
|
||||
"""Remove a catalog-installed MCP from config and (optionally) its clone dir. True if anything was removed."""
|
||||
"""Remove a catalog-installed MCP from config and (optionally) its clone dir. True if anything removed."""
|
||||
removed = remove_server(name)
|
||||
if purge_install_dir:
|
||||
clone = _install_root() / name
|
||||
|
||||
+66
-81
@@ -28,19 +28,11 @@ _MCP_PRESETS: Dict[str, Dict[str, Any]] = {
|
||||
"codex": {"command": "codex", "args": ["mcp-server"]},
|
||||
}
|
||||
|
||||
# ─── UI Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _info(text: str):
|
||||
print(color(f" {text}", Colors.DIM))
|
||||
|
||||
def _success(text: str):
|
||||
print(color(f" ✓ {text}", Colors.GREEN))
|
||||
|
||||
def _warning(text: str):
|
||||
print(color(f" ⚠ {text}", Colors.YELLOW))
|
||||
|
||||
def _error(text: str):
|
||||
print(color(f" ✗ {text}", Colors.RED))
|
||||
def _info(text: str): print(color(f" {text}", Colors.DIM))
|
||||
def _success(text: str): print(color(f" ✓ {text}", Colors.GREEN))
|
||||
def _warning(text: str): print(color(f" ⚠ {text}", Colors.YELLOW))
|
||||
def _error(text: str): print(color(f" ✗ {text}", Colors.RED))
|
||||
|
||||
|
||||
def _confirm(question: str, default: bool = True) -> bool:
|
||||
@@ -59,8 +51,6 @@ def _print_tools(tools: List[Tuple[str, str]], width: int, desc_max: int) -> Non
|
||||
print(f" {color(tool_name, Colors.GREEN):{width}s} {short}")
|
||||
|
||||
|
||||
# ─── Config Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def _get_mcp_servers(config: Optional[dict] = None) -> Dict[str, dict]:
|
||||
"""Return the ``mcp_servers`` dict from config, or empty dict."""
|
||||
if config is None:
|
||||
@@ -77,8 +67,7 @@ def _tool_filters(cfg: dict) -> Tuple[Optional[list], Optional[list]]:
|
||||
include, exclude = tools_cfg.get("include"), tools_cfg.get("exclude")
|
||||
return (
|
||||
include if include and isinstance(include, list) else None,
|
||||
exclude if exclude and isinstance(exclude, list) else None,
|
||||
)
|
||||
exclude if exclude and isinstance(exclude, list) else None)
|
||||
|
||||
|
||||
def _save_mcp_server(name: str, server_config: dict) -> bool:
|
||||
@@ -215,8 +204,7 @@ def _apply_mcp_preset(
|
||||
url: Optional[str],
|
||||
command: Optional[str],
|
||||
cmd_args: List[str],
|
||||
server_config: Dict[str, Any],
|
||||
) -> tuple[Optional[str], Optional[str], List[str], bool]:
|
||||
server_config: Dict[str, Any]) -> tuple[Optional[str], Optional[str], List[str], bool]:
|
||||
"""Apply a known MCP preset when transport details were omitted."""
|
||||
if not preset_name:
|
||||
return url, command, cmd_args, False
|
||||
@@ -236,8 +224,6 @@ def _apply_mcp_preset(
|
||||
return url, command, cmd_args, True
|
||||
|
||||
|
||||
# ─── Discovery (temporary connect) ───────────────────────────────────────────
|
||||
|
||||
def _resolve_mcp_server_config(config: dict) -> dict:
|
||||
"""Resolve ``${ENV}`` placeholders in a server config before connecting.
|
||||
|
||||
@@ -269,8 +255,7 @@ def _probe_single_server(
|
||||
raise ValueError("; ".join(issues))
|
||||
|
||||
from tools.mcp_tool import (
|
||||
_ensure_mcp_loop, _run_on_mcp_loop, _connect_server, _stop_mcp_loop_if_idle, _parse_boolish,
|
||||
)
|
||||
_ensure_mcp_loop, _run_on_mcp_loop, _connect_server, _stop_mcp_loop_if_idle, _parse_boolish)
|
||||
|
||||
config = _resolve_mcp_server_config(config)
|
||||
if connect_timeout is None:
|
||||
@@ -299,10 +284,8 @@ def _probe_single_server(
|
||||
|
||||
details["schema_chars"] = {
|
||||
t.name: len(_json.dumps(
|
||||
_convert_mcp_schema(name, t), separators=(",", ":"), default=str,
|
||||
))
|
||||
for t in server._tools
|
||||
}
|
||||
_convert_mcp_schema(name, t), separators=(",", ":"), default=str))
|
||||
for t in server._tools}
|
||||
except Exception: # pragma: no cover — display-only extra
|
||||
pass
|
||||
# Gate capability probes like runtime registration (_select_utility_schemas):
|
||||
@@ -357,9 +340,9 @@ def _unwrap_exception_group(exc: BaseException) -> Exception:
|
||||
return exc if isinstance(exc, Exception) else RuntimeError(str(exc))
|
||||
|
||||
|
||||
# ─── hermes mcp add ──────────────────────────────────────────────────────────
|
||||
|
||||
def _configure_http_auth(name: str, url: str, auth_type: Optional[str], server_config: Dict[str, Any]) -> bool:
|
||||
def _configure_http_auth(
|
||||
name: str, url: str, auth_type: Optional[str], server_config: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""OAuth or Bearer-token setup for an HTTP server. False when the user cancelled."""
|
||||
print()
|
||||
if auth_type == "oauth":
|
||||
@@ -383,7 +366,8 @@ def _configure_http_auth(name: str, url: str, auth_type: Optional[str], server_c
|
||||
return True
|
||||
|
||||
_info(f"Connecting to {url}")
|
||||
if _confirm("Does this server require authentication?", default=True) and (auth_type == "header" or not auth_type):
|
||||
needs_auth = _confirm("Does this server require authentication?", default=True)
|
||||
if needs_auth and (auth_type == "header" or not auth_type):
|
||||
env_key = _env_key_for_server(name)
|
||||
if get_env_value(env_key):
|
||||
_success(f"{env_key}: already configured")
|
||||
@@ -405,7 +389,9 @@ def _choose_tools(name: str, tools: List[Tuple[str, str]], server_config: Dict[s
|
||||
_print_tools(tools, 40, 60)
|
||||
print()
|
||||
try:
|
||||
choice = input(color(f" Enable all {len(tools)} tools? [Y/n/select]: ", Colors.YELLOW)).strip().lower()
|
||||
choice = input(
|
||||
color(f" Enable all {len(tools)} tools? [Y/n/select]: ", Colors.YELLOW)
|
||||
).strip().lower()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
_info("Cancelled.")
|
||||
@@ -417,7 +403,8 @@ def _choose_tools(name: str, tools: List[Tuple[str, str]], server_config: Dict[s
|
||||
return len(tools)
|
||||
from hermes_cli.curses_ui import curses_checklist
|
||||
|
||||
chosen = curses_checklist(f"Select tools for '{name}'", [f"{t[0]} — {t[1]}" for t in tools], set(range(len(tools))))
|
||||
labels = [f"{t[0]} — {t[1]}" for t in tools]
|
||||
chosen = curses_checklist(f"Select tools for '{name}'", labels, set(range(len(tools))))
|
||||
if not chosen:
|
||||
_info("No tools selected — server not saved.")
|
||||
return None
|
||||
@@ -443,8 +430,7 @@ def cmd_mcp_add(args):
|
||||
explicit_env = _parse_env_assignments(getattr(args, "env", None))
|
||||
url, command, cmd_args, _preset_applied = _apply_mcp_preset(
|
||||
name, preset_name=getattr(args, "preset", None), url=url, command=command,
|
||||
cmd_args=list(cmd_args), server_config=server_config,
|
||||
)
|
||||
cmd_args=list(cmd_args), server_config=server_config)
|
||||
except ValueError as exc:
|
||||
_error(str(exc))
|
||||
return
|
||||
@@ -460,7 +446,9 @@ def cmd_mcp_add(args):
|
||||
_info(' hermes mcp add myserver --preset mypreset')
|
||||
return
|
||||
|
||||
if name in _get_mcp_servers() and not _confirm(f"Server '{name}' already exists. Overwrite?", default=False):
|
||||
if name in _get_mcp_servers() and not _confirm(
|
||||
f"Server '{name}' already exists. Overwrite?", default=False
|
||||
):
|
||||
_info("Cancelled.")
|
||||
return
|
||||
|
||||
@@ -505,12 +493,12 @@ def cmd_mcp_add(args):
|
||||
server_config["enabled"] = True
|
||||
if _save_mcp_server(name, server_config):
|
||||
print()
|
||||
_success(f"Saved '{name}' to {display_hermes_home()}/config.yaml ({tool_count}/{len(tools)} tools enabled)")
|
||||
_success(
|
||||
f"Saved '{name}' to {display_hermes_home()}/config.yaml ({tool_count}/{len(tools)} tools enabled)"
|
||||
)
|
||||
_info("Start a new session to use these tools.")
|
||||
|
||||
|
||||
# ─── hermes mcp remove ───────────────────────────────────────────────────────
|
||||
|
||||
def cmd_mcp_remove(args):
|
||||
"""Remove an MCP server from config."""
|
||||
name = args.name
|
||||
@@ -531,8 +519,6 @@ def cmd_mcp_remove(args):
|
||||
pass
|
||||
|
||||
|
||||
# ─── hermes mcp list ──────────────────────────────────────────────────────────
|
||||
|
||||
def cmd_mcp_list(args=None):
|
||||
"""List all configured MCP servers."""
|
||||
servers = _get_mcp_servers()
|
||||
@@ -566,7 +552,12 @@ def cmd_mcp_list(args=None):
|
||||
transport = transport[:25] + "..."
|
||||
|
||||
include, exclude = _tool_filters(cfg)
|
||||
tools_str = f"{len(include)} selected" if include else f"-{len(exclude)} excluded" if exclude else "all"
|
||||
if include:
|
||||
tools_str = f"{len(include)} selected"
|
||||
elif exclude:
|
||||
tools_str = f"-{len(exclude)} excluded"
|
||||
else:
|
||||
tools_str = "all"
|
||||
|
||||
enabled = cfg.get("enabled", True)
|
||||
if isinstance(enabled, str):
|
||||
@@ -576,8 +567,6 @@ def cmd_mcp_list(args=None):
|
||||
print()
|
||||
|
||||
|
||||
# ─── hermes mcp test ──────────────────────────────────────────────────────────
|
||||
|
||||
def cmd_mcp_test(args):
|
||||
"""Test connection to an MCP server."""
|
||||
name = args.name
|
||||
@@ -618,8 +607,6 @@ def cmd_mcp_test(args):
|
||||
print()
|
||||
|
||||
|
||||
# ─── hermes mcp login ────────────────────────────────────────────────────────
|
||||
|
||||
def _reauth_oauth_server(name: str, server_config: dict) -> bool:
|
||||
"""Force a fresh OAuth flow for one server. Returns True on success.
|
||||
|
||||
@@ -657,7 +644,9 @@ def _reauth_oauth_server(name: str, server_config: dict) -> bool:
|
||||
except (TypeError, ValueError):
|
||||
_login_connect_timeout = 0.0
|
||||
with force_interactive_oauth():
|
||||
tools = _probe_single_server(name, server_config, connect_timeout=max(_login_connect_timeout, 315.0))
|
||||
tools = _probe_single_server(
|
||||
name, server_config, connect_timeout=max(_login_connect_timeout, 315.0)
|
||||
)
|
||||
# A clean probe is NOT proof of authentication: some servers (e.g. Google Drive) serve
|
||||
# initialize + tools/list without auth, so the flow may have failed (e.g. DCR 400 for
|
||||
# providers without RFC 7591) while the probe still lists tools. Verify a token landed.
|
||||
@@ -667,12 +656,12 @@ def _reauth_oauth_server(name: str, server_config: dict) -> bool:
|
||||
_info(
|
||||
"Some providers (e.g. Google Drive, Atlassian) do not support "
|
||||
"automatic client registration. For those you must create an "
|
||||
"OAuth client yourself and add its credentials to config.yaml:"
|
||||
)
|
||||
"OAuth client yourself and add its credentials to config.yaml:")
|
||||
print()
|
||||
for line in (
|
||||
"mcp_servers:", f" {name}:", f" url: {url}", " auth: oauth", " oauth:",
|
||||
' client_id: "<your-oauth-client-id>"', ' client_secret: "<your-oauth-client-secret>"',
|
||||
' client_id: "<your-oauth-client-id>"',
|
||||
' client_secret: "<your-oauth-client-secret>"',
|
||||
):
|
||||
print(color(f" {line}", Colors.DIM))
|
||||
print()
|
||||
@@ -732,9 +721,9 @@ def cmd_mcp_reauth(args):
|
||||
_reauth_oauth_server(name, cfg)
|
||||
|
||||
|
||||
# ─── hermes mcp configure ────────────────────────────────────────────────────
|
||||
|
||||
def _rebuild_exclude_list(name: str, exclude: list, tool_names: List[str], chosen: set, matches_name_filter) -> List[str]:
|
||||
def _rebuild_exclude_list(
|
||||
name: str, exclude: list, tool_names: List[str], chosen: set, matches_name_filter
|
||||
) -> List[str]:
|
||||
"""New ``tools.exclude`` for an exclude-mode entry after a checklist edit.
|
||||
|
||||
Stays in exclude mode rather than demoting the user's dynamic filter to a frozen include list:
|
||||
@@ -747,11 +736,12 @@ def _rebuild_exclude_list(name: str, exclude: list, tool_names: List[str], chose
|
||||
unchecked = {tool_names[i] for i in range(len(tool_names)) if i not in chosen}
|
||||
checked = {tool_names[i] for i in chosen}
|
||||
new_literals = (literal_entries - checked) | {
|
||||
tn for tn in unchecked if not matches_name_filter(tn, set(old_exclude))
|
||||
}
|
||||
tn for tn in unchecked if not matches_name_filter(tn, set(old_exclude))}
|
||||
# A re-checked tool still matched by a kept glob can't be enabled without dropping the glob —
|
||||
# surface that instead of silently ignoring the click or silently freezing the config.
|
||||
glob_shadowed = sorted(tn for tn in checked if glob_entries and matches_name_filter(tn, set(glob_entries)))
|
||||
glob_shadowed = sorted(
|
||||
tn for tn in checked if glob_entries and matches_name_filter(tn, set(glob_entries))
|
||||
)
|
||||
if glob_shadowed:
|
||||
_warning(
|
||||
f"{len(glob_shadowed)} re-enabled tool(s) still match glob "
|
||||
@@ -759,8 +749,7 @@ def _rebuild_exclude_list(name: str, exclude: list, tool_names: List[str], chose
|
||||
f"{', '.join(glob_shadowed[:5])}"
|
||||
f"{' ...' if len(glob_shadowed) > 5 else ''}. Remove the "
|
||||
f"pattern from mcp_servers.{name}.tools.exclude in "
|
||||
"config.yaml to enable them."
|
||||
)
|
||||
"config.yaml to enable them.")
|
||||
return glob_entries + sorted(new_literals)
|
||||
|
||||
|
||||
@@ -797,12 +786,11 @@ def cmd_mcp_configure(args):
|
||||
def matches_name_filter(tool_name, patterns):
|
||||
return tool_name in patterns
|
||||
|
||||
if include:
|
||||
include_set = {str(p) for p in include}
|
||||
pre_selected = {i for i, tn in enumerate(tool_names) if matches_name_filter(tn, include_set)}
|
||||
elif exclude:
|
||||
exclude_set = {str(p) for p in exclude}
|
||||
pre_selected = {i for i, tn in enumerate(tool_names) if not matches_name_filter(tn, exclude_set)}
|
||||
patterns = {str(p) for p in (include or exclude or [])}
|
||||
if patterns:
|
||||
pre_selected = {
|
||||
i for i, tn in enumerate(tool_names) if matches_name_filter(tn, patterns) == bool(include)
|
||||
}
|
||||
else:
|
||||
pre_selected = set(range(total))
|
||||
|
||||
@@ -811,7 +799,8 @@ def cmd_mcp_configure(args):
|
||||
|
||||
from hermes_cli.curses_ui import curses_checklist
|
||||
|
||||
chosen = curses_checklist(f"Select tools for '{name}'", [f"{t[0]} — {t[1]}" for t in all_tools], pre_selected)
|
||||
labels = [f"{t[0]} — {t[1]}" for t in all_tools]
|
||||
chosen = curses_checklist(f"Select tools for '{name}'", labels, pre_selected)
|
||||
if chosen == pre_selected:
|
||||
_info("No changes made.")
|
||||
return
|
||||
@@ -841,8 +830,6 @@ def cmd_mcp_configure(args):
|
||||
_info("Start a new session for changes to take effect.")
|
||||
|
||||
|
||||
# ─── Dispatcher ───────────────────────────────────────────────────────────────
|
||||
|
||||
_MCP_USAGE = (
|
||||
"hermes mcp Open the catalog picker (default)",
|
||||
"hermes mcp catalog List Nous-approved MCPs",
|
||||
@@ -867,21 +854,19 @@ def mcp_command(args):
|
||||
from mcp_serve import run_mcp_server
|
||||
run_mcp_server(verbose=getattr(args, "verbose", False))
|
||||
return
|
||||
# Catalog subcommands live in mcp_picker / mcp_catalog; import lazily to keep this module cheap.
|
||||
if action == "picker":
|
||||
from hermes_cli.mcp_picker import run_picker
|
||||
run_picker()
|
||||
return
|
||||
if action == "catalog":
|
||||
from hermes_cli.mcp_picker import show_catalog
|
||||
show_catalog()
|
||||
return
|
||||
if action == "install":
|
||||
from hermes_cli.mcp_picker import install_by_name
|
||||
import sys as _sys
|
||||
rc = install_by_name(getattr(args, "identifier", "") or "")
|
||||
if rc:
|
||||
_sys.exit(rc)
|
||||
if action in ("picker", "catalog", "install"):
|
||||
# Catalog subcommands live in mcp_picker / mcp_catalog; import lazily to keep this module cheap.
|
||||
from hermes_cli import mcp_picker
|
||||
|
||||
if action == "picker":
|
||||
mcp_picker.run_picker()
|
||||
elif action == "catalog":
|
||||
mcp_picker.show_catalog()
|
||||
else:
|
||||
import sys as _sys
|
||||
rc = mcp_picker.install_by_name(getattr(args, "identifier", "") or "")
|
||||
if rc:
|
||||
_sys.exit(rc)
|
||||
return
|
||||
handler = {
|
||||
"add": cmd_mcp_add, "remove": cmd_mcp_remove, "rm": cmd_mcp_remove, "list": cmd_mcp_list,
|
||||
|
||||
@@ -79,8 +79,7 @@ def _enable_disable(name: str, *, enable: bool) -> None:
|
||||
print(color(
|
||||
f" ✓ '{name}' {'enabled' if enable else 'disabled'}. "
|
||||
"Start a new Hermes session for changes to take effect.",
|
||||
Colors.GREEN,
|
||||
))
|
||||
Colors.GREEN))
|
||||
|
||||
|
||||
def _configure_tools(name: str) -> None:
|
||||
@@ -119,8 +118,7 @@ def _uninstall(name: str) -> None:
|
||||
print(color(
|
||||
f" ✓ Uninstalled '{name}'. "
|
||||
"Credentials in .env preserved — delete manually if no longer needed.",
|
||||
Colors.GREEN,
|
||||
))
|
||||
Colors.GREEN))
|
||||
else:
|
||||
print(color(f" '{name}' was not installed", Colors.DIM))
|
||||
|
||||
@@ -146,8 +144,7 @@ def _handle_row(row: _Row) -> None:
|
||||
("Configure tools (probe server + re-pick)", lambda: _configure_tools(row.name)),
|
||||
("Enable" if not enabled else "Disable",
|
||||
lambda: _enable_disable(row.name, enable=not is_enabled(row.name))),
|
||||
("Remove from config", lambda: _remove_custom(row.name)),
|
||||
])
|
||||
("Remove from config", lambda: _remove_custom(row.name))])
|
||||
return
|
||||
# Catalog row, installed + enabled
|
||||
print()
|
||||
@@ -158,8 +155,7 @@ def _handle_row(row: _Row) -> None:
|
||||
lambda: _enable_disable(row.name, enable=False)),
|
||||
("Uninstall (remove config and any cloned files)", lambda: _uninstall(row.name)),
|
||||
("Reinstall (re-clone, re-prompt for credentials)",
|
||||
lambda: _install(row.entry, "reinstall")),
|
||||
])
|
||||
lambda: _install(row.entry, "reinstall"))])
|
||||
|
||||
|
||||
def _print_rows_text(rows: List[_Row]) -> None:
|
||||
@@ -186,8 +182,7 @@ def _print_rows_text(rows: List[_Row]) -> None:
|
||||
print(color(
|
||||
f" ⚠ '{name}' requires a newer Hermes — run `hermes update` "
|
||||
"to install this entry.",
|
||||
Colors.YELLOW,
|
||||
))
|
||||
Colors.YELLOW))
|
||||
print()
|
||||
print()
|
||||
|
||||
@@ -206,7 +201,8 @@ def run_picker() -> None:
|
||||
_print_rows_text(rows) # non-interactive: degrade to the text dump
|
||||
return
|
||||
idx = curses_single_select(
|
||||
"MCP Catalog — ↑↓ navigate ENTER act on entry ESC/q quit", [_format_row(r) for r in rows],
|
||||
"MCP Catalog — ↑↓ navigate ENTER act on entry ESC/q quit",
|
||||
[_format_row(r) for r in rows],
|
||||
)
|
||||
if idx is None:
|
||||
return
|
||||
@@ -222,7 +218,6 @@ def install_by_name(identifier: str) -> int:
|
||||
print(color(
|
||||
f" ✗ '{identifier}' is not in the catalog. "
|
||||
"Run `hermes mcp catalog` to see available entries.",
|
||||
Colors.RED,
|
||||
))
|
||||
Colors.RED))
|
||||
return 1
|
||||
return 0 if _install(entry, "install") else 1
|
||||
|
||||
@@ -103,8 +103,7 @@ def validate_mcp_server_entry(name: str, entry: dict[str, Any]) -> list[str]:
|
||||
# One IOC is enough to refuse; don't leak the full match list.
|
||||
issues.append(
|
||||
f"MCP server '{name}' contains a known hermes-0day "
|
||||
f"indicator-of-compromise ('{ioc}')"
|
||||
)
|
||||
f"indicator-of-compromise ('{ioc}')")
|
||||
return issues
|
||||
|
||||
command = entry.get("command")
|
||||
@@ -117,8 +116,7 @@ def validate_mcp_server_entry(name: str, entry: dict[str, Any]) -> list[str]:
|
||||
if _EGRESS_PATTERN.search(script):
|
||||
issue = (
|
||||
f"MCP server '{name}' uses shell interpreter '{command}' with "
|
||||
f"network egress in args"
|
||||
)
|
||||
f"network egress in args")
|
||||
if _EXFIL_HINT_PATTERN.search(script):
|
||||
issue += " and exfiltration-shaped arguments"
|
||||
issues.append(issue)
|
||||
@@ -127,6 +125,5 @@ def validate_mcp_server_entry(name: str, entry: dict[str, Any]) -> list[str]:
|
||||
f"MCP server '{name}' uses shell interpreter '{command}' to write "
|
||||
f"to an OS persistence surface (SSH keys / PAM / sudoers / cron / "
|
||||
f"shell rc) — this is the hermes-0day backdoor shape, not a real "
|
||||
f"MCP server"
|
||||
)
|
||||
f"MCP server")
|
||||
return issues
|
||||
|
||||
@@ -55,8 +55,7 @@ def start_background_mcp_discovery(*, logger, thread_name: str) -> None:
|
||||
return
|
||||
logger.warning(
|
||||
"Background MCP discovery previously exited with no connected "
|
||||
"servers; retrying discovery thread"
|
||||
)
|
||||
"servers; retrying discovery thread")
|
||||
_mcp_discovery_started = False
|
||||
_mcp_discovery_thread = None
|
||||
|
||||
@@ -201,8 +200,7 @@ def ensure_mcp_discovery_before_agent_build(
|
||||
logger,
|
||||
timeout: "float | None" = None,
|
||||
single_query: bool = False,
|
||||
thread_name: str = "cli-mcp-discovery",
|
||||
) -> None:
|
||||
thread_name: str = "cli-mcp-discovery") -> None:
|
||||
"""Give configured MCP tools a bounded chance to register before AIAgent.
|
||||
|
||||
Non-interactive first turns (``chat -q``, ``hermes -z``) can construct ``AIAgent`` before any
|
||||
|
||||
@@ -49,7 +49,7 @@ def _scope_to_profile(profile: Optional[str]):
|
||||
|
||||
@router.post("/{provider}/oauth/start")
|
||||
async def start_memory_oauth(provider: str, profile: Optional[str] = None):
|
||||
"""Begin a provider's zero-CLI OAuth flow (browser + loopback listener). Returns immediately; poll status."""
|
||||
"""Begin a provider's zero-CLI OAuth flow (browser + loopback listener); returns immediately, poll status."""
|
||||
flow = _resolve_flow(provider)
|
||||
try:
|
||||
# The flow resolves its config path eagerly inside this scope; its worker thread outlives it.
|
||||
|
||||
@@ -17,8 +17,7 @@ _IMPORT_NAMES = {
|
||||
"honcho-ai": "honcho",
|
||||
"mem0ai": "mem0",
|
||||
"hindsight-client": "hindsight_client",
|
||||
"hindsight-all": "hindsight",
|
||||
}
|
||||
"hindsight-all": "hindsight"}
|
||||
|
||||
|
||||
def _provider_pip_dependencies(provider_name: str, declared: list) -> list:
|
||||
@@ -41,16 +40,8 @@ def _provider_pip_dependencies(provider_name: str, declared: list) -> list:
|
||||
return deps
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Curses-based interactive picker (same pattern as hermes tools)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _curses_select(
|
||||
title: str,
|
||||
items: list[tuple[str, str]],
|
||||
default: int = 0,
|
||||
*,
|
||||
cancel_returns: int | None = None,
|
||||
title: str, items: list[tuple[str, str]], default: int = 0, *, cancel_returns: int | None = None
|
||||
) -> int:
|
||||
"""Interactive single-select with arrow keys."""
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
@@ -87,10 +78,6 @@ def _prompt(label: str, default: str | None = None, secret: bool = False) -> str
|
||||
return val or (default or "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _install_dependencies(provider_name: str, *, force: bool = False) -> None:
|
||||
"""Install pip dependencies declared in ``plugin.yaml``.
|
||||
|
||||
@@ -207,10 +194,6 @@ def _find_provider(providers: list, provider_name: str):
|
||||
return next((p for p in providers if p[0] == provider_name), None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setup wizard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _post_setup_hook(provider, config: dict) -> bool:
|
||||
"""Normalize the ``memory`` block; True when the provider's ``post_setup`` took over (it owns
|
||||
config, connection test and activation), so the caller must stop."""
|
||||
@@ -271,7 +254,9 @@ def _prompt_schema_fields(name: str, schema: list, provider_config: dict, env_wr
|
||||
if choices and not is_secret:
|
||||
current = provider_config.get(key, default)
|
||||
current_idx = choices.index(current) if current and current in choices else 0
|
||||
sel = _curses_select(f" {desc}", [(c, "") for c in choices], default=current_idx, cancel_returns=_CANCELLED)
|
||||
sel = _curses_select(
|
||||
f" {desc}", [(c, "") for c in choices], default=current_idx, cancel_returns=_CANCELLED
|
||||
)
|
||||
if sel == _CANCELLED:
|
||||
_print_cancelled_setup()
|
||||
return False
|
||||
@@ -360,9 +345,7 @@ def cmd_setup(args) -> None:
|
||||
|
||||
|
||||
def _write_env_vars(
|
||||
env_writes: dict,
|
||||
hermes_home: str | os.PathLike[str] | None = None,
|
||||
) -> None:
|
||||
env_writes: dict, hermes_home: str | os.PathLike[str] | None = None) -> None:
|
||||
"""Persist memory-provider env vars through the canonical ``.env`` writer.
|
||||
|
||||
``save_env_value`` applies the shared gate (name regex, ``LD_PRELOAD``/``PYTHONPATH``/``HERMES_HOME``
|
||||
@@ -385,10 +368,6 @@ def _write_env_vars(
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mark(enabled) -> str:
|
||||
return "enabled ✓" if enabled else "disabled ✗"
|
||||
|
||||
@@ -466,10 +445,6 @@ def cmd_status(args) -> None:
|
||||
print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def memory_command(args) -> None:
|
||||
"""Route memory subcommands."""
|
||||
if getattr(args, "memory_command", None) == "setup":
|
||||
|
||||
+18
-23
@@ -60,7 +60,9 @@ def _safe_copy(payload: Any) -> Any:
|
||||
return dict(payload) if isinstance(payload, dict) else payload
|
||||
|
||||
|
||||
def _apply_request_chain(kind: str, payload_key: str, trace: List[Dict[str, Any]], **kwargs: Any) -> Dict[str, Any]:
|
||||
def _apply_request_chain(
|
||||
kind: str, payload_key: str, trace: List[Dict[str, Any]], **kwargs: Any
|
||||
) -> Dict[str, Any]:
|
||||
"""Feed ``kwargs[payload_key]`` through every ``kind`` middleware; each may return ``{payload_key: {...}}``."""
|
||||
from hermes_cli.plugins import invoke_middleware
|
||||
|
||||
@@ -75,8 +77,7 @@ def _apply_request_chain(kind: str, payload_key: str, trace: List[Dict[str, Any]
|
||||
entry = {
|
||||
key: value
|
||||
for key in ("source", "reason", "name")
|
||||
if isinstance(value := result.get(key), str) and value
|
||||
}
|
||||
if isinstance(value := result.get(key), str) and value}
|
||||
trace.append(entry or {"source": "plugin"})
|
||||
return current
|
||||
|
||||
@@ -92,14 +93,15 @@ def apply_llm_request_middleware(request: Dict[str, Any], **context: Any) -> Req
|
||||
trace: List[Dict[str, Any]] = []
|
||||
current_request = _apply_request_chain(
|
||||
LLM_REQUEST_MIDDLEWARE, "request", trace,
|
||||
request=_safe_copy(original_request), original_request=original_request, **context,
|
||||
)
|
||||
request=_safe_copy(original_request), original_request=original_request, **context)
|
||||
return RequestMiddlewareResult(
|
||||
payload=current_request, original_payload=original_request, changed=bool(trace), trace=trace,
|
||||
)
|
||||
|
||||
|
||||
def apply_tool_request_middleware(tool_name: str, args: Dict[str, Any], **context: Any) -> RequestMiddlewareResult:
|
||||
def apply_tool_request_middleware(
|
||||
tool_name: str, args: Dict[str, Any], **context: Any
|
||||
) -> RequestMiddlewareResult:
|
||||
"""Apply registered tool request middleware; ``{"args": {...}}`` replaces the effective tool
|
||||
arguments before hooks, guardrails, approvals, and execution see them."""
|
||||
original_args = _safe_copy(args)
|
||||
@@ -112,8 +114,7 @@ def apply_tool_request_middleware(tool_name: str, args: Dict[str, Any], **contex
|
||||
from agent import relay_runtime
|
||||
|
||||
relay_args = relay_runtime.apply_tool_request_intercepts(
|
||||
session_id=session_id, tool_name=tool_name, args=current_args,
|
||||
)
|
||||
session_id=session_id, tool_name=tool_name, args=current_args)
|
||||
if relay_args != current_args:
|
||||
current_args = _safe_copy(relay_args)
|
||||
trace.append({"source": "nemo_relay"})
|
||||
@@ -122,25 +123,22 @@ def apply_tool_request_middleware(tool_name: str, args: Dict[str, Any], **contex
|
||||
|
||||
if not has_middleware(TOOL_REQUEST_MIDDLEWARE):
|
||||
return RequestMiddlewareResult(
|
||||
payload=args if not trace else current_args, original_payload=args, changed=bool(trace), trace=trace,
|
||||
payload=args if not trace else current_args, original_payload=args,
|
||||
changed=bool(trace), trace=trace,
|
||||
)
|
||||
current_args = _apply_request_chain(
|
||||
TOOL_REQUEST_MIDDLEWARE, "args", trace,
|
||||
tool_name=tool_name, args=current_args, original_args=original_args, **context,
|
||||
)
|
||||
tool_name=tool_name, args=current_args, original_args=original_args, **context)
|
||||
return RequestMiddlewareResult(
|
||||
payload=current_args, original_payload=original_args, changed=bool(trace), trace=trace,
|
||||
)
|
||||
payload=current_args, original_payload=original_args, changed=bool(trace), trace=trace)
|
||||
|
||||
|
||||
def run_llm_execution_middleware(
|
||||
request: Dict[str, Any], next_call: Callable[[Dict[str, Any]], Any], **context: Any,
|
||||
) -> Any:
|
||||
request: Dict[str, Any], next_call: Callable[[Dict[str, Any]], Any], **context: Any) -> Any:
|
||||
"""Run provider execution through registered LLM execution middleware."""
|
||||
return _run_execution_chain(
|
||||
LLM_EXECUTION_MIDDLEWARE, next_call,
|
||||
request=request, original_request=context.pop("original_request", request), **context,
|
||||
)
|
||||
request=request, original_request=context.pop("original_request", request), **context)
|
||||
|
||||
|
||||
def run_tool_execution_middleware(
|
||||
@@ -149,8 +147,7 @@ def run_tool_execution_middleware(
|
||||
"""Run tool execution through registered tool execution middleware."""
|
||||
return _run_execution_chain(
|
||||
TOOL_EXECUTION_MIDDLEWARE, next_call,
|
||||
tool_name=tool_name, args=args, original_args=context.pop("original_args", args), **context,
|
||||
)
|
||||
tool_name=tool_name, args=args, original_args=context.pop("original_args", args), **context)
|
||||
|
||||
|
||||
class _DownstreamExecutionError(Exception):
|
||||
@@ -187,8 +184,7 @@ def _run_execution_chain(kind: str, terminal_call: Callable[[Any], Any], **kwarg
|
||||
raise RuntimeError(
|
||||
f"Middleware '{kind}' callback "
|
||||
f"{getattr(callback, '__name__', repr(callback))} called "
|
||||
"next_call() more than once; downstream execution is single-use"
|
||||
)
|
||||
"next_call() more than once; downstream execution is single-use")
|
||||
next_called = True
|
||||
try:
|
||||
next_result = call_at(index + 1, payload if next_payload is None else next_payload)
|
||||
@@ -207,8 +203,7 @@ def _run_execution_chain(kind: str, terminal_call: Callable[[Any], Any], **kwarg
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Middleware '%s' callback %s raised: %s",
|
||||
kind, getattr(callback, "__name__", repr(callback)), exc,
|
||||
)
|
||||
kind, getattr(callback, "__name__", repr(callback)), exc)
|
||||
if next_succeeded:
|
||||
return next_result
|
||||
if next_called:
|
||||
|
||||
Reference in New Issue
Block a user