opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config); Ink stays the default and the shipping path is untouched. - _resolve_tui_engine() (env > config > ink); refuses opentui on Windows/Termux (no Bun) -> falls back to ink with a notice. - _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step). - _bun_bin() with HERMES_BUN override. - Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host must not bootstrap Node). - Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun is JSC; the V8 flag errors/ignores). Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI -> real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
+2
-52
@@ -151,13 +151,7 @@ def _is_gateway_approval_context() -> bool:
|
||||
return bool(_get_session_platform())
|
||||
|
||||
# Sensitive write targets that should trigger approval even when referenced
|
||||
# via shell expansions like $HOME or $HERMES_HOME, or by the resolved absolute
|
||||
# active profile home path such as /home/hermes/.hermes/config.yaml. The
|
||||
# resolved-absolute form is folded into the ~/.hermes/ patterns at detection
|
||||
# time by _normalize_command_for_detection() — see the rewrite step there — so
|
||||
# these static patterns stay free of any import-time path snapshot (which would
|
||||
# go stale when HERMES_HOME is set after this module is imported, e.g. under the
|
||||
# hermetic test conftest or any deferred-profile-resolution path).
|
||||
# via shell expansions like $HOME or $HERMES_HOME.
|
||||
_SSH_SENSITIVE_PATH = r'(?:~|\$home|\$\{home\})/\.ssh(?:/|$)'
|
||||
_HERMES_ENV_PATH = (
|
||||
r'(?:~\/\.hermes/|'
|
||||
@@ -545,49 +539,8 @@ def _normalize_command_for_detection(command: str) -> str:
|
||||
command = unicodedata.normalize('NFKC', command)
|
||||
# Strip shell backslash-escapes: r\m → rm. Prevents \-injection bypass.
|
||||
command = re.sub(r'\\([^\n])', r'\1', command)
|
||||
# Strip empty-string literals that split tokens: r''m → rm, r"\"m → rm.
|
||||
# Strip empty-string literals that split tokens: r''m → rm, r""m → rm.
|
||||
command = re.sub(r"''|\"\"", '', command)
|
||||
# Fold the resolved absolute active-profile home path into the canonical
|
||||
# ~/.hermes/ form so the Hermes config/env patterns catch it. In Docker and
|
||||
# gateway deployments the agent often references the resolved absolute path
|
||||
# directly (e.g. `sed -i ... /home/hermes/.hermes/config.yaml`) rather than
|
||||
# ~, $HOME, or $HERMES_HOME. Done at detection time (not via an import-time
|
||||
# pattern snapshot) so it tracks the live HERMES_HOME even when that is set
|
||||
# after this module is imported — as the hermetic test conftest does.
|
||||
command = _rewrite_resolved_hermes_home(command)
|
||||
return command
|
||||
|
||||
|
||||
def _rewrite_resolved_hermes_home(command: str) -> str:
|
||||
"""Rewrite the resolved absolute Hermes home prefix to ``~/.hermes/``.
|
||||
|
||||
Resolves the active ``HERMES_HOME`` at call time (and its symlink-resolved
|
||||
form) and replaces an occurrence of ``<home>/`` in *command* with
|
||||
``~/.hermes/`` so the static ``_HERMES_CONFIG_PATH`` / ``_HERMES_ENV_PATH``
|
||||
patterns match. No-op when the path can't be resolved or doesn't appear.
|
||||
"""
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
home = get_hermes_home().expanduser()
|
||||
candidates = [
|
||||
str(home).rstrip("/"),
|
||||
str(home.resolve(strict=False)).rstrip("/"),
|
||||
]
|
||||
except Exception:
|
||||
return command
|
||||
seen: set[str] = set()
|
||||
for path in candidates:
|
||||
if not path or path in seen:
|
||||
continue
|
||||
seen.add(path)
|
||||
# Guard against a degenerate HERMES_HOME (e.g. "/" or "") rewriting
|
||||
# unrelated paths: require an absolute path with at least one non-root
|
||||
# component. The active profile home is always a real directory like
|
||||
# /home/hermes/.hermes or a per-test tempdir, never a bare root.
|
||||
normalized = path.rstrip("/")
|
||||
if not normalized.startswith("/") or normalized.count("/") < 2:
|
||||
continue
|
||||
command = command.replace(normalized + "/", "~/.hermes/")
|
||||
return command
|
||||
|
||||
|
||||
@@ -1428,9 +1381,6 @@ def check_all_command_guards(command: str, env_type: str,
|
||||
"pattern_key": primary_key,
|
||||
"pattern_keys": all_keys,
|
||||
"description": combined_desc,
|
||||
# Mirror the CLI's allow_permanent gate: a tirith warning downgrades
|
||||
# "always" to session scope below, so the UI must not offer it.
|
||||
"allow_permanent": not has_tirith,
|
||||
}
|
||||
decision = _await_gateway_decision(
|
||||
session_key, notify_cb, approval_data, surface="gateway"
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
"""Blueprints: shareable plain-language automations layered on skills + cron.
|
||||
|
||||
A "blueprint" is NOT a new object type. It is an ordinary skill (a SKILL.md the
|
||||
agent loads) that additionally declares an automation schedule in its
|
||||
frontmatter:
|
||||
|
||||
metadata:
|
||||
hermes:
|
||||
blueprint:
|
||||
schedule: "0 9 * * *" # presence of `blueprint:` marks it runnable
|
||||
deliver: origin # optional (default "origin")
|
||||
prompt: "..." # optional task instruction for the run
|
||||
no_agent: false # optional
|
||||
|
||||
Because a blueprint is just a skill, it flows through the ENTIRE existing
|
||||
skills-hub pipeline for free — search, inspect, quarantine, security scan,
|
||||
install, lock-file provenance, audit log, taps, the centralized index, and
|
||||
`hermes skills publish` for sharing. No new source type, no new store, no new
|
||||
transport. This module is the thin bridge between that skill metadata and the
|
||||
existing cron `create_job()` API:
|
||||
|
||||
* ``parse_blueprint(skill_md_text)`` -> BlueprintSpec | None
|
||||
* ``blueprint_spec_for_installed(name)`` -> BlueprintSpec | None
|
||||
* ``create_blueprint_job(spec, ...)`` -> the created cron job dict
|
||||
* ``export_blueprint(job, body)`` -> a shareable SKILL.md string
|
||||
|
||||
The dev guide's "Extend, Don't Duplicate" rule is the whole design: the blueprint
|
||||
is a skill, the schedule is a cron job, sharing is the existing publish/tap/
|
||||
index path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"BlueprintSpec",
|
||||
"parse_blueprint",
|
||||
"blueprint_spec_for_installed",
|
||||
"blueprint_to_job_spec",
|
||||
"create_blueprint_job",
|
||||
"register_blueprint_suggestion",
|
||||
"export_blueprint",
|
||||
"BlueprintError",
|
||||
]
|
||||
|
||||
|
||||
class BlueprintError(ValueError):
|
||||
"""Raised when a blueprint block is present but malformed."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlueprintSpec:
|
||||
"""Parsed ``metadata.hermes.blueprint`` automation spec for a skill."""
|
||||
|
||||
skill_name: str
|
||||
schedule: str
|
||||
deliver: str = "origin"
|
||||
prompt: Optional[str] = None
|
||||
no_agent: bool = False
|
||||
model: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
enabled_toolsets: Optional[List[str]] = None
|
||||
raw: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _split_frontmatter(text: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return the parsed YAML frontmatter mapping, or None if absent/invalid."""
|
||||
if not isinstance(text, str):
|
||||
return None
|
||||
stripped = text.lstrip()
|
||||
if not stripped.startswith("---"):
|
||||
return None
|
||||
# Find the closing fence after the opening one.
|
||||
after_open = stripped[3:]
|
||||
end = after_open.find("\n---")
|
||||
if end == -1:
|
||||
return None
|
||||
fm_text = after_open[:end]
|
||||
try:
|
||||
import yaml
|
||||
|
||||
data = yaml.safe_load(fm_text)
|
||||
except Exception as e: # pragma: no cover - malformed YAML
|
||||
logger.debug("blueprint: frontmatter YAML parse failed: %s", e)
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def parse_blueprint(skill_md_text: str) -> Optional[BlueprintSpec]:
|
||||
"""Extract a BlueprintSpec from a SKILL.md string, or None if not a blueprint.
|
||||
|
||||
A skill is a blueprint iff ``metadata.hermes.blueprint`` is a mapping containing
|
||||
a non-empty ``schedule``. Raises BlueprintError if the block exists but is
|
||||
structurally invalid (so a typo surfaces instead of silently no-op'ing).
|
||||
"""
|
||||
fm = _split_frontmatter(skill_md_text)
|
||||
if not fm:
|
||||
return None
|
||||
|
||||
name = str(fm.get("name", "")).strip()
|
||||
|
||||
meta = fm.get("metadata")
|
||||
hermes = meta.get("hermes") if isinstance(meta, dict) else None
|
||||
blueprint = hermes.get("blueprint") if isinstance(hermes, dict) else None
|
||||
if blueprint is None:
|
||||
return None
|
||||
if not isinstance(blueprint, dict):
|
||||
raise BlueprintError("metadata.hermes.blueprint must be a mapping")
|
||||
|
||||
schedule = str(blueprint.get("schedule", "")).strip()
|
||||
if not schedule:
|
||||
raise BlueprintError("blueprint.schedule is required and must be non-empty")
|
||||
|
||||
deliver = str(blueprint.get("deliver", "origin")).strip() or "origin"
|
||||
prompt = blueprint.get("prompt")
|
||||
if prompt is not None:
|
||||
prompt = str(prompt)
|
||||
no_agent = bool(blueprint.get("no_agent", False))
|
||||
model = blueprint.get("model")
|
||||
provider = blueprint.get("provider")
|
||||
toolsets = blueprint.get("enabled_toolsets")
|
||||
if toolsets is not None and not isinstance(toolsets, list):
|
||||
raise BlueprintError("blueprint.enabled_toolsets must be a list when present")
|
||||
|
||||
return BlueprintSpec(
|
||||
skill_name=name,
|
||||
schedule=schedule,
|
||||
deliver=deliver,
|
||||
prompt=prompt,
|
||||
no_agent=no_agent,
|
||||
model=str(model).strip() if model else None,
|
||||
provider=str(provider).strip() if provider else None,
|
||||
enabled_toolsets=[str(t) for t in toolsets] if toolsets else None,
|
||||
raw=blueprint,
|
||||
)
|
||||
|
||||
|
||||
def blueprint_spec_for_installed(skill_name: str) -> Optional[BlueprintSpec]:
|
||||
"""Locate an installed skill's SKILL.md and parse its blueprint block.
|
||||
|
||||
Searches the standard skills tree for ``<skill_name>/SKILL.md``. Returns
|
||||
None if the skill isn't found or isn't a blueprint.
|
||||
"""
|
||||
try:
|
||||
from tools.skills_hub import SKILLS_DIR
|
||||
except Exception: # pragma: no cover - import guard
|
||||
return None
|
||||
|
||||
base = Path(SKILLS_DIR)
|
||||
# Skills live at skills/<category>/<name>/SKILL.md or skills/<name>/SKILL.md.
|
||||
candidates = list(base.glob(f"**/{skill_name}/SKILL.md"))
|
||||
for path in candidates:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
spec = parse_blueprint(text)
|
||||
if spec is not None:
|
||||
# Prefer the frontmatter name, fall back to the directory name.
|
||||
if not spec.skill_name:
|
||||
spec.skill_name = skill_name
|
||||
return spec
|
||||
return None
|
||||
|
||||
|
||||
def blueprint_to_job_spec(
|
||||
spec: BlueprintSpec,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the ``cron.jobs.create_job`` kwargs dict for a BlueprintSpec.
|
||||
|
||||
This is the single source of truth for translating a blueprint into a job.
|
||||
Both the direct ``create_blueprint_job`` path and the suggestion path
|
||||
(``register_blueprint_suggestion``) build on it, so a blueprint scheduled now and
|
||||
a blueprint accepted from a suggestion produce an identical job.
|
||||
"""
|
||||
return {
|
||||
"prompt": spec.prompt,
|
||||
"schedule": spec.schedule,
|
||||
"name": name or f"blueprint:{spec.skill_name}",
|
||||
"deliver": spec.deliver,
|
||||
"skills": [spec.skill_name] if spec.skill_name else None,
|
||||
"model": spec.model,
|
||||
"provider": spec.provider,
|
||||
"enabled_toolsets": spec.enabled_toolsets,
|
||||
"no_agent": spec.no_agent,
|
||||
}
|
||||
|
||||
|
||||
def create_blueprint_job(
|
||||
spec: BlueprintSpec,
|
||||
*,
|
||||
origin: Optional[Dict[str, Any]] = None,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create the cron job described by a BlueprintSpec via the existing cron API.
|
||||
|
||||
The blueprint's skill is loaded before the run (cron ``skills=[name]``); the
|
||||
optional ``prompt`` becomes the task instruction. Delivery, model, and
|
||||
toolsets carry through. Returns the created job dict.
|
||||
"""
|
||||
from cron.jobs import create_job
|
||||
|
||||
job_spec = blueprint_to_job_spec(spec, name=name)
|
||||
if origin is not None:
|
||||
job_spec["origin"] = origin
|
||||
return create_job(**job_spec)
|
||||
|
||||
|
||||
def register_blueprint_suggestion(spec: BlueprintSpec) -> Optional[Dict[str, Any]]:
|
||||
"""Turn an installed blueprint into a pending Suggested Cron Job.
|
||||
|
||||
Blueprints are source ``blueprint`` of the unified suggestion surface: installing
|
||||
a skill that carries a ``blueprint:`` block does NOT auto-schedule it — it
|
||||
registers a suggestion the user accepts (or dismisses) like any other.
|
||||
Returns the suggestion record, or None if it was skipped (already
|
||||
seen/dismissed, backlog full, etc.).
|
||||
"""
|
||||
if not spec.skill_name:
|
||||
return None
|
||||
try:
|
||||
from cron.suggestions import add_suggestion
|
||||
except Exception: # pragma: no cover - import guard
|
||||
return None
|
||||
|
||||
return add_suggestion(
|
||||
title=f"Schedule '{spec.skill_name}'",
|
||||
description=(
|
||||
f"The '{spec.skill_name}' blueprint runs on schedule {spec.schedule}"
|
||||
+ (f", delivering to {spec.deliver}" if spec.deliver and spec.deliver != "origin" else "")
|
||||
+ "."
|
||||
),
|
||||
source="blueprint",
|
||||
job_spec=blueprint_to_job_spec(spec),
|
||||
dedup_key=f"blueprint:{spec.skill_name}:{spec.schedule}",
|
||||
)
|
||||
|
||||
|
||||
def export_blueprint(job: Dict[str, Any], body: str, *, blueprint_name: Optional[str] = None) -> str:
|
||||
"""Render a shareable blueprint SKILL.md from an existing cron job dict.
|
||||
|
||||
The inverse of ``create_blueprint_job``: take a cron job a user already built
|
||||
and emit a SKILL.md (with a ``metadata.hermes.blueprint`` block) they can hand
|
||||
to ``hermes skills publish`` to share. ``body`` is the plain-language
|
||||
description / instructions that become the SKILL.md body.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
name = blueprint_name or job.get("name") or "shared-blueprint"
|
||||
# Sanitize to a valid skill identifier.
|
||||
name = "".join(c if (c.isalnum() or c in "-_") else "-" for c in str(name).lower())
|
||||
name = name.strip("-_") or "shared-blueprint"
|
||||
|
||||
schedule = job.get("schedule_display") or _schedule_to_string(job.get("schedule"))
|
||||
skills = job.get("skills") or ([job["skill"]] if job.get("skill") else [])
|
||||
|
||||
blueprint_block: Dict[str, Any] = {"schedule": schedule}
|
||||
deliver = job.get("deliver")
|
||||
if deliver and deliver != "origin":
|
||||
blueprint_block["deliver"] = deliver
|
||||
if job.get("prompt"):
|
||||
blueprint_block["prompt"] = job["prompt"]
|
||||
if job.get("no_agent"):
|
||||
blueprint_block["no_agent"] = True
|
||||
if job.get("model"):
|
||||
blueprint_block["model"] = job["model"]
|
||||
if job.get("provider"):
|
||||
blueprint_block["provider"] = job["provider"]
|
||||
if job.get("enabled_toolsets"):
|
||||
blueprint_block["enabled_toolsets"] = job["enabled_toolsets"]
|
||||
|
||||
description = (
|
||||
(body.strip().splitlines() or ["Shared automation blueprint."])[0][:200]
|
||||
if body.strip()
|
||||
else "Shared automation blueprint."
|
||||
)
|
||||
|
||||
frontmatter = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"metadata": {
|
||||
"hermes": {
|
||||
"tags": ["blueprint", "automation"],
|
||||
"blueprint": blueprint_block,
|
||||
}
|
||||
},
|
||||
}
|
||||
fm_yaml = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True).strip()
|
||||
body_text = body.strip() or f"# {name}\n\nShared automation blueprint."
|
||||
return f"---\n{fm_yaml}\n---\n\n{body_text}\n"
|
||||
|
||||
|
||||
def _schedule_to_string(schedule: Any) -> str:
|
||||
"""Best-effort render of a parsed schedule dict back to a string."""
|
||||
if isinstance(schedule, str):
|
||||
return schedule
|
||||
if isinstance(schedule, dict):
|
||||
kind = schedule.get("kind")
|
||||
if kind == "cron" and schedule.get("expr"):
|
||||
return str(schedule["expr"])
|
||||
if kind == "interval":
|
||||
# parse_schedule stores interval periods as "minutes"; tolerate a
|
||||
# legacy/foreign "seconds" form too.
|
||||
if schedule.get("minutes"):
|
||||
mins = int(schedule["minutes"])
|
||||
if mins % 60 == 0:
|
||||
return f"every {mins // 60}h"
|
||||
return f"every {mins}m"
|
||||
if schedule.get("seconds"):
|
||||
secs = int(schedule["seconds"])
|
||||
if secs % 3600 == 0:
|
||||
return f"every {secs // 3600}h"
|
||||
if secs % 60 == 0:
|
||||
return f"every {secs // 60}m"
|
||||
return f"every {secs}s"
|
||||
return "0 9 * * *" # safe daily fallback
|
||||
@@ -307,7 +307,6 @@ def _run_git(
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
cwd=str(normalized_working_dir),
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
ok = result.returncode == 0
|
||||
stdout = result.stdout.strip()
|
||||
@@ -427,7 +426,6 @@ def _init_store(store: Path, working_dir: str) -> Optional[str]:
|
||||
["git", "init", "--bare", str(store)],
|
||||
capture_output=True, text=True,
|
||||
env=init_env, timeout=_GIT_TIMEOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return f"Shadow store init failed: {result.stderr.strip()}"
|
||||
|
||||
@@ -1618,7 +1618,6 @@ def _is_usable_python(python_path: str) -> bool:
|
||||
timeout=5,
|
||||
capture_output=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
|
||||
@@ -32,12 +32,10 @@ For captures / actions with `capture_after=True`:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
@@ -431,61 +429,6 @@ _DEFAULT_MAX_ELEMENTS = 100
|
||||
# call passing a very large integer would silently disable the safeguard and
|
||||
# reintroduce the original unbounded behavior.
|
||||
_MAX_ALLOWED_MAX_ELEMENTS = 1000
|
||||
_MIN_PROVIDER_IMAGE_DIMENSION = 8
|
||||
|
||||
|
||||
def _image_dimensions_from_b64(image_b64: str) -> Optional[Tuple[int, int]]:
|
||||
"""Return (width, height) for common inline screenshot formats.
|
||||
|
||||
Some providers reject images below 8x8 before the model sees the tool
|
||||
result. Inspecting the encoded bytes here lets computer_use fall back to
|
||||
its AX/SOM text payload instead of sending an unusable placeholder.
|
||||
"""
|
||||
if not image_b64:
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(image_b64, validate=False)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# PNG: signature + IHDR width/height.
|
||||
if raw.startswith(b"\x89PNG\r\n\x1a\n") and len(raw) >= 24:
|
||||
try:
|
||||
width, height = struct.unpack(">II", raw[16:24])
|
||||
return int(width), int(height)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# JPEG: scan for SOF markers that carry dimensions.
|
||||
if raw.startswith(b"\xff\xd8") and len(raw) > 4:
|
||||
i = 2
|
||||
while i + 9 < len(raw):
|
||||
if raw[i] != 0xFF:
|
||||
i += 1
|
||||
continue
|
||||
marker = raw[i + 1]
|
||||
i += 2
|
||||
while marker == 0xFF and i < len(raw):
|
||||
marker = raw[i]
|
||||
i += 1
|
||||
if marker in {0xD8, 0xD9}:
|
||||
continue
|
||||
if marker == 0xDA:
|
||||
break
|
||||
if i + 2 > len(raw):
|
||||
break
|
||||
segment_len = int.from_bytes(raw[i:i + 2], "big")
|
||||
if segment_len < 2 or i + segment_len > len(raw):
|
||||
break
|
||||
if marker in {
|
||||
0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
|
||||
0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF,
|
||||
} and segment_len >= 7:
|
||||
height = int.from_bytes(raw[i + 3:i + 5], "big")
|
||||
width = int.from_bytes(raw[i + 5:i + 7], "big")
|
||||
return int(width), int(height)
|
||||
i += segment_len
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_max_elements(value: Any) -> int:
|
||||
@@ -514,16 +457,6 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME
|
||||
total_elements = len(cap.elements)
|
||||
visible_elements = cap.elements[:max_elements]
|
||||
truncated_elements = max(0, total_elements - len(visible_elements))
|
||||
image_dimensions = _image_dimensions_from_b64(cap.png_b64 or "") if cap.png_b64 else None
|
||||
response_width = image_dimensions[0] if image_dimensions else cap.width
|
||||
response_height = image_dimensions[1] if image_dimensions else cap.height
|
||||
image_too_small = bool(
|
||||
image_dimensions
|
||||
and (
|
||||
image_dimensions[0] < _MIN_PROVIDER_IMAGE_DIMENSION
|
||||
or image_dimensions[1] < _MIN_PROVIDER_IMAGE_DIMENSION
|
||||
)
|
||||
)
|
||||
|
||||
# Index only what's actually surfaced in the response — otherwise the
|
||||
# human-readable summary references element indices the model cannot
|
||||
@@ -531,7 +464,7 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME
|
||||
# 40-line index window).
|
||||
element_index = _format_elements(visible_elements)
|
||||
summary_lines = [
|
||||
f"capture mode={cap.mode} {response_width}x{response_height}"
|
||||
f"capture mode={cap.mode} {cap.width}x{cap.height}"
|
||||
+ (f" app={cap.app}" if cap.app else "")
|
||||
+ (f" window={cap.window_title!r}" if cap.window_title else ""),
|
||||
f"{total_elements} interactable element(s):",
|
||||
@@ -543,15 +476,9 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME
|
||||
# selected) has a valid value to hand to _route_capture_through_aux_vision.
|
||||
# The AX path appends the "truncated to N of M" note to summary_lines
|
||||
# below and rebuilds; the multimodal path keeps this version untouched.
|
||||
if image_too_small:
|
||||
summary_lines.append(
|
||||
f" (screenshot omitted: {image_dimensions[0]}x{image_dimensions[1]} "
|
||||
f"is below the {_MIN_PROVIDER_IMAGE_DIMENSION}x{_MIN_PROVIDER_IMAGE_DIMENSION} "
|
||||
"provider minimum)"
|
||||
)
|
||||
summary = "\n".join(summary_lines)
|
||||
|
||||
if cap.png_b64 and cap.mode != "ax" and not image_too_small:
|
||||
if cap.png_b64 and cap.mode != "ax":
|
||||
# Decide whether to hand the screenshot to the auxiliary.vision
|
||||
# pipeline (text-only result) or keep the multimodal envelope (main
|
||||
# model handles vision natively). Issue #24015: previously the
|
||||
@@ -583,7 +510,7 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME
|
||||
"image_url": {"url": f"data:{_mime};base64,{cap.png_b64}"}},
|
||||
],
|
||||
"text_summary": summary,
|
||||
"meta": {"mode": cap.mode, "width": response_width, "height": response_height,
|
||||
"meta": {"mode": cap.mode, "width": cap.width, "height": cap.height,
|
||||
"elements": total_elements, "png_bytes": cap.png_bytes_len},
|
||||
}
|
||||
# AX-only (or image-missing fallback): text path actually carries the
|
||||
@@ -596,8 +523,8 @@ def _capture_response(cap: CaptureResult, max_elements: int = _DEFAULT_MAX_ELEME
|
||||
summary = "\n".join(summary_lines)
|
||||
payload: Dict[str, Any] = {
|
||||
"mode": cap.mode,
|
||||
"width": response_width,
|
||||
"height": response_height,
|
||||
"width": cap.width,
|
||||
"height": cap.height,
|
||||
"app": cap.app,
|
||||
"window_title": cap.window_title,
|
||||
"elements": [_element_to_dict(e) for e in visible_elements],
|
||||
|
||||
+20
-15
@@ -326,23 +326,15 @@ def _resolve_model_override(model_obj: Optional[Dict[str, Any]]) -> tuple:
|
||||
return (None, None)
|
||||
model_name = (model_obj.get("model") or "").strip() or None
|
||||
provider_name = (model_obj.get("provider") or "").strip() or None
|
||||
# Bare "custom" is usually an incomplete spec — the canonical form is
|
||||
# "custom:<name>" matching a custom_providers entry, and LLMs frequently
|
||||
# Bare "custom" is an incomplete spec — the canonical form is
|
||||
# "custom:<name>" matching a custom_providers entry. LLMs frequently
|
||||
# supply the bare type because the schema does not advertise the
|
||||
# ":<name>" suffix. It is only a problem when it can't resolve at runtime:
|
||||
# a user may literally name a ``providers.custom`` (or custom_providers
|
||||
# "custom") entry, in which case the job should keep ``provider="custom"``
|
||||
# and run against that endpoint. Only when no such entry exists do we treat
|
||||
# the bare value as "no provider supplied" and pin the current main
|
||||
# provider below — otherwise pinning to ``model.provider`` (e.g. codex)
|
||||
# silently hijacks a job that meant to use the configured custom endpoint.
|
||||
# ":<name>" suffix, which used to bypass the pinning path below and
|
||||
# leave the job stored with an unresolvable "custom" provider. Treat
|
||||
# the bare value as "no provider supplied" so the current main
|
||||
# provider gets pinned instead.
|
||||
if provider_name == "custom":
|
||||
try:
|
||||
from hermes_cli.runtime_provider import has_named_custom_provider
|
||||
if not has_named_custom_provider("custom"):
|
||||
provider_name = None
|
||||
except Exception:
|
||||
provider_name = None
|
||||
provider_name = None
|
||||
if model_name and not provider_name:
|
||||
# Pin to the current main provider so the job is stable
|
||||
try:
|
||||
@@ -459,6 +451,8 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
|
||||
result["enabled_toolsets"] = job["enabled_toolsets"]
|
||||
if job.get("workdir"):
|
||||
result["workdir"] = job["workdir"]
|
||||
if job.get("profile"):
|
||||
result["profile"] = job["profile"]
|
||||
return result
|
||||
|
||||
|
||||
@@ -481,6 +475,7 @@ def cronjob(
|
||||
context_from: Optional[Union[str, List[str]]] = None,
|
||||
enabled_toolsets: Optional[List[str]] = None,
|
||||
workdir: Optional[str] = None,
|
||||
profile: Optional[str] = None,
|
||||
no_agent: Optional[bool] = None,
|
||||
task_id: str = None,
|
||||
) -> str:
|
||||
@@ -547,6 +542,7 @@ def cronjob(
|
||||
context_from=context_from,
|
||||
enabled_toolsets=enabled_toolsets or None,
|
||||
workdir=_normalize_optional_job_value(workdir),
|
||||
profile=_normalize_optional_job_value(profile),
|
||||
no_agent=_no_agent,
|
||||
)
|
||||
return json.dumps(
|
||||
@@ -681,6 +677,10 @@ def cronjob(
|
||||
# Empty string clears the field (restores old behaviour);
|
||||
# otherwise pass raw — update_job() validates / normalizes.
|
||||
updates["workdir"] = _normalize_optional_job_value(workdir) or None
|
||||
if profile is not None:
|
||||
# Empty string clears the field (restores old behaviour);
|
||||
# otherwise pass raw — update_job() validates / normalizes.
|
||||
updates["profile"] = _normalize_optional_job_value(profile) or None
|
||||
if no_agent is not None:
|
||||
# Toggling no_agent on/off at update time. If flipping to True,
|
||||
# we need a script to already exist on the job (or be part of
|
||||
@@ -834,6 +834,10 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
|
||||
"type": "string",
|
||||
"description": "Optional absolute path to run the job from. When set, AGENTS.md / CLAUDE.md / .cursorrules from that directory are injected into the system prompt, and the terminal/file/code_exec tools use it as their working directory — useful for running a job inside a specific project repo. Must be an absolute path that exists. When unset (default), preserves the original behaviour: no project context files, tools use the scheduler's cwd. On update, pass an empty string to clear. Jobs with workdir run sequentially (not parallel) to keep per-job directories isolated."
|
||||
},
|
||||
"profile": {
|
||||
"type": "string",
|
||||
"description": "Optional Hermes profile name to run the job under. When set, the scheduler resolves that profile, applies a context-local Hermes home override, loads that profile's config/.env for the run, and bridges HERMES_HOME into subprocesses. Any temporary process-environment changes from profile .env loading are restored after the job exits. Use 'default' for the root Hermes profile. Named profiles must already exist. When unset (default), preserves the scheduler's existing profile. On update, pass an empty string to clear. Jobs with profile run sequentially (not parallel) to keep profile-scoped runtime state isolated."
|
||||
},
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
@@ -888,6 +892,7 @@ registry.register(
|
||||
context_from=args.get("context_from"),
|
||||
enabled_toolsets=args.get("enabled_toolsets"),
|
||||
workdir=args.get("workdir"),
|
||||
profile=args.get("profile"),
|
||||
no_agent=args.get("no_agent"),
|
||||
task_id=kw.get("task_id"),
|
||||
))(),
|
||||
|
||||
@@ -65,7 +65,6 @@ def _run(cmd: list[str], timeout: float = 3.0) -> tuple[int, str, str]:
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
return result.returncode, (result.stdout or "").strip(), (result.stderr or "").strip()
|
||||
except FileNotFoundError:
|
||||
|
||||
@@ -177,7 +177,6 @@ def reap_orphan_containers(
|
||||
listing = subprocess.run(
|
||||
[docker, "ps", "-a", *filters, "--format", "{{.ID}}"],
|
||||
capture_output=True, text=True, timeout=15, check=False,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.debug("orphan reaper docker ps failed: %s", e)
|
||||
@@ -211,7 +210,6 @@ def reap_orphan_containers(
|
||||
result = subprocess.run(
|
||||
[docker, "rm", "-f", cid],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
removed += 1
|
||||
@@ -241,7 +239,6 @@ def _container_finished_at(docker_exe: str, container_id: str):
|
||||
result = subprocess.run(
|
||||
[docker_exe, "inspect", "--format", "{{.State.FinishedAt}}", container_id],
|
||||
capture_output=True, text=True, timeout=10, check=False,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.debug("orphan reaper docker inspect %s failed: %s", container_id[:12], e)
|
||||
@@ -384,7 +381,6 @@ def _image_uses_init_entrypoint(docker_exe: str, image: str) -> bool:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.debug("Docker: could not inspect entrypoint for %s: %s", image, e)
|
||||
@@ -457,7 +453,6 @@ def _ensure_docker_available() -> None:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logger.error(
|
||||
@@ -838,7 +833,6 @@ class DockerEnvironment(BaseEnvironment):
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
logger.warning(
|
||||
@@ -877,7 +871,6 @@ class DockerEnvironment(BaseEnvironment):
|
||||
text=True,
|
||||
timeout=120, # image pull may take a while
|
||||
check=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
# Docker may create the container object before `docker run`
|
||||
@@ -894,7 +887,6 @@ class DockerEnvironment(BaseEnvironment):
|
||||
subprocess.run(
|
||||
[self._docker_exe, "rm", "-f", container_name],
|
||||
capture_output=True, timeout=10,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
raise
|
||||
self._container_id = result.stdout.strip()
|
||||
@@ -1005,7 +997,6 @@ class DockerEnvironment(BaseEnvironment):
|
||||
subprocess.run(
|
||||
[self._docker_exe, "start", cid],
|
||||
capture_output=True, text=True, timeout=30, check=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
self._container_id = cid
|
||||
logger.info("Recovery: restarted container %s", cid[:12])
|
||||
@@ -1036,7 +1027,6 @@ class DockerEnvironment(BaseEnvironment):
|
||||
]
|
||||
result = subprocess.run(
|
||||
run_cmd, capture_output=True, text=True, timeout=120, check=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
self._container_id = result.stdout.strip()
|
||||
self._container_name = new_name
|
||||
@@ -1091,7 +1081,6 @@ class DockerEnvironment(BaseEnvironment):
|
||||
result = subprocess.run(
|
||||
[docker, "info", "--format", "{{.Driver}}"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
driver = result.stdout.strip().lower()
|
||||
if driver != "overlay2":
|
||||
@@ -1102,15 +1091,13 @@ class DockerEnvironment(BaseEnvironment):
|
||||
probe = subprocess.run(
|
||||
[docker, "create", "--storage-opt", "size=1m", "hello-world"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
# Clean up the created container
|
||||
container_id = probe.stdout.strip()
|
||||
if container_id:
|
||||
subprocess.run([docker, "rm", container_id],
|
||||
capture_output=True, timeout=5,
|
||||
stdin=subprocess.DEVNULL)
|
||||
capture_output=True, timeout=5)
|
||||
_storage_opt_ok = True
|
||||
else:
|
||||
_storage_opt_ok = False
|
||||
@@ -1145,7 +1132,6 @@ class DockerEnvironment(BaseEnvironment):
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.debug("docker ps probe failed: %s — will start a fresh container", e)
|
||||
@@ -1262,7 +1248,6 @@ class DockerEnvironment(BaseEnvironment):
|
||||
subprocess.run(
|
||||
[docker_exe, "stop", "-t", "10", container_id],
|
||||
capture_output=True, timeout=30,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.warning("docker stop %s timed out / failed: %s", log_id, e)
|
||||
@@ -1271,7 +1256,6 @@ class DockerEnvironment(BaseEnvironment):
|
||||
subprocess.run(
|
||||
[docker_exe, "rm", "-f", container_id],
|
||||
capture_output=True, timeout=30,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.warning("docker rm -f %s failed: %s", log_id, e)
|
||||
|
||||
+11
-69
@@ -300,72 +300,6 @@ _SANE_PATH = (
|
||||
)
|
||||
|
||||
|
||||
def _append_missing_sane_path_entries(existing_path: str) -> str:
|
||||
"""Return a normalised POSIX PATH with missing sane entries appended.
|
||||
|
||||
On POSIX the caller-supplied PATH is rewritten (not merely appended to):
|
||||
empty entries and duplicate entries are dropped, preserving
|
||||
first-occurrence order, then each missing ``_SANE_PATH`` entry is appended
|
||||
once at the end so existing entries keep their precedence.
|
||||
|
||||
Two intentional normalisations beyond the bare "add Homebrew dirs" fix:
|
||||
|
||||
- **Empty entries are stripped.** A leading/trailing/double ``:`` encodes
|
||||
an empty PATH element, which POSIX shells interpret as the current
|
||||
working directory — a mild foot-gun in a default terminal environment.
|
||||
We drop these rather than carry them through.
|
||||
- **Duplicates are collapsed** (first occurrence wins), so a caller PATH
|
||||
that already contains repeats is not propagated verbatim.
|
||||
|
||||
For a well-formed PATH (no empties, no duplicates) the leading segment is
|
||||
byte-identical to the input and ordering is preserved; only the missing
|
||||
sane entries are appended. On Windows this is a no-op passthrough (the
|
||||
separator is ``;`` and the native PATH must not be touched).
|
||||
"""
|
||||
if _IS_WINDOWS:
|
||||
return existing_path
|
||||
|
||||
sane_entries = [entry for entry in _SANE_PATH.split(":") if entry]
|
||||
if not existing_path:
|
||||
return ":".join(sane_entries)
|
||||
|
||||
# De-duplicate the caller PATH (first occurrence wins) and drop empty
|
||||
# entries before merging in the sane fallbacks.
|
||||
seen: set[str] = set()
|
||||
ordered_entries: list[str] = []
|
||||
for entry in existing_path.split(":"):
|
||||
if not entry or entry in seen:
|
||||
continue
|
||||
seen.add(entry)
|
||||
ordered_entries.append(entry)
|
||||
|
||||
# _SANE_PATH is a static, duplicate-free constant, so a membership check
|
||||
# against the caller entries is sufficient — no need to track `seen` here.
|
||||
for entry in sane_entries:
|
||||
if entry not in seen:
|
||||
ordered_entries.append(entry)
|
||||
|
||||
return ":".join(ordered_entries)
|
||||
|
||||
|
||||
def _path_env_key(run_env: dict) -> str | None:
|
||||
"""Return the PATH env key to update without altering Windows casing.
|
||||
|
||||
Note: this is deliberately a *second* Windows guard, distinct from the
|
||||
early-return in ``_append_missing_sane_path_entries``. Its job is to pick
|
||||
the correctly-cased key (``Path`` vs ``PATH``) so completion writes back to
|
||||
the key the caller already used; the helper's guard makes that helper safe
|
||||
to call standalone (it is, e.g. in the Windows unit tests). Both are
|
||||
intentional.
|
||||
"""
|
||||
if not _IS_WINDOWS:
|
||||
return "PATH"
|
||||
for key in run_env:
|
||||
if key.upper() == "PATH":
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def _make_run_env(env: dict) -> dict:
|
||||
"""Build a run environment with a sane PATH and provider-var stripping."""
|
||||
try:
|
||||
@@ -381,9 +315,17 @@ def _make_run_env(env: dict) -> dict:
|
||||
run_env[real_key] = v
|
||||
elif k not in _HERMES_PROVIDER_ENV_BLOCKLIST or _is_passthrough(k):
|
||||
run_env[k] = v
|
||||
path_key = _path_env_key(run_env)
|
||||
if path_key is not None:
|
||||
run_env[path_key] = _append_missing_sane_path_entries(run_env.get(path_key, ""))
|
||||
existing_path = run_env.get("PATH", "")
|
||||
# The "/usr/bin not already present → inject sane POSIX path" heuristic
|
||||
# only makes sense on POSIX. On Windows the PATH separator is ";"
|
||||
# (the split(":") above turns a full Windows PATH into a single
|
||||
# unrecognisable chunk, which then triggers prepending POSIX paths
|
||||
# to a Windows PATH — completely wrong). Skip the injection entirely
|
||||
# on Windows; the native PATH already points at whatever shell
|
||||
# Hermes is driving via _find_bash (Git Bash), and Git Bash itself
|
||||
# prepends its MSYS2 /usr/bin equivalent via the shell-init files.
|
||||
if not _IS_WINDOWS and "/usr/bin" not in existing_path.split(":"):
|
||||
run_env["PATH"] = f"{existing_path}:{_SANE_PATH}" if existing_path else _SANE_PATH
|
||||
|
||||
_inject_context_hermes_home(run_env)
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ def _ensure_singularity_available() -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[exe, "version"], capture_output=True, text=True, timeout=10,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError(
|
||||
@@ -137,7 +136,6 @@ def _get_or_build_sif(image: str, executable: str = "apptainer") -> str:
|
||||
result = subprocess.run(
|
||||
[executable, "build", str(sif_path), image],
|
||||
capture_output=True, text=True, timeout=600, env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("SIF build failed, falling back to docker:// URL")
|
||||
@@ -220,7 +218,7 @@ class SingularityEnvironment(BaseEnvironment):
|
||||
cmd.extend([str(self.image), self.instance_id])
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to start instance: {result.stderr}")
|
||||
self._instance_started = True
|
||||
@@ -252,7 +250,6 @@ class SingularityEnvironment(BaseEnvironment):
|
||||
subprocess.run(
|
||||
[self.executable, "instance", "stop", self.instance_id],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
logger.info("Singularity instance %s stopped", self.instance_id)
|
||||
except Exception as e:
|
||||
|
||||
+1
-5
@@ -90,7 +90,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
|
||||
# ─── Web search backends ───────────────────────────────────────────────
|
||||
"search.exa": ("exa-py==2.10.2",),
|
||||
"search.firecrawl": ("firecrawl-py==4.17.0",),
|
||||
"search.parallel": ("parallel-web==0.6.0",),
|
||||
"search.parallel": ("parallel-web==0.4.2",),
|
||||
|
||||
# ─── TTS providers ─────────────────────────────────────────────────────
|
||||
# Pinned to exact versions to match pyproject.toml's no-ranges policy
|
||||
@@ -365,7 +365,6 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
|
||||
r = subprocess.run(
|
||||
[uv_bin, "pip", "install", *specs],
|
||||
capture_output=True, text=True, timeout=timeout, env=uv_env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
return _InstallResult(True, r.stdout or "", r.stderr or "")
|
||||
@@ -379,7 +378,6 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
|
||||
probe = subprocess.run(
|
||||
pip_cmd + ["--version"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
raise FileNotFoundError("pip not in venv")
|
||||
@@ -388,7 +386,6 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
||||
capture_output=True, text=True, timeout=120, check=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
return _InstallResult(False, "",
|
||||
@@ -398,7 +395,6 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300) -> _Install
|
||||
r = subprocess.run(
|
||||
pip_cmd + ["install", *specs],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
return _InstallResult(r.returncode == 0, r.stdout or "", r.stderr or "")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
|
||||
+14
-195
@@ -90,7 +90,7 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Coroutine, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -268,38 +268,6 @@ _SAFE_ENV_KEYS = frozenset({
|
||||
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM", "SHELL", "TMPDIR",
|
||||
})
|
||||
|
||||
_SAFE_ENV_KEYS_CASE_INSENSITIVE = frozenset({
|
||||
# Windows process/location vars. These are needed by launcher-style tools
|
||||
# such as Docker Desktop's MCP plugin discovery, and do not carry secrets.
|
||||
"ALLUSERSPROFILE",
|
||||
"APPDATA",
|
||||
"COMMONPROGRAMFILES",
|
||||
"COMMONPROGRAMFILES(X86)",
|
||||
"COMMONPROGRAMW6432",
|
||||
"COMPUTERNAME",
|
||||
"COMSPEC",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
"LOCALAPPDATA",
|
||||
"NUMBER_OF_PROCESSORS",
|
||||
"OS",
|
||||
"PATHEXT",
|
||||
"PROCESSOR_ARCHITECTURE",
|
||||
"PROGRAMDATA",
|
||||
"PROGRAMFILES",
|
||||
"PROGRAMFILES(X86)",
|
||||
"PROGRAMW6432",
|
||||
"PUBLIC",
|
||||
"SYSTEMDRIVE",
|
||||
"SYSTEMROOT",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"USERDOMAIN",
|
||||
"USERNAME",
|
||||
"USERPROFILE",
|
||||
"WINDIR",
|
||||
})
|
||||
|
||||
# Regex for credential patterns to strip from error messages
|
||||
_CREDENTIAL_PATTERN = re.compile(
|
||||
r"(?:"
|
||||
@@ -337,11 +305,7 @@ def _build_safe_env(user_env: Optional[dict]) -> dict:
|
||||
"""
|
||||
env = {}
|
||||
for key, value in os.environ.items():
|
||||
if (
|
||||
key in _SAFE_ENV_KEYS
|
||||
or key.upper() in _SAFE_ENV_KEYS_CASE_INSENSITIVE
|
||||
or key.startswith("XDG_")
|
||||
):
|
||||
if key in _SAFE_ENV_KEYS or key.startswith("XDG_"):
|
||||
env[key] = value
|
||||
if user_env:
|
||||
env.update(user_env)
|
||||
@@ -1202,26 +1166,6 @@ class MCPServerTask:
|
||||
"""Check if this server uses HTTP transport."""
|
||||
return "url" in self._config
|
||||
|
||||
def _advertises_tools(self) -> bool:
|
||||
"""Whether the server advertises the ``tools`` capability.
|
||||
|
||||
Per the MCP spec, ``InitializeResult.capabilities.tools`` is non-None
|
||||
iff the server implements the ``tools/*`` request family. Prompt-only
|
||||
or resource-only servers omit it, and calling ``tools/list`` against
|
||||
them raises ``McpError(-32601 Method not found)`` — which previously
|
||||
killed the connection during discovery and made every keepalive fail.
|
||||
(Ported from anomalyco/opencode#31271.)
|
||||
|
||||
Returns True when no capability info was captured (legacy fallback:
|
||||
preserve the old always-call-list_tools behavior rather than regress
|
||||
any server that was working before this gate).
|
||||
"""
|
||||
init_result = self.initialize_result
|
||||
caps = getattr(init_result, "capabilities", None) if init_result is not None else None
|
||||
if caps is None:
|
||||
return True
|
||||
return getattr(caps, "tools", None) is not None
|
||||
|
||||
# ----- Dynamic tool discovery (notifications/tools/list_changed) -----
|
||||
|
||||
async def _refresh_tools_task(self):
|
||||
@@ -1293,12 +1237,6 @@ class MCPServerTask:
|
||||
"""
|
||||
from tools.registry import registry
|
||||
|
||||
if not self._advertises_tools():
|
||||
# A server that doesn't implement tools/* should never send
|
||||
# tools/list_changed, but guard anyway — calling tools/list
|
||||
# would raise McpError(-32601).
|
||||
return
|
||||
|
||||
async with self._refresh_lock:
|
||||
# Capture old tool names for change diff
|
||||
old_tool_names = set(self._registered_tool_names)
|
||||
@@ -1386,22 +1324,12 @@ class MCPServerTask:
|
||||
|
||||
# Timeout — no lifecycle event fired. Send a keepalive
|
||||
# to exercise the connection and detect stale sockets.
|
||||
# Prompt-only / resource-only servers don't implement
|
||||
# ``tools/list`` (McpError -32601), so use the universal
|
||||
# ``ping`` request for them instead — otherwise every
|
||||
# keepalive cycle would trigger a spurious reconnect.
|
||||
if self.session:
|
||||
try:
|
||||
if self._advertises_tools():
|
||||
await asyncio.wait_for(
|
||||
self.session.list_tools(),
|
||||
timeout=30.0,
|
||||
)
|
||||
else:
|
||||
await asyncio.wait_for(
|
||||
self.session.send_ping(),
|
||||
timeout=30.0,
|
||||
)
|
||||
await asyncio.wait_for(
|
||||
self.session.list_tools(),
|
||||
timeout=30.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"MCP server '%s' keepalive failed, "
|
||||
@@ -1814,25 +1742,9 @@ class MCPServerTask:
|
||||
)
|
||||
|
||||
async def _discover_tools(self):
|
||||
"""Discover tools from the connected session.
|
||||
|
||||
Capability-gated: prompt-only / resource-only MCP servers don't
|
||||
implement ``tools/list``, and calling it raises ``McpError(-32601)``,
|
||||
which previously aborted the connection — those servers could never
|
||||
stay connected for their prompts/resources. Skip the call when the
|
||||
server doesn't advertise the ``tools`` capability.
|
||||
(Ported from anomalyco/opencode#31271.)
|
||||
"""
|
||||
"""Discover tools from the connected session."""
|
||||
if self.session is None:
|
||||
return
|
||||
if not self._advertises_tools():
|
||||
logger.info(
|
||||
"MCP server '%s': does not advertise 'tools' capability — "
|
||||
"skipping tools/list (prompts/resources remain available)",
|
||||
self.name,
|
||||
)
|
||||
self._tools = []
|
||||
return
|
||||
async with self._rpc_lock:
|
||||
tools_result = await self.session.list_tools()
|
||||
self._tools = (
|
||||
@@ -2074,8 +1986,6 @@ class MCPServerTask:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_servers: Dict[str, MCPServerTask] = {}
|
||||
_server_connecting: set[str] = set()
|
||||
_server_connect_errors: Dict[str, str] = {}
|
||||
|
||||
# Circuit breaker: consecutive error counts per server. After
|
||||
# _CIRCUIT_BREAKER_THRESHOLD consecutive failures, the handler returns
|
||||
@@ -2462,8 +2372,8 @@ _mcp_tool_server_names: Dict[str, str] = {}
|
||||
_mcp_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
_mcp_thread: Optional[threading.Thread] = None
|
||||
|
||||
# Protects _mcp_loop, _mcp_thread, _servers, MCP connection status maps,
|
||||
# _parallel_safe_servers, _mcp_tool_server_names, and _stdio_pids.
|
||||
# Protects _mcp_loop, _mcp_thread, _servers, _parallel_safe_servers,
|
||||
# _mcp_tool_server_names, and _stdio_pids.
|
||||
_lock = threading.Lock()
|
||||
|
||||
# PIDs of stdio MCP server subprocesses. Tracked so we can force-kill
|
||||
@@ -2550,37 +2460,6 @@ def _ensure_mcp_loop():
|
||||
_mcp_thread.start()
|
||||
|
||||
|
||||
def _wrap_with_home_override(coro: "Coroutine") -> "Coroutine":
|
||||
"""Carry the caller's context-local HERMES_HOME override into ``coro``.
|
||||
|
||||
Returns ``coro`` unchanged when no override is active. Otherwise wraps
|
||||
it so the override is set inside the coroutine's own (task-local)
|
||||
context on the MCP loop and reset when it completes — concurrent calls
|
||||
carrying different scopes don't interfere.
|
||||
"""
|
||||
try:
|
||||
from hermes_constants import (
|
||||
get_hermes_home_override,
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
home_override = get_hermes_home_override()
|
||||
except Exception:
|
||||
return coro
|
||||
if not home_override:
|
||||
return coro
|
||||
|
||||
async def _scoped():
|
||||
token = set_hermes_home_override(home_override)
|
||||
try:
|
||||
return await coro
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
return _scoped()
|
||||
|
||||
|
||||
def _run_on_mcp_loop(coro_or_factory, timeout: float = 30):
|
||||
"""Schedule a coroutine on the MCP event loop and block until done.
|
||||
|
||||
@@ -2603,19 +2482,6 @@ def _run_on_mcp_loop(coro_or_factory, timeout: float = 30):
|
||||
raise RuntimeError("MCP event loop is not running")
|
||||
|
||||
coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory
|
||||
|
||||
# Propagate the context-local HERMES_HOME override onto the MCP loop.
|
||||
# Tasks scheduled via run_coroutine_threadsafe are created INSIDE the
|
||||
# loop thread, so they copy the loop thread's context — not the
|
||||
# scheduling thread's. A per-request profile scope (the dashboard's
|
||||
# ?profile= endpoints, e.g. the MCP "Test server" probe) would silently
|
||||
# vanish here: OAuth token stores and any other get_hermes_home()
|
||||
# resolution inside the coroutine would read the process home instead
|
||||
# of the selected profile's. Re-establish the override inside the
|
||||
# task's own context (task-local — concurrent calls carrying different
|
||||
# scopes don't interfere). No-op when no override is active.
|
||||
coro = _wrap_with_home_override(coro)
|
||||
|
||||
future = safe_schedule_threadsafe(
|
||||
coro, loop,
|
||||
logger=logger,
|
||||
@@ -3607,8 +3473,6 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]:
|
||||
timeout=connect_timeout,
|
||||
)
|
||||
with _lock:
|
||||
_server_connecting.discard(name)
|
||||
_server_connect_errors.pop(name, None)
|
||||
_servers[name] = server
|
||||
|
||||
registered_names = _register_server_tools(name, server, config)
|
||||
@@ -3655,9 +3519,6 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
|
||||
for k, v in servers.items()
|
||||
if k not in _servers and _parse_boolish(v.get("enabled", True), default=True)
|
||||
}
|
||||
_server_connecting.update(new_servers)
|
||||
for srv_name in new_servers:
|
||||
_server_connect_errors.pop(srv_name, None)
|
||||
# Track which servers opt-in to parallel tool calls (idempotent).
|
||||
for srv_name, srv_cfg in servers.items():
|
||||
if _parse_boolish(srv_cfg.get("supports_parallel_tool_calls", False), default=False):
|
||||
@@ -3685,20 +3546,12 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
|
||||
for name, result in zip(server_names, results):
|
||||
if isinstance(result, BaseException):
|
||||
command = new_servers.get(name, {}).get("command")
|
||||
message = _format_connect_error(result)
|
||||
with _lock:
|
||||
_server_connecting.discard(name)
|
||||
_server_connect_errors[name] = message
|
||||
logger.warning(
|
||||
"Failed to connect to MCP server '%s'%s: %s",
|
||||
name,
|
||||
f" (command={command})" if command else "",
|
||||
message,
|
||||
_format_connect_error(result),
|
||||
)
|
||||
else:
|
||||
with _lock:
|
||||
_server_connecting.discard(name)
|
||||
_server_connect_errors.pop(name, None)
|
||||
|
||||
# Per-server timeouts are handled inside _discover_and_register_server.
|
||||
# The outer timeout is generous: 120s total for parallel discovery.
|
||||
@@ -3803,10 +3656,8 @@ def is_mcp_tool_parallel_safe(tool_name: str) -> bool:
|
||||
def get_mcp_status() -> List[dict]:
|
||||
"""Return status of all configured MCP servers for banner display.
|
||||
|
||||
Returns a list of dicts with keys: name, transport, tools, connected,
|
||||
disabled, and status. Includes connected servers, disabled servers,
|
||||
in-flight connection attempts, recorded failures, and servers that are
|
||||
configured but have not been started in this process yet.
|
||||
Returns a list of dicts with keys: name, transport, tools, connected.
|
||||
Includes both successfully connected servers and configured-but-failed ones.
|
||||
"""
|
||||
result: List[dict] = []
|
||||
|
||||
@@ -3817,8 +3668,6 @@ def get_mcp_status() -> List[dict]:
|
||||
|
||||
with _lock:
|
||||
active_servers = dict(_servers)
|
||||
connecting = set(_server_connecting)
|
||||
connect_errors = dict(_server_connect_errors)
|
||||
|
||||
for name, cfg in configured.items():
|
||||
transport = cfg.get("transport", "http") if "url" in cfg else "stdio"
|
||||
@@ -3831,12 +3680,11 @@ def get_mcp_status() -> List[dict]:
|
||||
"tools": len(server._registered_tool_names) if hasattr(server, "_registered_tool_names") else len(server._tools),
|
||||
"connected": True,
|
||||
"disabled": False,
|
||||
"status": "connected",
|
||||
}
|
||||
if server._sampling:
|
||||
entry["sampling"] = dict(server._sampling.metrics)
|
||||
result.append(entry)
|
||||
elif not enabled:
|
||||
else:
|
||||
# A server with enabled: false is intentionally not connected — it is
|
||||
# disabled, not failed. Surface that distinction so consumers (banner,
|
||||
# TUI) can render "disabled" rather than an alarming "failed".
|
||||
@@ -3845,36 +3693,7 @@ def get_mcp_status() -> List[dict]:
|
||||
"transport": transport,
|
||||
"tools": 0,
|
||||
"connected": False,
|
||||
"disabled": True,
|
||||
"status": "disabled",
|
||||
})
|
||||
elif name in connecting:
|
||||
result.append({
|
||||
"name": name,
|
||||
"transport": transport,
|
||||
"tools": 0,
|
||||
"connected": False,
|
||||
"disabled": False,
|
||||
"status": "connecting",
|
||||
})
|
||||
elif name in connect_errors:
|
||||
result.append({
|
||||
"name": name,
|
||||
"transport": transport,
|
||||
"tools": 0,
|
||||
"connected": False,
|
||||
"disabled": False,
|
||||
"status": "failed",
|
||||
"error": connect_errors[name],
|
||||
})
|
||||
else:
|
||||
result.append({
|
||||
"name": name,
|
||||
"transport": transport,
|
||||
"tools": 0,
|
||||
"connected": False,
|
||||
"disabled": False,
|
||||
"status": "configured",
|
||||
"disabled": not enabled,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
+9
-90
@@ -606,63 +606,6 @@ class MemoryStore:
|
||||
raise RuntimeError(f"Failed to write memory file {path}: {e}")
|
||||
|
||||
|
||||
def _apply_write_gate(action: str, target: str, content: Optional[str],
|
||||
old_text: Optional[str]) -> Optional[str]:
|
||||
"""Evaluate the memory write gate. Returns a JSON tool-result string when
|
||||
the write should NOT proceed normally (blocked or staged), or None when the
|
||||
caller should perform the real write.
|
||||
|
||||
Only the mutating actions (add/replace/remove) are gated.
|
||||
"""
|
||||
if action not in {"add", "replace", "remove"}:
|
||||
return None
|
||||
|
||||
try:
|
||||
from tools import write_approval as wa
|
||||
except Exception:
|
||||
# If the gate module can't load, fail open (current behaviour) rather
|
||||
# than blocking all memory writes.
|
||||
return None
|
||||
|
||||
# Build a small inline summary/detail for the foreground approval prompt.
|
||||
label = "user profile" if target == "user" else "memory"
|
||||
if action == "add":
|
||||
summary = f"add to {label}"
|
||||
detail = content or ""
|
||||
elif action == "replace":
|
||||
summary = f"replace in {label}"
|
||||
detail = f"old: {old_text}\nnew: {content}"
|
||||
else: # remove
|
||||
summary = f"remove from {label}"
|
||||
detail = old_text or ""
|
||||
|
||||
decision = wa.evaluate_gate(wa.MEMORY, inline_summary=summary, inline_detail=detail)
|
||||
|
||||
if decision.allow:
|
||||
return None
|
||||
|
||||
if decision.blocked:
|
||||
return tool_error(decision.message, success=False)
|
||||
|
||||
# stage
|
||||
payload = {
|
||||
"action": action,
|
||||
"target": target,
|
||||
"content": content,
|
||||
"old_text": old_text,
|
||||
}
|
||||
record = wa.stage_write(
|
||||
wa.MEMORY, payload,
|
||||
summary=f"{summary}: {detail[:120]}",
|
||||
origin=wa.current_origin(),
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "staged": True, "pending_id": record["id"],
|
||||
"message": decision.message},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def memory_tool(
|
||||
action: str,
|
||||
target: str = "memory",
|
||||
@@ -681,29 +624,21 @@ def memory_tool(
|
||||
if target not in {"memory", "user"}:
|
||||
return tool_error(f"Invalid target '{target}'. Use 'memory' or 'user'.", success=False)
|
||||
|
||||
# Validate required params BEFORE the gate so an invalid write is rejected
|
||||
# immediately instead of being staged and only failing at approve time.
|
||||
if action == "add" and not content:
|
||||
return tool_error("Content is required for 'add' action.", success=False)
|
||||
if action == "replace" and (not old_text or not content):
|
||||
missing = "old_text" if not old_text else "content"
|
||||
return tool_error(f"{missing} is required for 'replace' action.", success=False)
|
||||
if action == "remove" and not old_text:
|
||||
return tool_error("old_text is required for 'remove' action.", success=False)
|
||||
|
||||
# Approval gate: when on, stages the write (background/gateway) or prompts
|
||||
# inline (interactive CLI); when off (default) passes straight through.
|
||||
gate_result = _apply_write_gate(action, target, content, old_text)
|
||||
if gate_result is not None:
|
||||
return gate_result
|
||||
|
||||
if action == "add":
|
||||
if not content:
|
||||
return tool_error("Content is required for 'add' action.", success=False)
|
||||
result = store.add(target, content)
|
||||
|
||||
elif action == "replace":
|
||||
if not old_text:
|
||||
return tool_error("old_text is required for 'replace' action.", success=False)
|
||||
if not content:
|
||||
return tool_error("content is required for 'replace' action.", success=False)
|
||||
result = store.replace(target, old_text, content)
|
||||
|
||||
elif action == "remove":
|
||||
if not old_text:
|
||||
return tool_error("old_text is required for 'remove' action.", success=False)
|
||||
result = store.remove(target, old_text)
|
||||
|
||||
else:
|
||||
@@ -717,23 +652,7 @@ def check_memory_requirements() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def apply_memory_pending(payload: Dict[str, Any], store: "MemoryStore") -> Dict[str, Any]:
|
||||
"""Replay a staged memory write directly against the store, bypassing the
|
||||
write gate. Called by the /memory approve handler.
|
||||
|
||||
Returns the store's result dict.
|
||||
"""
|
||||
action = payload.get("action")
|
||||
target = payload.get("target", "memory")
|
||||
content = payload.get("content") or ""
|
||||
old_text = payload.get("old_text") or ""
|
||||
if action == "add":
|
||||
return store.add(target, content)
|
||||
if action == "replace":
|
||||
return store.replace(target, old_text, content)
|
||||
if action == "remove":
|
||||
return store.remove(target, old_text)
|
||||
return {"success": False, "error": f"Unknown staged action '{action}'."}
|
||||
# =============================================================================
|
||||
# OpenAI Function-Calling Schema
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@@ -472,7 +472,6 @@ class ProcessRegistry:
|
||||
text=True,
|
||||
timeout=10,
|
||||
creationflags=windows_hide_flags(),
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
@@ -1208,14 +1207,10 @@ class ProcessRegistry:
|
||||
if session.exited:
|
||||
return {"status": "already_exited", "error": "Process has already finished"}
|
||||
|
||||
# PTY mode -- write through pty handle.
|
||||
# PTY mode -- write through pty handle (expects bytes)
|
||||
if hasattr(session, '_pty') and session._pty:
|
||||
try:
|
||||
# pywinpty expects str on Windows; ptyprocess expects bytes on POSIX.
|
||||
if _IS_WINDOWS:
|
||||
pty_data = data.decode("utf-8") if isinstance(data, bytes) else str(data)
|
||||
else:
|
||||
pty_data = data.encode("utf-8") if isinstance(data, str) else data
|
||||
pty_data = data.encode("utf-8") if isinstance(data, str) else data
|
||||
session._pty.write(pty_data)
|
||||
return {"status": "ok", "bytes_written": len(data)}
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read the in-app terminal pane in the Hermes desktop GUI.
|
||||
|
||||
The embedded terminal's buffer lives in the desktop renderer (xterm.js), so this
|
||||
tool round-trips through the gateway's blocking-prompt bridge — the same one
|
||||
`clarify` uses: tui_gateway emits ``terminal.read.request``, the renderer answers
|
||||
with ``terminal.read.respond``. This module is just schema + a thin dispatcher
|
||||
over the platform-injected callback.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Callable, Optional
|
||||
|
||||
from tools.registry import registry, tool_error
|
||||
|
||||
|
||||
def read_terminal_tool(
|
||||
start_line: Optional[int] = None,
|
||||
count: Optional[int] = None,
|
||||
callback: Optional[Callable] = None,
|
||||
) -> str:
|
||||
"""Return the in-app terminal's contents (+ line metadata) as a JSON string."""
|
||||
if callback is None:
|
||||
return tool_error("read_terminal is only available in the Hermes desktop app.")
|
||||
|
||||
try:
|
||||
window = {
|
||||
key: max(floor, int(val))
|
||||
for key, val, floor in (("start", start_line, 0), ("count", count, 1))
|
||||
if val is not None
|
||||
}
|
||||
except (TypeError, ValueError):
|
||||
return tool_error("start_line and count must be integers.")
|
||||
|
||||
try:
|
||||
raw = callback(**window)
|
||||
except Exception as exc:
|
||||
return tool_error(f"Failed to read terminal: {exc}")
|
||||
|
||||
if not raw:
|
||||
return tool_error("No in-app terminal is open, or the read timed out.")
|
||||
|
||||
# Desktop answers with a JSON object; pass it through, else wrap the raw text.
|
||||
try:
|
||||
return json.dumps(json.loads(raw), ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
return json.dumps({"text": str(raw)}, ensure_ascii=False)
|
||||
|
||||
|
||||
def check_read_terminal_requirements() -> bool:
|
||||
"""Desktop GUI only — HERMES_DESKTOP is set on the gateway the app spawns."""
|
||||
return (os.getenv("HERMES_DESKTOP") or "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
READ_TERMINAL_SCHEMA = {
|
||||
"name": "read_terminal",
|
||||
"description": (
|
||||
"Read what's currently shown in the in-app terminal pane of the Hermes "
|
||||
"desktop GUI (the embedded shell beside this chat). Call with no arguments "
|
||||
"to get the visible screen plus the total line count (`total_lines`). To "
|
||||
"page through scrollback, pass `start_line` (0 = oldest line) and `count`; "
|
||||
"valid lines are [0, total_lines). Returns JSON: "
|
||||
"{total_lines, start, end, viewport_rows, cursor_row, text}."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start_line": {
|
||||
"type": "integer",
|
||||
"description": "0-indexed first line (0 = oldest). Omit for the visible screen.",
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Lines to read from start_line. Defaults to the visible row count.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
registry.register(
|
||||
name="read_terminal",
|
||||
toolset="terminal",
|
||||
schema=READ_TERMINAL_SCHEMA,
|
||||
handler=lambda args, **kw: read_terminal_tool(
|
||||
start_line=args.get("start_line"),
|
||||
count=args.get("count"),
|
||||
callback=kw.get("callback"),
|
||||
),
|
||||
check_fn=check_read_terminal_requirements,
|
||||
emoji="🖥️",
|
||||
)
|
||||
@@ -38,7 +38,7 @@ _NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE
|
||||
# below and falls through to channel-name resolution, which has no way to
|
||||
# resolve a raw phone number. Keeping the '+' preserves the E.164 form that
|
||||
# downstream adapters (signal, etc.) expect.
|
||||
_PHONE_PLATFORMS = frozenset({"photon", "signal", "sms", "whatsapp"})
|
||||
_PHONE_PLATFORMS = frozenset({"signal", "sms", "whatsapp"})
|
||||
_E164_TARGET_RE = re.compile(r"^\s*\+(\d{7,15})\s*$")
|
||||
# Email addresses — a valid email like "user@domain.com" should be treated as
|
||||
# an explicit target for the email platform, not fall through to channel-name
|
||||
|
||||
@@ -822,75 +822,6 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:
|
||||
# Main entry point
|
||||
# =============================================================================
|
||||
|
||||
# ContextVar bypass: set while replaying an already-approved staged skill write
|
||||
# so skill_manage() does not re-gate (and re-stage) it.
|
||||
import contextvars as _ctxvars
|
||||
_skill_gate_bypass: "_ctxvars.ContextVar[bool]" = _ctxvars.ContextVar(
|
||||
"skill_gate_bypass", default=False
|
||||
)
|
||||
|
||||
|
||||
def _apply_skill_write_gate(action, name, **payload_kwargs):
|
||||
"""Evaluate the skill write gate. Returns a JSON tool-result string when the
|
||||
write should NOT proceed (blocked or staged), or None to perform the real
|
||||
write. Bypassed during approved-pending replay.
|
||||
"""
|
||||
if action not in {"create", "edit", "patch", "delete", "write_file", "remove_file"}:
|
||||
return None
|
||||
if _skill_gate_bypass.get():
|
||||
return None
|
||||
|
||||
try:
|
||||
from tools import write_approval as wa
|
||||
except Exception:
|
||||
return None # fail open
|
||||
|
||||
decision = wa.evaluate_gate(wa.SKILLS)
|
||||
if decision.allow:
|
||||
return None
|
||||
if decision.blocked:
|
||||
return tool_error(decision.message, success=False)
|
||||
|
||||
# stage — record the full skill_manage kwargs so approval can replay it.
|
||||
payload = {"action": action, "name": name}
|
||||
payload.update({k: v for k, v in payload_kwargs.items() if v is not None})
|
||||
gist = wa.skill_gist(
|
||||
action, name,
|
||||
content=payload_kwargs.get("content") or "",
|
||||
file_path=payload_kwargs.get("file_path") or "",
|
||||
old_string=payload_kwargs.get("old_string") or "",
|
||||
new_string=payload_kwargs.get("new_string") or "",
|
||||
)
|
||||
record = wa.stage_write(wa.SKILLS, payload, summary=gist, origin=wa.current_origin())
|
||||
return json.dumps(
|
||||
{"success": True, "staged": True, "pending_id": record["id"],
|
||||
"gist": gist, "message": decision.message},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def apply_skill_pending(payload: Dict[str, Any]) -> str:
|
||||
"""Replay a staged skill write, bypassing the gate. Returns the tool result
|
||||
JSON string. Called by the /skills approve handler.
|
||||
"""
|
||||
token = _skill_gate_bypass.set(True)
|
||||
try:
|
||||
return skill_manage(
|
||||
action=payload.get("action", ""),
|
||||
name=payload.get("name", ""),
|
||||
content=payload.get("content"),
|
||||
category=payload.get("category"),
|
||||
file_path=payload.get("file_path"),
|
||||
file_content=payload.get("file_content"),
|
||||
old_string=payload.get("old_string"),
|
||||
new_string=payload.get("new_string"),
|
||||
replace_all=payload.get("replace_all", False),
|
||||
absorbed_into=payload.get("absorbed_into"),
|
||||
)
|
||||
finally:
|
||||
_skill_gate_bypass.reset(token)
|
||||
|
||||
|
||||
def skill_manage(
|
||||
action: str,
|
||||
name: str,
|
||||
@@ -908,19 +839,6 @@ def skill_manage(
|
||||
|
||||
Returns JSON string with results.
|
||||
"""
|
||||
# Approval gate: when on, stages the write for review (skills are too large
|
||||
# to review inline, so they always stage regardless of origin); when off
|
||||
# (default) passes straight through. The gate is bypassed when this call is
|
||||
# itself replaying an already-approved staged write (_skill_apply_pending).
|
||||
gate_result = _apply_skill_write_gate(
|
||||
action, name, content=content, category=category,
|
||||
file_path=file_path, file_content=file_content,
|
||||
old_string=old_string, new_string=new_string,
|
||||
replace_all=replace_all, absorbed_into=absorbed_into,
|
||||
)
|
||||
if gate_result is not None:
|
||||
return gate_result
|
||||
|
||||
if action == "create":
|
||||
if not content:
|
||||
return tool_error("content is required for 'create'. Provide the full SKILL.md text (frontmatter + body).", success=False)
|
||||
|
||||
+44
-184
@@ -301,7 +301,6 @@ class GitHubAuth:
|
||||
result = subprocess.run(
|
||||
["gh", "auth", "token"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
@@ -551,8 +550,11 @@ class GitHubSource(SkillSource):
|
||||
return [SkillMeta(**s) for s in cached]
|
||||
|
||||
url = f"https://api.github.com/repos/{repo}/contents/{path.rstrip('/')}"
|
||||
resp = self._github_get(url)
|
||||
if resp is None or resp.status_code != 200:
|
||||
try:
|
||||
resp = httpx.get(url, headers=self.auth.get_headers(), timeout=15, follow_redirects=True)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
except httpx.HTTPError:
|
||||
return []
|
||||
|
||||
entries = resp.json()
|
||||
@@ -637,98 +639,15 @@ class GitHubSource(SkillSource):
|
||||
|
||||
def _check_rate_limit_response(self, resp: "httpx.Response") -> None:
|
||||
"""Flag the instance as rate-limited when GitHub returns 403 + exhausted quota."""
|
||||
if resp.status_code in (403, 429):
|
||||
if resp.status_code == 403:
|
||||
remaining = resp.headers.get("X-RateLimit-Remaining", "")
|
||||
if remaining == "0" or resp.status_code == 429:
|
||||
if remaining == "0":
|
||||
self._rate_limited = True
|
||||
logger.warning(
|
||||
"GitHub API rate limit exhausted (unauthenticated: 60 req/hr). "
|
||||
"Set GITHUB_TOKEN or install the gh CLI to raise the limit to 5,000/hr."
|
||||
)
|
||||
|
||||
def _github_get(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
params: Optional[Dict] = None,
|
||||
headers: Optional[Dict] = None,
|
||||
timeout: float = 15.0,
|
||||
max_retries: int = 3,
|
||||
) -> Optional["httpx.Response"]:
|
||||
"""GET against the GitHub API with retry/backoff on transient failures.
|
||||
|
||||
Returns the final ``httpx.Response`` (caller inspects status) or
|
||||
``None`` when every attempt raised a transport error.
|
||||
|
||||
Retries on:
|
||||
- 403/429 with ``X-RateLimit-Remaining: 0`` — waits until the
|
||||
reset time (capped) when the header is present, else exponential
|
||||
backoff. This is the all-GitHub-tap-collapse case: a single
|
||||
shared rate limit zeroes github + claude-marketplace + well-known
|
||||
at once during the index build.
|
||||
- 5xx and connection/timeout errors — exponential backoff.
|
||||
|
||||
On terminal rate-limit exhaustion the instance is flagged via
|
||||
``_check_rate_limit_response`` so the build can fail loud instead of
|
||||
silently shipping an index with the GitHub sources dropped to zero.
|
||||
"""
|
||||
hdrs = headers if headers is not None else self.auth.get_headers()
|
||||
backoff = 1.0
|
||||
last_resp: Optional["httpx.Response"] = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
resp = httpx.get(
|
||||
url, params=params, headers=hdrs,
|
||||
timeout=timeout, follow_redirects=True,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.debug("GitHub GET %s failed (attempt %d/%d): %s",
|
||||
url, attempt + 1, max_retries, e)
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30.0)
|
||||
continue
|
||||
return None
|
||||
|
||||
last_resp = resp
|
||||
if resp.status_code == 200:
|
||||
return resp
|
||||
|
||||
# Rate-limited: honor the reset header when present, else back off.
|
||||
if resp.status_code in (403, 429):
|
||||
remaining = resp.headers.get("X-RateLimit-Remaining", "")
|
||||
is_rl = remaining == "0" or resp.status_code == 429
|
||||
if is_rl and attempt < max_retries - 1:
|
||||
wait = backoff
|
||||
reset = resp.headers.get("X-RateLimit-Reset", "")
|
||||
retry_after = resp.headers.get("Retry-After", "")
|
||||
if retry_after.isdigit():
|
||||
wait = min(float(retry_after), 60.0)
|
||||
elif reset.isdigit():
|
||||
delta = float(reset) - time.time()
|
||||
if 0 < delta <= 60.0:
|
||||
wait = delta
|
||||
logger.debug(
|
||||
"GitHub rate limited on %s, waiting %.1fs (attempt %d/%d)",
|
||||
url, wait, attempt + 1, max_retries,
|
||||
)
|
||||
time.sleep(wait)
|
||||
backoff = min(backoff * 2, 30.0)
|
||||
continue
|
||||
# Out of retries (or not a rate-limit 403) — flag and return.
|
||||
self._check_rate_limit_response(resp)
|
||||
return resp
|
||||
|
||||
# 5xx — retry; 4xx (other than rate limit) — return immediately.
|
||||
if 500 <= resp.status_code < 600 and attempt < max_retries - 1:
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 30.0)
|
||||
continue
|
||||
return resp
|
||||
|
||||
return last_resp
|
||||
|
||||
|
||||
def _download_directory(self, repo: str, path: str) -> Dict[str, str]:
|
||||
"""Recursively download all text files from a GitHub directory.
|
||||
|
||||
@@ -849,12 +768,17 @@ class GitHubSource(SkillSource):
|
||||
def _fetch_file_content(self, repo: str, path: str) -> Optional[str]:
|
||||
"""Fetch a single file's content from GitHub."""
|
||||
url = f"https://api.github.com/repos/{repo}/contents/{path}"
|
||||
resp = self._github_get(
|
||||
url,
|
||||
headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"},
|
||||
)
|
||||
if resp is not None and resp.status_code == 200:
|
||||
return resp.text
|
||||
try:
|
||||
resp = httpx.get(
|
||||
url,
|
||||
headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"},
|
||||
timeout=15, follow_redirects=True,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.text
|
||||
self._check_rate_limit_response(resp)
|
||||
except httpx.HTTPError as e:
|
||||
logger.debug("GitHub contents API fetch failed: %s", e)
|
||||
return None
|
||||
|
||||
def _get_skillsh_groupings(self, repo: str) -> Optional[Dict[str, str]]:
|
||||
@@ -1946,12 +1870,6 @@ class ClawHubSource(SkillSource):
|
||||
|
||||
BASE_URL = "https://clawhub.ai/api/v1"
|
||||
|
||||
# Wall-clock budget for a full catalog walk. ClawHub has 50k+ skills and
|
||||
# the walk is sequential (~250 requests, each under per-request
|
||||
# timeout=30 so nothing errors), so an unbounded walk can block for
|
||||
# minutes. Bound it so a slow/large catalog cannot hang the caller.
|
||||
CATALOG_WALK_BUDGET_SECONDS = 12
|
||||
|
||||
def source_id(self) -> str:
|
||||
return "clawhub"
|
||||
|
||||
@@ -2119,13 +2037,12 @@ class ClawHubSource(SkillSource):
|
||||
if results:
|
||||
return results
|
||||
else:
|
||||
# Empty query: route through the paginating catalog walker. When
|
||||
# the full catalog is already disk-cached this returns it whole and
|
||||
# the caller paginates client-side. On a cold cache, bound the walk
|
||||
# to `limit` so a browse command renders its first page without
|
||||
# walking the entire 50k+ catalog (max_items=0 → unbounded, used
|
||||
# only by the offline index builder via search("", limit=0)).
|
||||
catalog = self._load_catalog_index(max_items=limit if limit > 0 else 0)
|
||||
# Empty query: route through the paginating catalog walker so the
|
||||
# full ClawHub catalog (20k+ skills) lands in the index. The
|
||||
# single-request listing path below caps at one page (200 items)
|
||||
# regardless of `limit`, which silently truncates the public
|
||||
# skills index. The catalog walker follows `nextCursor`.
|
||||
catalog = self._load_catalog_index()
|
||||
if catalog:
|
||||
return self._dedupe_results(catalog)[:limit] if limit > 0 else self._dedupe_results(catalog)
|
||||
|
||||
@@ -2250,21 +2167,7 @@ class ClawHubSource(SkillSource):
|
||||
_write_index_cache(cache_key, [_skill_meta_to_dict(s) for s in results])
|
||||
return results
|
||||
|
||||
def _load_catalog_index(self, max_items: int = 0) -> List[SkillMeta]:
|
||||
"""Walk the ClawHub catalog via cursor pagination.
|
||||
|
||||
``max_items`` bounds the walk: once at least that many distinct skills
|
||||
have been gathered the walk stops early. This is what browse's
|
||||
cold-start fallback wants — it only renders one page, so walking the
|
||||
entire 50k+ catalog just to slice off the first N is pure waste.
|
||||
``max_items=0`` (the default, used by the offline index builder) means
|
||||
walk to exhaustion.
|
||||
|
||||
Caching: only a *complete* catalog (cursor exhausted or page cap) is
|
||||
written to the shared ``clawhub_catalog_v1`` cache. A walk truncated by
|
||||
``max_items`` OR the wall-clock budget is partial, so caching it would
|
||||
poison the full-catalog cache with an incomplete slice.
|
||||
"""
|
||||
def _load_catalog_index(self) -> List[SkillMeta]:
|
||||
cache_key = "clawhub_catalog_v1"
|
||||
cached = _read_index_cache(cache_key)
|
||||
if cached is not None:
|
||||
@@ -2279,22 +2182,8 @@ class ClawHubSource(SkillSource):
|
||||
# terminates well before this on `nextCursor` going None — the cap is
|
||||
# a safety rail against an infinite-cursor loop.
|
||||
max_pages = 750
|
||||
# Wall-clock budget is for interactive browse (max_items > 0) only.
|
||||
# The offline index builder passes max_items=0 and must walk the full
|
||||
# catalog — a 12s cap there ships ~3k skills and trips the deploy
|
||||
# health floor (20k).
|
||||
deadline = (
|
||||
time.monotonic() + self.CATALOG_WALK_BUDGET_SECONDS
|
||||
if max_items > 0
|
||||
else None
|
||||
)
|
||||
hit_deadline = False
|
||||
hit_max_items = False
|
||||
|
||||
for _ in range(max_pages):
|
||||
if deadline is not None and time.monotonic() > deadline:
|
||||
hit_deadline = True
|
||||
break
|
||||
params: Dict[str, Any] = {"limit": 200}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
@@ -2332,19 +2221,7 @@ class ClawHubSource(SkillSource):
|
||||
if not isinstance(cursor, str) or not cursor:
|
||||
break
|
||||
|
||||
# Browse's cold-start fallback only renders one page, so stop as
|
||||
# soon as we have enough to satisfy the caller's bound. The index
|
||||
# builder passes max_items=0 (unbounded) and walks to exhaustion.
|
||||
if max_items > 0 and len(results) >= max_items:
|
||||
hit_max_items = True
|
||||
break
|
||||
|
||||
# Only cache a walk that reached a natural stop (cursor exhausted or
|
||||
# page cap). A walk truncated by the wall-clock budget OR by max_items
|
||||
# is partial, so writing it would poison the shared full-catalog cache
|
||||
# with incomplete data.
|
||||
if not hit_deadline and not hit_max_items:
|
||||
_write_index_cache(cache_key, [_skill_meta_to_dict(s) for s in results])
|
||||
_write_index_cache(cache_key, [_skill_meta_to_dict(s) for s in results])
|
||||
return results
|
||||
|
||||
def _get_json(self, url: str, timeout: int = 20) -> Optional[Any]:
|
||||
@@ -2496,19 +2373,10 @@ class ClaudeMarketplaceSource(SkillSource):
|
||||
|
||||
def __init__(self, auth: GitHubAuth):
|
||||
self.auth = auth
|
||||
# Persistent GitHubSource so rate-limit state survives across the
|
||||
# marketplace-index fetch + per-skill inspect calls and can be
|
||||
# surfaced to the index builder (see is_rate_limited).
|
||||
self.github = GitHubSource(auth=auth)
|
||||
|
||||
def source_id(self) -> str:
|
||||
return "claude-marketplace"
|
||||
|
||||
@property
|
||||
def is_rate_limited(self) -> bool:
|
||||
"""Whether the underlying GitHub API hit a rate limit during the crawl."""
|
||||
return self.github.is_rate_limited
|
||||
|
||||
def trust_level_for(self, identifier: str) -> str:
|
||||
parts = identifier.split("/", 2)
|
||||
if len(parts) >= 2:
|
||||
@@ -2547,13 +2415,15 @@ class ClaudeMarketplaceSource(SkillSource):
|
||||
|
||||
def fetch(self, identifier: str) -> Optional[SkillBundle]:
|
||||
# Delegate to GitHub Contents API since marketplace skills live in GitHub repos
|
||||
bundle = self.github.fetch(identifier)
|
||||
gh = GitHubSource(auth=self.auth)
|
||||
bundle = gh.fetch(identifier)
|
||||
if bundle:
|
||||
bundle.source = "claude-marketplace"
|
||||
return bundle
|
||||
|
||||
def inspect(self, identifier: str) -> Optional[SkillMeta]:
|
||||
meta = self.github.inspect(identifier)
|
||||
gh = GitHubSource(auth=self.auth)
|
||||
meta = gh.inspect(identifier)
|
||||
if meta:
|
||||
meta.source = "claude-marketplace"
|
||||
meta.trust_level = self.trust_level_for(identifier)
|
||||
@@ -2567,15 +2437,16 @@ class ClaudeMarketplaceSource(SkillSource):
|
||||
return cached
|
||||
|
||||
url = f"https://api.github.com/repos/{repo}/contents/.claude-plugin/marketplace.json"
|
||||
resp = self.github._github_get(
|
||||
url,
|
||||
headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"},
|
||||
)
|
||||
if resp is None or resp.status_code != 200:
|
||||
return []
|
||||
try:
|
||||
resp = httpx.get(
|
||||
url,
|
||||
headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
data = json.loads(resp.text)
|
||||
except json.JSONDecodeError:
|
||||
except (httpx.HTTPError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
plugins = data.get("plugins", [])
|
||||
@@ -3821,20 +3692,13 @@ def parallel_search_sources(
|
||||
if not active:
|
||||
return all_results, source_counts, timed_out_ids
|
||||
|
||||
# NOTE: a `with ThreadPoolExecutor(...) as pool` block calls
|
||||
# ``shutdown(wait=True)`` on exit, which blocks until every submitted
|
||||
# worker finishes — so a single slow source (e.g. ClawHub) keeps the
|
||||
# caller blocked for minutes and renders ``overall_timeout`` a no-op.
|
||||
# Manage the executor manually and shut it down with ``wait=False`` so
|
||||
# the timeout is actually honoured.
|
||||
pool = ThreadPoolExecutor(max_workers=min(len(active), 8))
|
||||
futures = {}
|
||||
for src in active:
|
||||
lim = per_source_limits.get(src.source_id(), 50)
|
||||
fut = pool.submit(_search_one_source, src, query, lim)
|
||||
futures[fut] = src.source_id()
|
||||
with ThreadPoolExecutor(max_workers=min(len(active), 8)) as pool:
|
||||
futures = {}
|
||||
for src in active:
|
||||
lim = per_source_limits.get(src.source_id(), 50)
|
||||
fut = pool.submit(_search_one_source, src, query, lim)
|
||||
futures[fut] = src.source_id()
|
||||
|
||||
try:
|
||||
try:
|
||||
for fut in as_completed(futures, timeout=overall_timeout):
|
||||
try:
|
||||
@@ -3854,10 +3718,6 @@ def parallel_search_sources(
|
||||
"Skills browse timed out waiting for: %s",
|
||||
", ".join(timed_out_ids),
|
||||
)
|
||||
finally:
|
||||
# wait=False so a slow source cannot block the caller's return;
|
||||
# cancel_futures drops not-yet-started work.
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
return all_results, source_counts, timed_out_ids
|
||||
|
||||
|
||||
+1
-12
@@ -1032,21 +1032,10 @@ def skill_view(
|
||||
_record(None, categorized_path.with_suffix(".md"))
|
||||
|
||||
# Strategy 2: recursive by directory name (catches nested skills
|
||||
# like "foundations/runtime/explore-codebase" called by bare name),
|
||||
# plus frontmatter `name:` lookup. `skills_list()` exposes the
|
||||
# frontmatter name, so `skill_view(name)` must accept it too even
|
||||
# when the on-disk directory is a shorter category/alias.
|
||||
# like "foundations/runtime/explore-codebase" called by bare name).
|
||||
for found_skill_md in iter_skill_index_files(search_dir, "SKILL.md"):
|
||||
if found_skill_md.parent.name == name:
|
||||
_record(found_skill_md.parent, found_skill_md)
|
||||
continue
|
||||
try:
|
||||
fm_content = found_skill_md.read_text(encoding="utf-8")
|
||||
fm, _ = _parse_frontmatter(fm_content)
|
||||
except Exception:
|
||||
fm = {}
|
||||
if fm.get("name") == name:
|
||||
_record(found_skill_md.parent, found_skill_md)
|
||||
|
||||
# Strategy 3: legacy flat <name>.md files anywhere under the dir.
|
||||
for found_md in search_dir.rglob(f"{name}.md"):
|
||||
|
||||
+13
-45
@@ -777,8 +777,7 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None
|
||||
the password in the command string themselves; see their execute()
|
||||
methods for how they handle the non-None sudo_stdin case.
|
||||
|
||||
If SUDO_PASSWORD is not set and an interactive UI is available
|
||||
(HERMES_INTERACTIVE=1 or a registered sudo password callback):
|
||||
If SUDO_PASSWORD is not set and in interactive mode (HERMES_INTERACTIVE=1):
|
||||
Prompts user for password with 45s timeout, caches for session.
|
||||
|
||||
If SUDO_PASSWORD is not set and NOT interactive:
|
||||
@@ -806,11 +805,7 @@ def _transform_sudo_command(command: str | None) -> tuple[str | None, str | None
|
||||
if not has_configured_password and not sudo_password and _sudo_nopasswd_works():
|
||||
return command, None
|
||||
|
||||
has_sudo_prompt_callback = _get_sudo_password_callback() is not None
|
||||
should_prompt_for_sudo = (
|
||||
env_var_enabled("HERMES_INTERACTIVE") or has_sudo_prompt_callback
|
||||
)
|
||||
if not has_configured_password and not sudo_password and should_prompt_for_sudo:
|
||||
if not has_configured_password and not sudo_password and env_var_enabled("HERMES_INTERACTIVE"):
|
||||
sudo_password = _prompt_for_sudo_password(timeout_seconds=45)
|
||||
if sudo_password:
|
||||
_set_cached_sudo_password(sudo_password)
|
||||
@@ -834,7 +829,7 @@ import sys
|
||||
|
||||
|
||||
# Tool description for LLM
|
||||
TERMINAL_TOOL_DESCRIPTION = """Execute shell commands on a Linux environment. Filesystem, current working directory, and exported environment variables persist between calls.
|
||||
TERMINAL_TOOL_DESCRIPTION = """Execute shell commands on a Linux environment. Filesystem usually persists between calls.
|
||||
|
||||
Do NOT use cat/head/tail to read files — use read_file instead.
|
||||
Do NOT use grep/rg/find to search — use search_files instead.
|
||||
@@ -842,7 +837,6 @@ Do NOT use ls to list directories — use search_files(target='files') instead.
|
||||
Do NOT use sed/awk to edit files — use patch instead.
|
||||
Do NOT use echo/cat heredoc to create files — use write_file instead.
|
||||
Reserve terminal for: builds, installs, git, processes, scripts, network, package managers, and anything that needs a shell.
|
||||
Because exported environment state persists, activate a virtualenv or export setup variables once per session; do not re-source the same environment before every command unless a command proves the shell state was reset.
|
||||
|
||||
Foreground (default): Commands return INSTANTLY when done, even if the timeout is high. Set timeout=300 for long builds/scripts — you'll still get the result in seconds if it's fast. Prefer foreground for short commands.
|
||||
Background: Set background=true to get a session_id. Almost always pair with notify_on_complete=true — bg without notify runs SILENTLY and you have no way to learn it finished short of calling process(action='poll') yourself. Two legitimate uses:
|
||||
@@ -1036,7 +1030,7 @@ def _resolve_container_task_id(task_id: Optional[str]) -> str:
|
||||
|
||||
# Configuration from environment variables
|
||||
|
||||
def _parse_env_var(name: str, default: str, converter: Any = int, type_label: str = "integer"):
|
||||
def _parse_env_var(name: str, default: str, converter=int, type_label: str = "integer"):
|
||||
"""Parse an environment variable with *converter*, raising a clear error on bad values.
|
||||
|
||||
Without this wrapper, a single malformed env var (e.g. TERMINAL_TIMEOUT=5m)
|
||||
@@ -1073,32 +1067,6 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
env_type = os.getenv("TERMINAL_ENV", "local")
|
||||
|
||||
mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in {"true", "1", "yes"}
|
||||
container_backend = env_type in {"docker", "singularity", "modal", "daytona"}
|
||||
docker_backend = env_type == "docker"
|
||||
|
||||
# Docker/container-only env vars may be bridged from config.yaml even when
|
||||
# the active backend is local/ssh. Do not parse their JSON/numeric payloads
|
||||
# until a backend that can consume them is selected; a stale or invalid
|
||||
# Docker value should not make local terminal/execute_code unusable.
|
||||
if container_backend:
|
||||
container_cpu = _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number")
|
||||
container_memory = _parse_env_var("TERMINAL_CONTAINER_MEMORY", "5120")
|
||||
container_disk = _parse_env_var("TERMINAL_CONTAINER_DISK", "51200")
|
||||
else:
|
||||
container_cpu = 1.0
|
||||
container_memory = 5120
|
||||
container_disk = 51200
|
||||
|
||||
if docker_backend:
|
||||
docker_forward_env = _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON")
|
||||
docker_volumes = _parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON")
|
||||
docker_env = _parse_env_var("TERMINAL_DOCKER_ENV", "{}", json.loads, "valid JSON")
|
||||
docker_extra_args = _parse_env_var("TERMINAL_DOCKER_EXTRA_ARGS", "[]", json.loads, "valid JSON")
|
||||
else:
|
||||
docker_forward_env = []
|
||||
docker_volumes = []
|
||||
docker_env = {}
|
||||
docker_extra_args = []
|
||||
|
||||
# Default cwd: local uses the host's current directory, ssh uses the
|
||||
# remote home, and everything else starts in the backend's default
|
||||
@@ -1142,7 +1110,7 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
"env_type": env_type,
|
||||
"modal_mode": coerce_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")),
|
||||
"docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image),
|
||||
"docker_forward_env": docker_forward_env,
|
||||
"docker_forward_env": _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON"),
|
||||
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
|
||||
"modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image),
|
||||
"daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image),
|
||||
@@ -1166,14 +1134,14 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
"local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in {"true", "1", "yes"},
|
||||
# Container resource config (applies to docker, singularity, modal,
|
||||
# daytona -- ignored for local/ssh)
|
||||
"container_cpu": container_cpu,
|
||||
"container_memory": container_memory, # MB (default 5GB)
|
||||
"container_disk": container_disk, # MB (default 50GB)
|
||||
"container_cpu": _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number"),
|
||||
"container_memory": _parse_env_var("TERMINAL_CONTAINER_MEMORY", "5120"), # MB (default 5GB)
|
||||
"container_disk": _parse_env_var("TERMINAL_CONTAINER_DISK", "51200"), # MB (default 50GB)
|
||||
"container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"true", "1", "yes"},
|
||||
"docker_volumes": docker_volumes,
|
||||
"docker_env": docker_env,
|
||||
"docker_volumes": _parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON"),
|
||||
"docker_env": _parse_env_var("TERMINAL_DOCKER_ENV", "{}", json.loads, "valid JSON"),
|
||||
"docker_run_as_host_user": os.getenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in {"true", "1", "yes"},
|
||||
"docker_extra_args": docker_extra_args,
|
||||
"docker_extra_args": _parse_env_var("TERMINAL_DOCKER_EXTRA_ARGS", "[]", json.loads, "valid JSON"),
|
||||
# Cross-process container reuse (issue #20561). The docs claim
|
||||
# "ONE long-lived container shared across sessions" — this toggle
|
||||
# makes that real by probing for a labeled container at startup and
|
||||
@@ -2468,13 +2436,13 @@ def check_terminal_requirements() -> bool:
|
||||
if not docker:
|
||||
logger.error("Docker executable not found in PATH or common install locations")
|
||||
return False
|
||||
result = subprocess.run([docker, "version"], capture_output=True, timeout=5, stdin=subprocess.DEVNULL)
|
||||
result = subprocess.run([docker, "version"], capture_output=True, timeout=5)
|
||||
return result.returncode == 0
|
||||
|
||||
elif env_type == "singularity":
|
||||
executable = shutil.which("apptainer") or shutil.which("singularity")
|
||||
if executable:
|
||||
result = subprocess.run([executable, "--version"], capture_output=True, timeout=5, stdin=subprocess.DEVNULL)
|
||||
result = subprocess.run([executable, "--version"], capture_output=True, timeout=5)
|
||||
return result.returncode == 0
|
||||
return False
|
||||
|
||||
|
||||
@@ -288,7 +288,6 @@ def _verify_cosign(checksums_path: str, sig_path: str, cert_path: str) -> bool |
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("cosign provenance verification passed")
|
||||
@@ -735,7 +734,6 @@ def check_command_security(command: str) -> dict:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except OSError as exc:
|
||||
# Covers FileNotFoundError, PermissionError, exec format error.
|
||||
|
||||
@@ -490,7 +490,6 @@ def _terminate_command_stt_process_tree(proc: subprocess.Popen) -> None:
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
@@ -556,7 +555,7 @@ def _run_command_stt(command: str, timeout: float) -> subprocess.CompletedProces
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL)
|
||||
proc = subprocess.Popen(command, **popen_kwargs)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
@@ -1187,7 +1186,7 @@ def _prepare_local_audio(file_path: str, work_dir: str) -> tuple[Optional[str],
|
||||
command = [ffmpeg, "-y", "-i", file_path, converted_path]
|
||||
|
||||
try:
|
||||
subprocess.run(command, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
|
||||
subprocess.run(command, check=True, capture_output=True, text=True, timeout=300)
|
||||
return converted_path, None
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("ffmpeg conversion timed out for %s", file_path)
|
||||
@@ -1233,9 +1232,9 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any]
|
||||
# User-provided templates (env var) may contain shell syntax; auto-detected commands are safe for list mode.
|
||||
use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip())
|
||||
if use_shell:
|
||||
subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
|
||||
subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300)
|
||||
else:
|
||||
subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
|
||||
subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300)
|
||||
|
||||
|
||||
txt_files = sorted(Path(output_dir).glob("*.txt"))
|
||||
|
||||
+25
-205
@@ -190,8 +190,6 @@ DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
|
||||
DEFAULT_GEMINI_TTS_MODEL = "gemini-2.5-flash-preview-tts"
|
||||
DEFAULT_GEMINI_TTS_VOICE = "Kore"
|
||||
DEFAULT_GEMINI_TTS_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
DEFAULT_GEMINI_AUDIO_TAGS = False
|
||||
GEMINI_AUDIO_TAG_REWRITE_TASK = "tts_audio_tags"
|
||||
# PCM output specs for Gemini TTS (fixed by the API)
|
||||
GEMINI_TTS_SAMPLE_RATE = 24000
|
||||
GEMINI_TTS_CHANNELS = 1
|
||||
@@ -206,8 +204,8 @@ DEFAULT_OUTPUT_DIR = _get_default_output_dir()
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-provider input-character limits (from official provider docs).
|
||||
# A single global cap was wrong: OpenAI is 4096, xAI is 15k, MiniMax is 10k,
|
||||
# ElevenLabs is model-dependent (5k / 10k / 30k / 40k), Gemini has a 32k-token
|
||||
# context window. Users can override any of these via
|
||||
# ElevenLabs is model-dependent (5k / 10k / 30k / 40k), Gemini caps at ~8k
|
||||
# input tokens. Users can override any of these via
|
||||
# ``tts.<provider>.max_text_length`` in config.yaml.
|
||||
# ---------------------------------------------------------------------------
|
||||
PROVIDER_MAX_TEXT_LENGTH: Dict[str, int] = {
|
||||
@@ -216,7 +214,7 @@ PROVIDER_MAX_TEXT_LENGTH: Dict[str, int] = {
|
||||
"xai": 15000, # https://docs.x.ai/developers/model-capabilities/audio/text-to-speech
|
||||
"minimax": 10000, # https://platform.minimax.io/docs/api-reference/speech-t2a-http (sync)
|
||||
"mistral": 4000, # conservative; no published per-request cap
|
||||
"gemini": 32000, # Gemini TTS has a 32k-token context window; char cap is conservative
|
||||
"gemini": 5000, # Gemini TTS caps at ~8k input tokens / ~655s audio
|
||||
"elevenlabs": 10000, # fallback when model-aware lookup can't resolve (multilingual_v2)
|
||||
"neutts": 2000, # local model, quality falls off on long text
|
||||
"kittentts": 2000, # local 25MB model
|
||||
@@ -235,23 +233,6 @@ ELEVENLABS_MODEL_MAX_TEXT_LENGTH: Dict[str, int] = {
|
||||
"eleven_flash_v2_5": 40000,
|
||||
}
|
||||
|
||||
|
||||
def _config_bool(value: Any, default: bool = False) -> bool:
|
||||
"""Coerce common YAML/env bool spellings without treating random strings as true."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on", "enabled"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off", "disabled"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
# Final fallback when provider isn't recognised at all.
|
||||
FALLBACK_MAX_TEXT_LENGTH = 4000
|
||||
|
||||
@@ -712,7 +693,6 @@ def _terminate_command_tts_process_tree(proc: subprocess.Popen) -> None:
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
@@ -765,7 +745,7 @@ def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProces
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
proc = subprocess.Popen(command, **popen_kwargs, stdin=subprocess.DEVNULL)
|
||||
proc = subprocess.Popen(command, **popen_kwargs)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
@@ -902,7 +882,6 @@ def _convert_to_opus(mp3_path: str) -> Optional[str]:
|
||||
["ffmpeg", "-i", mp3_path, "-acodec", "libopus",
|
||||
"-ac", "1", "-b:a", "64k", "-vbr", "off", ogg_path, "-y"],
|
||||
capture_output=True, timeout=30,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("ffmpeg conversion failed with return code %d: %s",
|
||||
@@ -1088,7 +1067,20 @@ _XAI_FIRST_SENTENCE_RE = re.compile(r"^(.{12,120}?[.!?…])\s+(?=\S)", flags=re.
|
||||
|
||||
|
||||
def _xai_bool_config(value: Any, default: bool = False) -> bool:
|
||||
return _config_bool(value, default=default)
|
||||
"""Coerce common YAML/env bool spellings without treating random strings as true."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on", "enabled"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off", "disabled"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _apply_xai_auto_speech_tags(text: str) -> str:
|
||||
@@ -1400,160 +1392,6 @@ def _wrap_pcm_as_wav(
|
||||
return riff_header + fmt_chunk + data_chunk_header + pcm_bytes
|
||||
|
||||
|
||||
def _resolve_gemini_persona_prompt_path(gemini_config: Dict[str, Any]) -> Optional[Path]:
|
||||
"""Return the configured persona prompt file path, if any."""
|
||||
raw = gemini_config.get("persona_prompt_file")
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return None
|
||||
|
||||
expanded = os.path.expandvars(raw.strip())
|
||||
path = Path(expanded).expanduser()
|
||||
if not path.is_absolute():
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
path = get_hermes_home() / path
|
||||
except Exception:
|
||||
path = Path.cwd() / path
|
||||
return path
|
||||
|
||||
|
||||
def _read_gemini_persona_prompt(gemini_config: Dict[str, Any]) -> str:
|
||||
"""Read the Gemini persona prompt file, failing soft on config mistakes."""
|
||||
path = _resolve_gemini_persona_prompt_path(gemini_config)
|
||||
if path is None:
|
||||
return ""
|
||||
try:
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
logger.warning(
|
||||
"Gemini TTS persona prompt file unavailable at %s: %s",
|
||||
path,
|
||||
exc,
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _gemini_model_supports_audio_tags(model: str) -> bool:
|
||||
"""Return True for Gemini TTS models known to support expressive audio tags."""
|
||||
normalized = (model or "").strip().lower().rsplit("/", 1)[-1]
|
||||
return "gemini-3.1" in normalized and "tts" in normalized
|
||||
|
||||
|
||||
def _gemini_audio_tags_enabled(gemini_config: Dict[str, Any], model: str) -> bool:
|
||||
raw = gemini_config.get("audio_tags")
|
||||
if isinstance(raw, dict):
|
||||
raw = raw.get("enabled")
|
||||
enabled = _config_bool(raw, default=DEFAULT_GEMINI_AUDIO_TAGS)
|
||||
if not enabled:
|
||||
return False
|
||||
if not _gemini_model_supports_audio_tags(model):
|
||||
logger.warning(
|
||||
"Gemini TTS audio_tags enabled, but model %s is not known to support "
|
||||
"Gemini audio tags; skipping hidden tag rewrite",
|
||||
model,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _clean_gemini_audio_tag_rewrite(content: str) -> str:
|
||||
clean = (content or "").strip()
|
||||
fence = re.fullmatch(r"```(?:[A-Za-z0-9_-]+)?\s*(.*?)\s*```", clean, flags=re.DOTALL)
|
||||
if fence:
|
||||
clean = fence.group(1).strip()
|
||||
return clean
|
||||
|
||||
|
||||
def _extract_auxiliary_message_content(response: Any) -> str:
|
||||
try:
|
||||
choice = response.choices[0]
|
||||
message = getattr(choice, "message", None)
|
||||
if isinstance(message, dict):
|
||||
return str(message.get("content") or "")
|
||||
return str(getattr(message, "content", "") or "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _rewrite_gemini_tts_audio_tags(text: str, persona_prompt: str = "") -> str:
|
||||
"""Use the configured auxiliary model to insert Gemini audio tags."""
|
||||
transcript = text.strip()
|
||||
if not transcript:
|
||||
return text
|
||||
|
||||
system_prompt = (
|
||||
"You rewrite transcripts for Gemini 3.1 Flash TTS by inserting expressive "
|
||||
"audio tags.\n\n"
|
||||
"Audio tags are inline square-bracket modifiers such as [whispers], "
|
||||
"[excitedly], [very slow], [sarcastically], [laughs], [sighs], or [gasp]. "
|
||||
"There is no fixed allowlist. Use creative freeform tags generously but "
|
||||
"naturally to control tone, pace, emotional vibe, emphasis, section-level "
|
||||
"delivery, and non-verbal sounds. Use English audio tags even when the "
|
||||
"spoken transcript is not English.\n\n"
|
||||
"Rules:\n"
|
||||
"- Preserve the spoken words, order, and meaning.\n"
|
||||
"- Do not add new spoken sentences or remove existing spoken words.\n"
|
||||
"- Use square brackets for every audio tag.\n"
|
||||
"- Do not use SSML or XML tags.\n"
|
||||
"- Do not explain or comment.\n"
|
||||
"- Return only the tagged TTS script."
|
||||
)
|
||||
context = persona_prompt.strip() or "(none)"
|
||||
user_prompt = (
|
||||
"PERSONA AND DIRECTOR CONTEXT:\n"
|
||||
f"{context}\n\n"
|
||||
"TRANSCRIPT TO TAG:\n"
|
||||
f"{transcript}"
|
||||
)
|
||||
try:
|
||||
from agent.auxiliary_client import call_llm
|
||||
|
||||
response = call_llm(
|
||||
task=GEMINI_AUDIO_TAG_REWRITE_TASK,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.7,
|
||||
)
|
||||
tagged = _clean_gemini_audio_tag_rewrite(_extract_auxiliary_message_content(response))
|
||||
return tagged or text
|
||||
except Exception as exc:
|
||||
logger.warning("Gemini TTS audio tag rewrite failed; using untagged text: %s", exc)
|
||||
return text
|
||||
|
||||
|
||||
def _compose_gemini_tts_prompt(
|
||||
text: str,
|
||||
gemini_config: Dict[str, Any],
|
||||
persona_prompt: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build the Gemini prompt from persona direction plus the live transcript."""
|
||||
transcript = text.strip()
|
||||
if persona_prompt is None:
|
||||
persona_prompt = _read_gemini_persona_prompt(gemini_config)
|
||||
if not persona_prompt:
|
||||
return transcript
|
||||
|
||||
preamble = (
|
||||
"Synthesize speech from the TRANSCRIPT only. Treat AUDIO PROFILE, "
|
||||
"SCENE, DIRECTOR'S NOTES, and SAMPLE CONTEXT as performance direction; "
|
||||
"do not speak those sections aloud."
|
||||
)
|
||||
|
||||
placeholder_patterns = (
|
||||
re.compile(r"\{\{\s*transcript\s*\}\}", flags=re.IGNORECASE),
|
||||
re.compile(r"\{\s*transcript\s*\}", flags=re.IGNORECASE),
|
||||
)
|
||||
prompt = persona_prompt
|
||||
for pattern in placeholder_patterns:
|
||||
if pattern.search(prompt):
|
||||
prompt = pattern.sub(transcript, prompt)
|
||||
return f"{preamble}\n\n{prompt}".strip()
|
||||
|
||||
return f"{preamble}\n\n{persona_prompt}\n\n#### TRANSCRIPT\n{transcript}".strip()
|
||||
|
||||
|
||||
def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
|
||||
"""Generate audio using Google Gemini TTS.
|
||||
|
||||
@@ -1579,8 +1417,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
|
||||
"GEMINI_API_KEY not set. Get one at https://aistudio.google.com/app/apikey"
|
||||
)
|
||||
|
||||
raw_gemini_config = tts_config.get("gemini", {})
|
||||
gemini_config = raw_gemini_config if isinstance(raw_gemini_config, dict) else {}
|
||||
gemini_config = tts_config.get("gemini", {})
|
||||
model = str(gemini_config.get("model", DEFAULT_GEMINI_TTS_MODEL)).strip() or DEFAULT_GEMINI_TTS_MODEL
|
||||
voice = str(gemini_config.get("voice", DEFAULT_GEMINI_TTS_VOICE)).strip() or DEFAULT_GEMINI_TTS_VOICE
|
||||
base_url = str(
|
||||
@@ -1588,25 +1425,9 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
|
||||
or get_env_value("GEMINI_BASE_URL")
|
||||
or DEFAULT_GEMINI_TTS_BASE_URL
|
||||
).strip().rstrip("/")
|
||||
persona_prompt = _read_gemini_persona_prompt(gemini_config)
|
||||
tts_script = text
|
||||
if _gemini_audio_tags_enabled(gemini_config, model):
|
||||
tts_script = _rewrite_gemini_tts_audio_tags(text, persona_prompt=persona_prompt)
|
||||
prompt_text = _compose_gemini_tts_prompt(
|
||||
tts_script,
|
||||
gemini_config,
|
||||
persona_prompt=persona_prompt,
|
||||
)
|
||||
max_len = _resolve_max_text_length("gemini", tts_config)
|
||||
if len(prompt_text) > max_len:
|
||||
logger.warning(
|
||||
"Gemini TTS composed prompt too long (%d chars), truncating to %d",
|
||||
len(prompt_text), max_len,
|
||||
)
|
||||
prompt_text = prompt_text[:max_len]
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"contents": [{"parts": [{"text": prompt_text}]}],
|
||||
"contents": [{"parts": [{"text": text}]}],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["AUDIO"],
|
||||
"speechConfig": {
|
||||
@@ -1683,7 +1504,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
|
||||
]
|
||||
else:
|
||||
cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=30, stdin=subprocess.DEVNULL)
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=30)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.decode("utf-8", errors="ignore")[:300]
|
||||
raise RuntimeError(f"ffmpeg conversion failed: {stderr}")
|
||||
@@ -1766,7 +1587,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
|
||||
"--device", device,
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
# Filter out the "OK:" line from stderr
|
||||
@@ -1778,7 +1599,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg:
|
||||
conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
|
||||
subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
|
||||
subprocess.run(conv_cmd, check=True, timeout=30)
|
||||
os.remove(wav_path)
|
||||
else:
|
||||
# No ffmpeg — just rename the WAV to the expected path
|
||||
@@ -1849,7 +1670,6 @@ def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str:
|
||||
[_sys.executable, "-m", "piper.download_voices", voice,
|
||||
"--download-dir", str(download_dir)],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(
|
||||
@@ -1937,7 +1757,7 @@ def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any])
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg:
|
||||
conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
|
||||
subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
|
||||
subprocess.run(conv_cmd, check=True, timeout=30)
|
||||
try:
|
||||
os.remove(wav_path)
|
||||
except OSError:
|
||||
@@ -2003,7 +1823,7 @@ def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any])
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg:
|
||||
conv_cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
|
||||
subprocess.run(conv_cmd, check=True, timeout=30, stdin=subprocess.DEVNULL)
|
||||
subprocess.run(conv_cmd, check=True, timeout=30)
|
||||
os.remove(wav_path)
|
||||
else:
|
||||
# No ffmpeg — rename the WAV to the expected path
|
||||
|
||||
+3
-4
@@ -75,7 +75,6 @@ def _termux_api_app_installed() -> bool:
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
return "package:com.termux.api" in (result.stdout or "")
|
||||
except Exception:
|
||||
@@ -389,7 +388,7 @@ class TermuxAudioRecorder:
|
||||
"-c", str(CHANNELS),
|
||||
]
|
||||
try:
|
||||
subprocess.run(command, capture_output=True, text=True, timeout=15, check=True, stdin=subprocess.DEVNULL)
|
||||
subprocess.run(command, capture_output=True, text=True, timeout=15, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
details = (e.stderr or e.stdout or str(e)).strip()
|
||||
raise RuntimeError(f"Termux microphone start failed: {details}") from e
|
||||
@@ -406,7 +405,7 @@ class TermuxAudioRecorder:
|
||||
mic_cmd = _termux_microphone_command()
|
||||
if not mic_cmd:
|
||||
return
|
||||
subprocess.run([mic_cmd, "-q"], capture_output=True, text=True, timeout=15, check=False, stdin=subprocess.DEVNULL)
|
||||
subprocess.run([mic_cmd, "-q"], capture_output=True, text=True, timeout=15, check=False)
|
||||
|
||||
def stop(self) -> Optional[str]:
|
||||
with self._lock:
|
||||
@@ -1096,7 +1095,7 @@ def play_audio_file(file_path: str) -> bool:
|
||||
exe = shutil.which(cmd[0])
|
||||
if exe:
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
with _playback_lock:
|
||||
_active_playback = proc
|
||||
proc.wait(timeout=300)
|
||||
|
||||
+26
-221
@@ -141,71 +141,36 @@ def _load_web_config() -> dict:
|
||||
except (ImportError, Exception):
|
||||
return {}
|
||||
|
||||
# Recognized web backend names (config values accepted in ``web.backend`` /
|
||||
# ``web.search_backend`` / ``web.extract_backend``). Kept as a single source of
|
||||
# truth for config validation across the selection helpers.
|
||||
_KNOWN_WEB_BACKENDS = frozenset(
|
||||
{"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs", "xai"}
|
||||
)
|
||||
|
||||
# Backends that only service web_search (their provider's ``supports_extract()``
|
||||
# is False). They are skipped during *extract* auto-detect so a search-only
|
||||
# credential (e.g. SEARXNG_URL) does not shadow the keyless Parallel free-MCP
|
||||
# fallback, which would otherwise leave web_extract broken on a no-key install.
|
||||
_SEARCH_ONLY_BACKENDS = frozenset({"searxng", "brave-free", "ddgs", "xai"})
|
||||
|
||||
|
||||
def _get_backend(capability: str = "search") -> str:
|
||||
def _get_backend() -> str:
|
||||
"""Determine which web backend to use (shared fallback).
|
||||
|
||||
Reads ``web.backend`` from config.yaml (set by ``hermes tools``).
|
||||
Falls back to whichever API key is present for users who configured
|
||||
keys manually without running setup.
|
||||
|
||||
``capability`` ("search" | "extract") only affects auto-detect: for
|
||||
``extract`` we skip search-only backends (``_SEARCH_ONLY_BACKENDS``) so a
|
||||
search-only credential never shadows the keyless Parallel free-MCP extract
|
||||
fallback. An explicit ``web.backend`` value is honored as-is (explicit wins,
|
||||
surfacing that backend's own search-only error rather than rerouting).
|
||||
"""
|
||||
configured = (_load_web_config().get("backend") or "").lower().strip()
|
||||
if configured in _KNOWN_WEB_BACKENDS:
|
||||
if configured in {"parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs", "xai"}:
|
||||
return configured
|
||||
|
||||
# Fallback for manual / legacy config — pick the highest-priority
|
||||
# available backend. Explicit user credentials (TAVILY_API_KEY etc.)
|
||||
# beat the managed-tool-gateway probe so a deliberate setup is not
|
||||
# pre-empted by a Nous OAuth token whose subscription tier may not
|
||||
# actually grant web-search access (the gateway then fails at runtime
|
||||
# with "no subscription" and the tool returns an error to the agent
|
||||
# without falling back). Free-tier backends (searxng / brave-free /
|
||||
# keyless parallel / ddgs) trail the keyed ones.
|
||||
# available backend. Firecrawl also counts as available when the managed
|
||||
# tool gateway is configured for Nous subscribers.
|
||||
# Free-tier backends (searxng / brave-free / ddgs) trail the paid ones so
|
||||
# existing paid setups are unaffected.
|
||||
backend_candidates = (
|
||||
("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") or _is_tool_gateway_ready()),
|
||||
("parallel", _has_env("PARALLEL_API_KEY")),
|
||||
("tavily", _has_env("TAVILY_API_KEY")),
|
||||
("exa", _has_env("EXA_API_KEY")),
|
||||
("parallel", _has_env("PARALLEL_API_KEY")),
|
||||
("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL")),
|
||||
("firecrawl", _is_tool_gateway_ready()),
|
||||
("searxng", _has_env("SEARXNG_URL")),
|
||||
("brave-free", _has_env("BRAVE_SEARCH_API_KEY")),
|
||||
# Keyless Parallel free MCP — always available, the intended no-key
|
||||
# default for both search and extract. Ahead of ddgs (search-only, so it
|
||||
# can't service web_extract); ddgs stays reachable via web.backend=ddgs.
|
||||
("parallel", True),
|
||||
("ddgs", _ddgs_package_importable()),
|
||||
)
|
||||
for backend, available in backend_candidates:
|
||||
if not available:
|
||||
continue
|
||||
# For extract, skip search-only backends so the keyless Parallel
|
||||
# free-MCP fallback (which can fetch URLs) is reached instead.
|
||||
if capability == "extract" and backend in _SEARCH_ONLY_BACKENDS:
|
||||
continue
|
||||
return backend
|
||||
if available:
|
||||
return backend
|
||||
|
||||
# Defensive terminal (the keyless ``parallel`` candidate above is always
|
||||
# available, so this is effectively unreachable).
|
||||
return "parallel"
|
||||
return "firecrawl" # default (backward compat)
|
||||
|
||||
|
||||
def _get_search_backend() -> str:
|
||||
@@ -236,19 +201,14 @@ def _get_extract_backend() -> str:
|
||||
def _get_capability_backend(capability: str) -> str:
|
||||
"""Shared helper for per-capability backend selection.
|
||||
|
||||
Reads ``web.{capability}_backend`` from config. Any explicit value is
|
||||
honored **regardless of availability** — including unrecognized typos like
|
||||
``parrallel`` — so the dispatcher surfaces that backend's own setup/config
|
||||
error rather than silently rerouting to the keyless Parallel default (which
|
||||
would send user queries to a different provider and hide the
|
||||
misconfiguration). This matches ``web_search_registry``'s "explicit config
|
||||
wins" rule. Only an *unset* value falls through to ``_get_backend()``.
|
||||
Reads ``web.{capability}_backend`` from config; if set and available,
|
||||
uses it. Otherwise falls through to the shared ``_get_backend()``.
|
||||
"""
|
||||
cfg = _load_web_config()
|
||||
specific = (cfg.get(f"{capability}_backend") or "").lower().strip()
|
||||
if specific:
|
||||
if specific and _is_backend_available(specific):
|
||||
return specific
|
||||
return _get_backend(capability)
|
||||
return _get_backend()
|
||||
|
||||
|
||||
def _is_backend_available(backend: str) -> bool:
|
||||
@@ -256,8 +216,6 @@ def _is_backend_available(backend: str) -> bool:
|
||||
if backend == "exa":
|
||||
return _has_env("EXA_API_KEY")
|
||||
if backend == "parallel":
|
||||
# Credential probe: True only with a real key. The keyless free-MCP
|
||||
# fallback is handled by _get_backend()'s terminal default, not here.
|
||||
return _has_env("PARALLEL_API_KEY")
|
||||
if backend == "firecrawl":
|
||||
return check_firecrawl_api_key()
|
||||
@@ -810,17 +768,6 @@ def _ensure_web_plugins_loaded() -> None:
|
||||
Mirrors :func:`tools.browser_tool._ensure_browser_plugins_loaded` exactly:
|
||||
the underlying discovery call is idempotent and cheap on subsequent
|
||||
invocations.
|
||||
|
||||
Triggering discovery is necessary but not *sufficient*: the sweep can
|
||||
finish without registering the bundled web providers (its exception
|
||||
swallowed below as a warning, a packaged layout where discovery ran before
|
||||
the bundled tree was importable, or a stale empty-discovery cache). When
|
||||
that happens the registry is empty and *both* web_search and web_extract
|
||||
dead-end on "No web {search,extract} provider configured" — even though the
|
||||
keyless Parallel default is supposed to work with zero setup. So after
|
||||
discovery we verify the keyless default landed and, if not, register the
|
||||
bundled providers directly (see
|
||||
:func:`_register_bundled_web_providers_directly`).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
@@ -833,87 +780,6 @@ def _ensure_web_plugins_loaded() -> None:
|
||||
# clue in normal logs about the real cause.
|
||||
logger.warning("Web plugin discovery failed (non-fatal): %s", exc)
|
||||
|
||||
# Belt-and-suspenders: guarantee the keyless Parallel default (the
|
||||
# documented zero-setup backend for both web_search and web_extract) is
|
||||
# actually registered. The lookup is a cheap dict hit on the healthy path
|
||||
# (discovery already registered it → no-op); only an empty registry pays
|
||||
# for the direct-registration sweep.
|
||||
try:
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
if get_provider("parallel") is None:
|
||||
_register_bundled_web_providers_directly()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Bundled web provider fallback check failed: %s", exc)
|
||||
|
||||
|
||||
def _register_bundled_web_providers_directly() -> None:
|
||||
"""Register the repo's bundled web providers without the plugin manager.
|
||||
|
||||
The normal path is the general plugin sweep
|
||||
(:func:`hermes_cli.plugins._ensure_plugins_discovered`), which auto-loads
|
||||
every ``plugins/web/<name>`` backend (they are ``kind: backend``). This
|
||||
fallback exists for the runtimes where that sweep does not leave the web
|
||||
registry populated — so the keyless Parallel default (and any bundled
|
||||
backend the user explicitly configured) keeps working instead of
|
||||
surfacing a misleading "No web provider configured" error.
|
||||
|
||||
Imports each bundled ``plugins/web/<name>`` package and calls its
|
||||
``register()`` directly against :mod:`agent.web_search_registry`. Idempotent
|
||||
(re-register overwrites) and honors an explicit ``plugins.disabled`` entry
|
||||
so a backend the user turned off stays off.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.plugins import (
|
||||
_get_disabled_plugins,
|
||||
get_bundled_plugins_dir,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Bundled web provider fallback unavailable: %s", exc)
|
||||
return
|
||||
|
||||
web_dir = get_bundled_plugins_dir() / "web"
|
||||
if not web_dir.is_dir():
|
||||
return
|
||||
|
||||
disabled = _get_disabled_plugins()
|
||||
|
||||
from agent.web_search_provider import WebSearchProvider
|
||||
from agent.web_search_registry import register_provider
|
||||
|
||||
class _DirectRegistrationCtx:
|
||||
"""Minimal plugin ctx exposing only web-provider registration."""
|
||||
|
||||
def register_web_search_provider(self, provider) -> None:
|
||||
if isinstance(provider, WebSearchProvider):
|
||||
register_provider(provider)
|
||||
|
||||
ctx = _DirectRegistrationCtx()
|
||||
import importlib
|
||||
|
||||
for child in sorted(web_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if not (child / "plugin.yaml").exists() and not (child / "plugin.yml").exists():
|
||||
continue
|
||||
# Respect an explicit disable — match discover_and_load's key/name
|
||||
# check (key ``web/<dir>``; manifest name ``web-<dir-with-dashes>``).
|
||||
if (
|
||||
f"web/{child.name}" in disabled
|
||||
or f"web-{child.name.replace('_', '-')}" in disabled
|
||||
):
|
||||
continue
|
||||
try:
|
||||
module = importlib.import_module(f"plugins.web.{child.name}")
|
||||
register_fn = getattr(module, "register", None)
|
||||
if callable(register_fn):
|
||||
register_fn(ctx)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug(
|
||||
"Direct registration of bundled web provider '%s' failed: %s",
|
||||
child.name, exc,
|
||||
)
|
||||
|
||||
|
||||
def web_search_tool(query: str, limit: int = 5) -> str:
|
||||
"""
|
||||
@@ -1103,19 +969,11 @@ async def web_extract_tool(
|
||||
else:
|
||||
safe_urls.append(url)
|
||||
|
||||
# Tracks the free-tier Parallel extract path (no key → web_fetch via the
|
||||
# hosted Search MCP) so we can credit Parallel in the output/UI. Bound
|
||||
# here so empty/all-blocked inputs (which skip dispatch) stay defined.
|
||||
_free_parallel_extract = False
|
||||
|
||||
# Dispatch only safe URLs to the configured backend
|
||||
if not safe_urls:
|
||||
results = []
|
||||
else:
|
||||
backend = _get_extract_backend()
|
||||
_free_parallel_extract = (
|
||||
backend == "parallel" and not _has_env("PARALLEL_API_KEY")
|
||||
)
|
||||
|
||||
# All seven providers (brave-free, ddgs, searxng, exa, parallel,
|
||||
# tavily, firecrawl) now live as plugins. The dispatcher is a
|
||||
@@ -1289,14 +1147,6 @@ async def web_extract_tool(
|
||||
for r in response.get("results", [])
|
||||
]
|
||||
trimmed_response = {"results": trimmed_results}
|
||||
if _free_parallel_extract:
|
||||
# Credit Parallel's free Search MCP (drives the "[Parallel]" UI tag
|
||||
# + lets the model cite the source). Free tier only.
|
||||
trimmed_response["provider"] = "parallel"
|
||||
trimmed_response["attribution"] = (
|
||||
"Extraction powered by the free Parallel Web Search MCP "
|
||||
"(https://parallel.ai)."
|
||||
)
|
||||
|
||||
if trimmed_response.get("results") == []:
|
||||
result_json = tool_error("Content was inaccessible or not found")
|
||||
@@ -1328,61 +1178,16 @@ async def web_extract_tool(
|
||||
return tool_error(error_msg)
|
||||
|
||||
|
||||
def web_tools_registered() -> bool:
|
||||
"""Whether the web tools should be registered. Always True.
|
||||
|
||||
Registration is decoupled from credential readiness: with no credentials,
|
||||
search/extract fall back to Parallel's free hosted Search MCP, and an
|
||||
explicitly configured-but-unavailable backend must stay registered so
|
||||
dispatch surfaces that backend's own setup error rather than the tool
|
||||
silently vanishing. For "is web actually configured?" use
|
||||
:func:`check_web_api_key`.
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
def _parallel_provider_registered() -> bool:
|
||||
"""True when the bundled ``web-parallel`` provider is registered/enabled.
|
||||
|
||||
Plugin discovery skips disabled plugins, so a disabled (``plugins.disabled``)
|
||||
or otherwise-unregistered parallel provider yields ``None`` here.
|
||||
"""
|
||||
_ensure_web_plugins_loaded()
|
||||
try:
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
return get_provider("parallel") is not None
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def _backend_usable(backend: str) -> bool:
|
||||
"""True when *backend* can service calls. Keyless Parallel counts (free MCP).
|
||||
|
||||
Unknown/typo'd backend names are not usable (so an explicit typo is reported
|
||||
as a config problem rather than masked by the keyless fallback).
|
||||
"""
|
||||
if backend == "parallel" and not _has_env("PARALLEL_API_KEY"):
|
||||
# Keyless Parallel is only genuinely usable when its provider is actually
|
||||
# registered/enabled. If web-parallel is disabled or discovery failed,
|
||||
# report unusable so setup is not skipped and the user is not left with
|
||||
# web tools that fail at runtime ("No web search provider configured").
|
||||
return _parallel_provider_registered()
|
||||
return _is_backend_available(backend)
|
||||
|
||||
|
||||
# Convenience function to check Firecrawl credentials
|
||||
def check_web_api_key() -> bool:
|
||||
"""Usability probe: True when the selected web backends can service calls.
|
||||
|
||||
Probes the backends that :func:`_get_search_backend` /
|
||||
:func:`_get_extract_backend` actually select (not just shared
|
||||
``web.backend``), so an explicit per-capability backend with missing
|
||||
credentials — or a typo'd name — reports unusable instead of being masked by
|
||||
the keyless Parallel fallback. Keyless Parallel itself genuinely services
|
||||
calls, so a zero-setup install reports usable. Distinct from
|
||||
:func:`web_tools_registered` (always True — whether the tool is offered).
|
||||
"""
|
||||
return _backend_usable(_get_search_backend()) and _backend_usable(_get_extract_backend())
|
||||
"""Check whether the configured web backend is available."""
|
||||
configured = _load_web_config().get("backend", "").lower().strip()
|
||||
if configured in {"exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs", "xai"}:
|
||||
return _is_backend_available(configured)
|
||||
return any(
|
||||
_is_backend_available(backend)
|
||||
for backend in ("exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs", "xai")
|
||||
)
|
||||
|
||||
|
||||
def check_auxiliary_model() -> bool:
|
||||
@@ -1550,7 +1355,7 @@ registry.register(
|
||||
toolset="web",
|
||||
schema=WEB_SEARCH_SCHEMA,
|
||||
handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=args.get("limit", 5)),
|
||||
check_fn=web_tools_registered,
|
||||
check_fn=check_web_api_key,
|
||||
requires_env=_web_requires_env(),
|
||||
emoji="🔍",
|
||||
max_result_size_chars=100_000,
|
||||
@@ -1561,7 +1366,7 @@ registry.register(
|
||||
schema=WEB_EXTRACT_SCHEMA,
|
||||
handler=lambda args, **kw: web_extract_tool(
|
||||
args.get("urls", [])[:5] if isinstance(args.get("urls"), list) else [], "markdown"),
|
||||
check_fn=web_tools_registered,
|
||||
check_fn=check_web_api_key,
|
||||
requires_env=_web_requires_env(),
|
||||
is_async=True,
|
||||
emoji="📄",
|
||||
|
||||
@@ -1,493 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Write-approval gate + pending store for memory and skill writes.
|
||||
|
||||
Background
|
||||
----------
|
||||
The agent writes to two persistent stores that survive across sessions:
|
||||
|
||||
* **memory** — MEMORY.md / USER.md, small (~200 char) declarative entries
|
||||
* **skills** — SKILL.md + supporting files, potentially huge (10-100 KB)
|
||||
|
||||
Both stores are written from two origins:
|
||||
|
||||
* **foreground** — a normal agent turn (user is present / chatting)
|
||||
* **background_review** — the self-improvement review fork that runs after a
|
||||
turn and autonomously decides what to save (the source of the
|
||||
"wrong assumptions" users complained about)
|
||||
|
||||
This module lets the user gate those writes per-subsystem with a boolean
|
||||
``write_approval``:
|
||||
|
||||
* ``false`` (default) — write freely (the pre-gate behaviour)
|
||||
* ``true`` — require approval: do not commit the write; either
|
||||
prompt inline (memory, interactive CLI only) or **stage** it to a pending
|
||||
store and surface it for the user to approve or reject out-of-band
|
||||
|
||||
The size asymmetry between memory and skills is real and unavoidable: a memory
|
||||
entry can be reviewed inline in a chat bubble; a 100 KB SKILL.md cannot. So
|
||||
the gate stages BOTH to disk, but review affordances differ by subsystem
|
||||
(see ``hermes_cli`` slash handlers): memory shows full content, skills show
|
||||
metadata + a one-line gist + a ``diff`` escape hatch (CLI/dashboard/file).
|
||||
|
||||
Staging is mandatory for background-origin writes (a daemon thread cannot
|
||||
block on an interactive prompt) and for gateway sessions (no inline prompt
|
||||
channel — review happens via ``/memory pending``). Foreground CLI memory
|
||||
writes prompt inline via the dangerous-command approval callback; skill
|
||||
writes always stage (too big to eyeball mid-loop).
|
||||
|
||||
Pending records live under ``<HERMES_HOME>/pending/{memory,skills}/<id>.json``
|
||||
so they survive process restarts and can be reviewed from CLI, gateway, or the
|
||||
web dashboard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Subsystem identifiers
|
||||
MEMORY = "memory"
|
||||
SKILLS = "skills"
|
||||
_SUBSYSTEMS = (MEMORY, SKILLS)
|
||||
|
||||
# Config key (per subsystem). A single boolean: the approval gate is OFF by
|
||||
# default (writes flow freely, the pre-gate behaviour), and ON means stage /
|
||||
# prompt every write for the user's approval. There is intentionally no third
|
||||
# "block all writes" state — to disable a subsystem entirely use its own
|
||||
# enable flag (e.g. ``memory.memory_enabled: false``).
|
||||
CONFIG_KEY = "write_approval"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def write_approval_enabled(subsystem: str) -> bool:
|
||||
"""Return whether the approval gate is enabled for ``subsystem``.
|
||||
|
||||
Reads ``<subsystem>.write_approval`` from config.yaml. Defaults to
|
||||
``False`` (gate off — writes flow freely) for any unset / invalid value so
|
||||
existing installs keep their current behaviour until the user opts in.
|
||||
"""
|
||||
if subsystem not in _SUBSYSTEMS:
|
||||
return False
|
||||
try:
|
||||
from hermes_cli.config import load_config, cfg_get
|
||||
cfg = load_config()
|
||||
raw = cfg_get(cfg, subsystem, CONFIG_KEY, default=False)
|
||||
except Exception:
|
||||
return False
|
||||
return _normalize_enabled(raw)
|
||||
|
||||
|
||||
def _normalize_enabled(value: Any) -> bool:
|
||||
"""Coerce a config value to a bool. Default (unknown) is False (gate off).
|
||||
|
||||
Accepts real bools and the usual truthy/falsey strings. YAML 1.1 parses
|
||||
bare ``on``/``off``/``yes``/``no`` as bools already, so the string branch
|
||||
is mostly for hand-edited configs.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"on", "true", "yes", "1", "approve", "enabled"}
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pending store (file-backed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _pending_dir(subsystem: str) -> Path:
|
||||
return get_hermes_home() / "pending" / subsystem
|
||||
|
||||
|
||||
def stage_write(subsystem: str, payload: Dict[str, Any],
|
||||
*, summary: str, origin: str) -> Dict[str, Any]:
|
||||
"""Persist a pending write and return a short record describing it.
|
||||
|
||||
Args:
|
||||
subsystem: ``memory`` or ``skills``.
|
||||
payload: the exact kwargs needed to replay the write when approved
|
||||
(e.g. ``{"action": "add", "target": "user", "content": "..."}``
|
||||
for memory, or the full ``skill_manage`` kwargs for skills).
|
||||
summary: a one-line human-readable description shown in pending lists.
|
||||
For skills this is the LLM/heuristic gist; for memory it can be the
|
||||
entry text itself.
|
||||
origin: ``foreground`` or ``background_review`` — recorded for audit.
|
||||
|
||||
Returns a dict with ``id`` and metadata. Best-effort: on disk failure it
|
||||
logs and still returns a record (the write is simply lost, which is the
|
||||
safe failure for an approval gate — nothing is silently committed).
|
||||
"""
|
||||
pid = uuid.uuid4().hex[:8]
|
||||
record = {
|
||||
"id": pid,
|
||||
"subsystem": subsystem,
|
||||
"action": payload.get("action", ""),
|
||||
"summary": (summary or "").strip(),
|
||||
"origin": origin or "foreground",
|
||||
"created_at": time.time(),
|
||||
"payload": payload,
|
||||
}
|
||||
try:
|
||||
d = _pending_dir(subsystem)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
path = d / f"{pid}.json"
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
except Exception as e: # pragma: no cover - disk failure path
|
||||
logger.error("Failed to stage pending %s write: %s", subsystem, e, exc_info=True)
|
||||
return record
|
||||
|
||||
|
||||
def list_pending(subsystem: str) -> List[Dict[str, Any]]:
|
||||
"""Return all pending records for ``subsystem``, oldest first."""
|
||||
d = _pending_dir(subsystem)
|
||||
if not d.exists():
|
||||
return []
|
||||
records: List[Dict[str, Any]] = []
|
||||
for p in d.glob("*.json"):
|
||||
try:
|
||||
records.append(json.loads(p.read_text(encoding="utf-8")))
|
||||
except Exception:
|
||||
logger.warning("Skipping unreadable pending record: %s", p)
|
||||
records.sort(key=lambda r: r.get("created_at", 0))
|
||||
return records
|
||||
|
||||
|
||||
def get_pending(subsystem: str, pending_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return a single pending record by id, or None."""
|
||||
path = _pending_dir(subsystem) / f"{pending_id}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def discard_pending(subsystem: str, pending_id: str) -> bool:
|
||||
"""Delete a pending record. Returns True if it existed."""
|
||||
path = _pending_dir(subsystem) / f"{pending_id}.json"
|
||||
try:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
return True
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error("Failed to discard pending %s/%s: %s", subsystem, pending_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def pending_count(subsystem: str) -> int:
|
||||
"""Cheap count of pending records (for notification badges)."""
|
||||
d = _pending_dir(subsystem)
|
||||
if not d.exists():
|
||||
return 0
|
||||
try:
|
||||
return sum(1 for _ in d.glob("*.json"))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write origin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def current_origin() -> str:
|
||||
"""Return the active write origin: ``foreground`` or ``background_review``.
|
||||
|
||||
Reuses the skill-provenance ContextVar, which the background review fork
|
||||
already sets (see ``agent.background_review`` /
|
||||
``AIAgent._spawn_background_review``). Foreground agent turns leave it at
|
||||
the default ``foreground``.
|
||||
"""
|
||||
try:
|
||||
from tools.skill_provenance import get_current_write_origin
|
||||
return get_current_write_origin()
|
||||
except Exception:
|
||||
return "foreground"
|
||||
|
||||
|
||||
def is_background() -> bool:
|
||||
return current_origin() == "background_review"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate decision
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class GateDecision:
|
||||
"""Result of evaluating the write gate for a single write attempt.
|
||||
|
||||
Exactly one of the boolean flags is True:
|
||||
* ``allow`` — proceed with the real write (gate off, or an inline
|
||||
approval was granted).
|
||||
* ``blocked`` — refuse the write (the user denied an inline approval
|
||||
prompt). ``message`` explains why; surface it to the agent.
|
||||
* ``stage`` — do not write; the caller should stage the payload via
|
||||
``stage_write`` (gate on, and no inline prompt is available — gateway,
|
||||
background review, script, or any skill write). ``message`` is the
|
||||
user-facing "staged for approval" note.
|
||||
"""
|
||||
|
||||
__slots__ = ("allow", "blocked", "stage", "message")
|
||||
|
||||
def __init__(self, *, allow=False, blocked=False, stage=False, message=""):
|
||||
self.allow = allow
|
||||
self.blocked = blocked
|
||||
self.stage = stage
|
||||
self.message = message
|
||||
|
||||
|
||||
def evaluate_gate(subsystem: str, *, inline_summary: str = "",
|
||||
inline_detail: str = "") -> GateDecision:
|
||||
"""Decide what to do with a pending write for ``subsystem``.
|
||||
|
||||
Args:
|
||||
subsystem: ``memory`` or ``skills``.
|
||||
inline_summary: short description used as the inline approval prompt
|
||||
header (memory foreground path only).
|
||||
inline_detail: full content shown in the inline prompt (memory entries
|
||||
are small; skills never take the inline path).
|
||||
|
||||
Decision matrix:
|
||||
gate off (default) → allow (writes flow freely)
|
||||
gate on, memory + interactive CLI → inline approve/deny prompt
|
||||
gate on, memory + gateway/script/bg → stage
|
||||
gate on, skills (any origin) → stage (too big to review inline)
|
||||
|
||||
Note: there is no config-driven "blocked" outcome — the gate only ever
|
||||
delays a write for approval, never silently refuses it. ``blocked`` is
|
||||
still produced when the user *actively denies* an inline prompt.
|
||||
"""
|
||||
if not write_approval_enabled(subsystem):
|
||||
return GateDecision(allow=True)
|
||||
|
||||
background = is_background()
|
||||
|
||||
# Skills always stage — a SKILL.md is too large to review inline, and a
|
||||
# background skill write happens in a daemon thread with no user present.
|
||||
if subsystem == SKILLS or background:
|
||||
where = "/skills pending" if subsystem == SKILLS else "/memory pending"
|
||||
return GateDecision(
|
||||
stage=True,
|
||||
message=(
|
||||
f"Staged for approval ({subsystem}.write_approval is on). "
|
||||
f"Not yet saved — review with {where}."
|
||||
),
|
||||
)
|
||||
|
||||
# Memory + foreground: if an interactive approval channel exists (a CLI
|
||||
# approval callback registered on this thread), prompt inline — entries
|
||||
# are small enough to show in full. Otherwise (gateway, script, batch,
|
||||
# no listener) stage instead of forcing a blind deny.
|
||||
if _interactive_approval_available():
|
||||
granted = _prompt_inline_memory_approval(inline_summary, inline_detail)
|
||||
if granted is True:
|
||||
return GateDecision(allow=True)
|
||||
if granted is False:
|
||||
return GateDecision(
|
||||
blocked=True,
|
||||
message="Memory write denied by user. The change was not saved.",
|
||||
)
|
||||
# granted is None → prompt failed; fall through to staging.
|
||||
|
||||
return GateDecision(
|
||||
stage=True,
|
||||
message=(
|
||||
"Staged for approval (memory.write_approval is on). "
|
||||
"Not yet saved — review with /memory pending."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _interactive_approval_available() -> bool:
|
||||
"""True when a foreground memory write can be approved inline.
|
||||
|
||||
Inline prompting requires a per-thread approval callback registered by the
|
||||
interactive CLI (``tools.terminal_tool.set_approval_callback``). Every
|
||||
other surface stages instead:
|
||||
|
||||
* **Gateway/API sessions** — the dangerous-command ``/approve`` round-trip
|
||||
lives in the pending-approval queue (``submit_pending`` +
|
||||
``_await_gateway_decision``), which ``prompt_dangerous_approval`` never
|
||||
reaches; trying to prompt from a gateway session would hit the
|
||||
``input()`` fallback and silently deny. Staging gives the user a real
|
||||
review affordance (``/memory pending``) instead.
|
||||
* Scripts, cron, and background threads — no user present.
|
||||
"""
|
||||
try:
|
||||
from tools.terminal_tool import _get_approval_callback
|
||||
return _get_approval_callback() is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _prompt_inline_memory_approval(summary: str, detail: str) -> Optional[bool]:
|
||||
"""Prompt the user inline to approve a memory write.
|
||||
|
||||
Returns True (approved), False (denied), or None (no interactive prompt
|
||||
available / prompt failed → caller should stage instead).
|
||||
|
||||
Reuses the per-thread CLI approval callback registered for dangerous
|
||||
commands (``tools.terminal_tool.set_approval_callback``). The callback is
|
||||
invoked directly — NOT via ``prompt_dangerous_approval`` — because that
|
||||
wrapper falls back to ``input()`` (deadlock-prone under prompt_toolkit,
|
||||
see #15216) and converts callback errors into a silent deny; here a
|
||||
failed prompt must stage the write instead.
|
||||
"""
|
||||
try:
|
||||
from tools.terminal_tool import _get_approval_callback
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
callback = _get_approval_callback()
|
||||
if callback is None:
|
||||
# No interactive channel on this thread — stage rather than risk the
|
||||
# input() fallback (deadlock under prompt_toolkit, EOF-deny in tests).
|
||||
return None
|
||||
|
||||
header = summary.strip() or "Save to memory?"
|
||||
body = detail.strip()
|
||||
description = f"Save to memory: {header}"
|
||||
command = body if body else header
|
||||
# Invoke the callback directly instead of via prompt_dangerous_approval:
|
||||
# that wrapper swallows callback exceptions into "deny", which would
|
||||
# silently refuse the write. Direct invocation lets a crashed prompt fall
|
||||
# back to staging (the gate only ever delays a write, never drops it).
|
||||
try:
|
||||
choice = callback(command, description, allow_permanent=False)
|
||||
except Exception as e:
|
||||
logger.error("Inline memory approval prompt failed: %s", e)
|
||||
return None
|
||||
|
||||
if choice in {"once", "session"}:
|
||||
return True
|
||||
if choice == "deny":
|
||||
return False
|
||||
# Any other outcome (e.g. timeout that returns "deny" already handled) →
|
||||
# treat unknown as no-decision so we stage rather than silently drop.
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skill-specific helpers (gist + diff for the review affordances)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def skill_gist(action: str, name: str, *, content: str = "",
|
||||
file_path: str = "", old_string: str = "",
|
||||
new_string: str = "") -> str:
|
||||
"""Build a one-line human gist for a pending skill write.
|
||||
|
||||
Heuristic, no model call — the gist surfaces enough to decide approve/reject
|
||||
in a chat bubble, while the full diff stays behind /skills diff (CLI/
|
||||
dashboard/file). For create/edit it pulls the frontmatter ``description:``;
|
||||
for patch/write_file it describes the size of the change.
|
||||
"""
|
||||
if action in {"create", "edit"} and content:
|
||||
desc = _frontmatter_description(content)
|
||||
size = f"{len(content) // 1024 + 1} KB" if len(content) >= 1024 else f"{len(content)} chars"
|
||||
verb = "create" if action == "create" else "rewrite"
|
||||
if desc:
|
||||
return f"{verb} '{name}' — {desc} ({size})"
|
||||
return f"{verb} '{name}' ({size})"
|
||||
if action == "patch":
|
||||
target = file_path or "SKILL.md"
|
||||
removed = old_string.count("\n") + 1 if old_string else 0
|
||||
added = new_string.count("\n") + 1 if new_string else 0
|
||||
return f"patch '{name}' {target} (+{added}/-{removed} lines)"
|
||||
if action == "write_file":
|
||||
return f"write {file_path} in '{name}'"
|
||||
if action == "remove_file":
|
||||
return f"remove {file_path} from '{name}'"
|
||||
if action == "delete":
|
||||
return f"delete skill '{name}'"
|
||||
return f"{action} '{name}'"
|
||||
|
||||
|
||||
def _frontmatter_description(content: str) -> str:
|
||||
"""Extract the ``description:`` value from SKILL.md YAML frontmatter."""
|
||||
import re
|
||||
m = re.search(r"^description:\s*(.+)$", content, re.MULTILINE)
|
||||
if not m:
|
||||
return ""
|
||||
desc = m.group(1).strip().strip("'\"")
|
||||
return desc[:140]
|
||||
|
||||
|
||||
def skill_pending_diff(record: Dict[str, Any]) -> str:
|
||||
"""Build a full unified diff (or full content) for a staged skill write.
|
||||
|
||||
Used by /skills diff <id> on a surface that can render it (CLI pager, web
|
||||
dashboard, or by opening the pending JSON file). For create this is the new
|
||||
file content; for edit/patch it is a unified diff against the current
|
||||
on-disk skill.
|
||||
"""
|
||||
import difflib
|
||||
payload = record.get("payload", {})
|
||||
action = payload.get("action", "")
|
||||
name = payload.get("name", "")
|
||||
|
||||
if action == "create":
|
||||
return (payload.get("content") or "")
|
||||
|
||||
# Resolve current on-disk content for diffable actions.
|
||||
try:
|
||||
from tools.skill_manager_tool import _find_skill
|
||||
except Exception:
|
||||
_find_skill = None # type: ignore
|
||||
|
||||
current = ""
|
||||
target_label = "SKILL.md"
|
||||
if _find_skill is not None:
|
||||
found = _find_skill(name)
|
||||
if found:
|
||||
base = found["path"]
|
||||
if action == "edit":
|
||||
p = base / "SKILL.md"
|
||||
elif action in {"patch", "write_file"}:
|
||||
rel = payload.get("file_path") or "SKILL.md"
|
||||
p = base / rel
|
||||
target_label = rel
|
||||
else:
|
||||
p = base / "SKILL.md"
|
||||
try:
|
||||
if p.exists():
|
||||
current = p.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
current = ""
|
||||
|
||||
if action == "edit":
|
||||
new = payload.get("content") or ""
|
||||
elif action == "patch":
|
||||
old_s = payload.get("old_string") or ""
|
||||
new_s = payload.get("new_string") or ""
|
||||
new = current.replace(old_s, new_s) if current else f"(patch {old_s!r} → {new_s!r})"
|
||||
elif action == "write_file":
|
||||
new = payload.get("file_content") or ""
|
||||
elif action == "remove_file":
|
||||
return f"remove file: {payload.get('file_path')} from skill '{name}'"
|
||||
elif action == "delete":
|
||||
return f"delete skill '{name}'"
|
||||
else:
|
||||
return f"({action} on '{name}')"
|
||||
|
||||
diff = difflib.unified_diff(
|
||||
current.splitlines(keepends=True),
|
||||
new.splitlines(keepends=True),
|
||||
fromfile=f"a/{target_label}",
|
||||
tofile=f"b/{target_label}",
|
||||
)
|
||||
text = "".join(diff)
|
||||
return text or "(no textual change)"
|
||||
Reference in New Issue
Block a user