256 lines
9.3 KiB
Python
256 lines
9.3 KiB
Python
"""Skill readiness: required env vars, secret capture, and setup notes.
|
|
|
|
Split out of ``tools.skills_tool``; every name is re-imported there so
|
|
``from tools.skills_tool import X`` / ``patch("tools.skills_tool.X")`` keep
|
|
working. Module state (``_secret_capture_callback``, ``load_env``) stays in
|
|
``tools.skills_tool`` and is read lazily at call time so test patches on the
|
|
origin module are honored.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
from enum import Enum
|
|
from typing import Any, Dict, List, Tuple
|
|
|
|
from hermes_constants import display_hermes_home
|
|
from utils import env_var_enabled
|
|
|
|
logger = logging.getLogger("tools.skills_tool")
|
|
|
|
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
_REMOTE_ENV_BACKENDS = frozenset({"docker", "singularity", "modal", "ssh", "daytona", "vercel_sandbox"})
|
|
|
|
|
|
class SkillReadinessStatus(str, Enum):
|
|
AVAILABLE = "available"
|
|
SETUP_NEEDED = "setup_needed"
|
|
UNSUPPORTED = "unsupported"
|
|
|
|
|
|
def _is_remote_env_backend(backend: str) -> bool:
|
|
"""Built-in remote backends plus plugin backends declaring is_remote."""
|
|
if backend in _REMOTE_ENV_BACKENDS:
|
|
return True
|
|
if not backend or backend == "local":
|
|
return False
|
|
try:
|
|
from agent.terminal_env_registry import provider_flag
|
|
|
|
return bool(provider_flag(backend, "is_remote", False))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _normalize_prerequisite_values(value: Any) -> List[str]:
|
|
if not value:
|
|
return []
|
|
if isinstance(value, str):
|
|
value = [value]
|
|
return [str(item) for item in value if str(item).strip()]
|
|
|
|
|
|
def _collect_prerequisite_values(frontmatter: Dict[str, Any]) -> Tuple[List[str], List[str]]:
|
|
prereqs = frontmatter.get("prerequisites")
|
|
if not prereqs or not isinstance(prereqs, dict):
|
|
return [], []
|
|
return (
|
|
_normalize_prerequisite_values(prereqs.get("env_vars")),
|
|
_normalize_prerequisite_values(prereqs.get("commands")),
|
|
)
|
|
|
|
|
|
def _as_dict_list(raw: Any) -> list:
|
|
"""Accept a single mapping or a list; anything else is treated as empty."""
|
|
if isinstance(raw, dict):
|
|
return [raw]
|
|
return raw if isinstance(raw, list) else []
|
|
|
|
|
|
def _clean_str(value: Any) -> str | None:
|
|
"""Stripped string when *value* is a non-blank str, else None."""
|
|
return value.strip() if isinstance(value, str) and value.strip() else None
|
|
|
|
|
|
def _normalize_setup_metadata(frontmatter: Dict[str, Any]) -> Dict[str, Any]:
|
|
setup = frontmatter.get("setup")
|
|
if not isinstance(setup, dict):
|
|
return {"help": None, "collect_secrets": []}
|
|
collect_secrets: List[Dict[str, Any]] = []
|
|
for item in _as_dict_list(setup.get("collect_secrets")):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
env_var = str(item.get("env_var") or "").strip()
|
|
if not env_var:
|
|
continue
|
|
entry: Dict[str, Any] = {
|
|
"env_var": env_var,
|
|
"prompt": str(item.get("prompt") or f"Enter value for {env_var}").strip(),
|
|
"secret": bool(item.get("secret", True)),
|
|
}
|
|
provider_url = str(item.get("provider_url") or item.get("url") or "").strip()
|
|
if provider_url:
|
|
entry["provider_url"] = provider_url
|
|
collect_secrets.append(entry)
|
|
return {"help": _clean_str(setup.get("help")), "collect_secrets": collect_secrets}
|
|
|
|
|
|
def _get_required_environment_variables(
|
|
frontmatter: Dict[str, Any],
|
|
legacy_env_vars: List[str] | None = None,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Merge required_environment_variables, setup.collect_secrets and legacy
|
|
prerequisites.env_vars into one deduped, validated list (first entry wins)."""
|
|
setup = _normalize_setup_metadata(frontmatter)
|
|
required: List[Dict[str, Any]] = []
|
|
seen: set[str] = set()
|
|
|
|
def _append_required(entry: Dict[str, Any]) -> None:
|
|
env_name = str(entry.get("name") or entry.get("env_var") or "").strip()
|
|
if not env_name or env_name in seen or not _ENV_VAR_NAME_RE.match(env_name):
|
|
return
|
|
normalized: Dict[str, Any] = {
|
|
"name": env_name,
|
|
"prompt": str(entry.get("prompt") or f"Enter value for {env_name}").strip(),
|
|
}
|
|
help_text = _clean_str(
|
|
entry.get("help") or entry.get("provider_url") or entry.get("url") or setup.get("help")
|
|
)
|
|
if help_text:
|
|
normalized["help"] = help_text
|
|
required_for = _clean_str(entry.get("required_for"))
|
|
if required_for:
|
|
normalized["required_for"] = required_for
|
|
if entry.get("optional"):
|
|
normalized["optional"] = True
|
|
seen.add(env_name)
|
|
required.append(normalized)
|
|
|
|
for item in _as_dict_list(frontmatter.get("required_environment_variables")):
|
|
if isinstance(item, str):
|
|
_append_required({"name": item})
|
|
elif isinstance(item, dict):
|
|
_append_required(item)
|
|
for item in setup["collect_secrets"]:
|
|
_append_required({
|
|
"name": item.get("env_var"),
|
|
"prompt": item.get("prompt"),
|
|
"help": item.get("provider_url") or setup.get("help"),
|
|
})
|
|
if legacy_env_vars is None:
|
|
legacy_env_vars, _ = _collect_prerequisite_values(frontmatter)
|
|
for env_var in legacy_env_vars:
|
|
_append_required({"name": env_var})
|
|
return required
|
|
|
|
|
|
def _capture_result(missing_names, setup_skipped=False, gateway_setup_hint=None):
|
|
return {
|
|
"missing_names": missing_names,
|
|
"setup_skipped": setup_skipped,
|
|
"gateway_setup_hint": gateway_setup_hint,
|
|
}
|
|
|
|
|
|
def _capture_required_environment_variables(
|
|
skill_name: str,
|
|
missing_entries: List[Dict[str, Any]],
|
|
) -> Dict[str, Any]:
|
|
"""Prompt for missing secrets via the registered capture callback (if any)."""
|
|
from tools import skills_tool as _st
|
|
|
|
if not missing_entries:
|
|
return _capture_result([])
|
|
missing_names = [entry["name"] for entry in missing_entries]
|
|
# Most gateway surfaces (messaging platforms) can't prompt for a secret, so
|
|
# they short-circuit to the "unsupported" hint. Interactive gateway surfaces
|
|
# (desktop app / TUI) set HERMES_INTERACTIVE — the same flag tools/approval.py
|
|
# uses — and register a callback routing to a secure secret.request overlay,
|
|
# so they fall through and actually prompt.
|
|
if _is_gateway_surface() and not env_var_enabled("HERMES_INTERACTIVE"):
|
|
return _capture_result(missing_names, gateway_setup_hint=_gateway_setup_hint())
|
|
callback = _st._secret_capture_callback
|
|
if callback is None:
|
|
return _capture_result(missing_names)
|
|
|
|
setup_skipped = False
|
|
remaining_names: List[str] = []
|
|
for entry in missing_entries:
|
|
metadata = {"skill_name": skill_name}
|
|
for k in ("help", "required_for"):
|
|
if entry.get(k):
|
|
metadata[k] = entry[k]
|
|
try:
|
|
callback_result = callback(entry["name"], entry["prompt"], metadata)
|
|
except Exception:
|
|
logger.warning(f"Secret capture callback failed for {entry['name']}", exc_info=True)
|
|
callback_result = {"success": False, "stored_as": entry["name"], "validated": False, "skipped": True}
|
|
ok = isinstance(callback_result, dict)
|
|
if ok and callback_result.get("success") and not callback_result.get("skipped"):
|
|
continue
|
|
setup_skipped = True
|
|
remaining_names.append(entry["name"])
|
|
return _capture_result(remaining_names, setup_skipped)
|
|
|
|
|
|
def _is_gateway_surface() -> bool:
|
|
if env_var_enabled("HERMES_GATEWAY_SESSION"):
|
|
return True
|
|
from gateway.session_context import get_session_env
|
|
return bool(get_session_env("HERMES_SESSION_PLATFORM"))
|
|
|
|
|
|
def _get_terminal_backend_name() -> str:
|
|
return str(os.getenv("TERMINAL_ENV", "local")).strip().lower() or "local"
|
|
|
|
|
|
def _env_snapshot_or_load(env_snapshot):
|
|
if env_snapshot is None:
|
|
from tools import skills_tool as _st
|
|
|
|
return _st.load_env()
|
|
return env_snapshot
|
|
|
|
|
|
def _is_env_var_persisted(var_name: str, env_snapshot: Dict[str, str] | None = None) -> bool:
|
|
env_snapshot = _env_snapshot_or_load(env_snapshot)
|
|
if var_name in env_snapshot:
|
|
return bool(env_snapshot.get(var_name))
|
|
return bool(os.getenv(var_name))
|
|
|
|
|
|
def _remaining_required_environment_names(
|
|
required_env_vars: List[Dict[str, Any]],
|
|
capture_result: Dict[str, Any],
|
|
*,
|
|
env_snapshot: Dict[str, str] | None = None,
|
|
) -> List[str]:
|
|
missing_names = set(capture_result["missing_names"])
|
|
env_snapshot = _env_snapshot_or_load(env_snapshot)
|
|
return [
|
|
e["name"]
|
|
for e in required_env_vars
|
|
if not e.get("optional")
|
|
and (e["name"] in missing_names or not _is_env_var_persisted(e["name"], env_snapshot))
|
|
]
|
|
|
|
|
|
def _gateway_setup_hint() -> str:
|
|
try:
|
|
from gateway.platforms.base import GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE
|
|
|
|
return GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE
|
|
except Exception:
|
|
return f"Secure secret entry is not available. Load this skill in the local CLI to be prompted, or add the key to {display_hermes_home()}/.env manually."
|
|
|
|
|
|
def _build_setup_note(
|
|
readiness_status: SkillReadinessStatus,
|
|
missing: List[str],
|
|
setup_help: str | None = None,
|
|
) -> str | None:
|
|
if readiness_status != SkillReadinessStatus.SETUP_NEEDED:
|
|
return None
|
|
note = f"Setup needed before using this skill: missing {', '.join(missing) if missing else 'required prerequisites'}."
|
|
return f"{note} {setup_help}" if setup_help else note
|